Skip to content

Commit

Permalink
Merge pull request #72 from crashtech/v2/schemas
Browse files Browse the repository at this point in the history
Add support for multiple schemas
  • Loading branch information
Carlos authored Dec 25, 2022
2 parents 9ddc46b + a1c3cab commit 86f4fa5
Show file tree
Hide file tree
Showing 20 changed files with 376 additions and 61 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@
* [TODO](https://github.com/crashtech/torque-postgresql/wiki/TODO)

# Description
`torque-postgresql` is a plugin that enhances Ruby on Rails enabling easy access to existing PostgreSQL advanced resources, such as data types and queries statements. Its features are designed to be as similar to Rails architecture and they work as smoothly as possible.
`torque-postgresql` is a plugin that enhances Ruby on Rails enabling easy access to existing PostgreSQL advanced resources, such as data types and query statements. Its features are designed to be similar to Rails architecture and work as smoothly as possible.

100% plug-and-play, with optional configurations, so that can be adapted to your project's design pattern.
Fully compatible with `schema.rb` and 100% plug-and-play, with optional configurations, so that it can be adapted to your project's design pattern.

# Installation

To install torque-postgresql you need to add the following to your Gemfile:
```ruby
gem 'torque-postgresql', '~> 1.1' # For Rails < 6.0
gem 'torque-postgresql', '~> 2.0' # For Rails >= 6.0 < 6.1
gem 'torque-postgresql', '~> 2.0.4' # For Rails >= 6.1
```
Expand Down Expand Up @@ -48,25 +47,26 @@ These are the currently available features:

## Data types

* [Box](https://github.com/crashtech/torque-postgresql/wiki/Box)
* [Circle](https://github.com/crashtech/torque-postgresql/wiki/Circle)
* [Date/Time Range](https://github.com/crashtech/torque-postgresql/wiki/Date-Time-Range)
* [Enum](https://github.com/crashtech/torque-postgresql/wiki/Enum)
* [EnumSet](https://github.com/crashtech/torque-postgresql/wiki/Enum-Set)
* [Interval](https://github.com/crashtech/torque-postgresql/wiki/Interval)
* [Date/Time Range](https://github.com/crashtech/torque-postgresql/wiki/Date-Time-Range)
* [Box](https://github.com/crashtech/torque-postgresql/wiki/Box)
* [Circle](https://github.com/crashtech/torque-postgresql/wiki/Circle)
* [Line](https://github.com/crashtech/torque-postgresql/wiki/Line)
* [Segment](https://github.com/crashtech/torque-postgresql/wiki/Segment)

## Querying

* [Arel](https://github.com/crashtech/torque-postgresql/wiki/Arel)
* [Has Many](https://github.com/crashtech/torque-postgresql/wiki/Has-Many)
* [Auxiliary Statements](https://github.com/crashtech/torque-postgresql/wiki/Auxiliary-Statements)
* [Belongs to Many](https://github.com/crashtech/torque-postgresql/wiki/Belongs-to-Many)
* [Dynamic Attributes](https://github.com/crashtech/torque-postgresql/wiki/Dynamic-Attributes)
* [Distinct On](https://github.com/crashtech/torque-postgresql/wiki/Distinct-On)
* [Insert All](https://github.com/crashtech/torque-postgresql/wiki/Insert-All)
* [Auxiliary Statements](https://github.com/crashtech/torque-postgresql/wiki/Auxiliary-Statements)
* [Dynamic Attributes](https://github.com/crashtech/torque-postgresql/wiki/Dynamic-Attributes)
* [Has Many](https://github.com/crashtech/torque-postgresql/wiki/Has-Many)
* [Inherited Tables](https://github.com/crashtech/torque-postgresql/wiki/Inherited-Tables)
* [Insert All](https://github.com/crashtech/torque-postgresql/wiki/Insert-All)
* [Multiple Schemas](https://github.com/crashtech/torque-postgresql/wiki/Multiple-Schemas)

# How to Contribute

Expand Down
17 changes: 17 additions & 0 deletions README.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ reconfigured on the model, and then can be used during querying process.

{Learn more}[link:classes/Torque/PostgreSQL/AuxiliaryStatement.html]

* Multiple Schemas

Allows models and modules to have a schema associated with them, so that
developers can better organize their tables into schemas and build features in
a way that the database can better represent how they are separated.

create_schema "internal", force: :cascade

module Internal
class User < ActiveRecord::Base
self.schema = 'internal'
end
end

Internal::User.all

{Learn more}[link:classes/Torque/PostgreSQL/Adapter/DatabaseStatements.html]

== Download and installation

Expand Down
3 changes: 2 additions & 1 deletion lib/torque/postgresql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
require 'torque/postgresql/attributes'
require 'torque/postgresql/autosave_association'
require 'torque/postgresql/auxiliary_statement'
require 'torque/postgresql/base'
require 'torque/postgresql/inheritance'
require 'torque/postgresql/base'# Needs to be after inheritance
require 'torque/postgresql/insert_all'
require 'torque/postgresql/migration'
require 'torque/postgresql/relation'
require 'torque/postgresql/reflection'
require 'torque/postgresql/schema_cache'
require 'torque/postgresql/table_name'

require 'torque/postgresql/railtie' if defined?(Rails)
4 changes: 2 additions & 2 deletions lib/torque/postgresql/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ def version
)
end

# Add `inherits` to the list of extracted table options
# Add `inherits` and `schema` to the list of extracted table options
def extract_table_options!(options)
super.merge(options.extract!(:inherits))
super.merge(options.extract!(:inherits, :schema))
end

# Allow filtered bulk insert by adding the where clause. This method is
Expand Down
68 changes: 61 additions & 7 deletions lib/torque/postgresql/adapter/database_statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ def dump_mode!
@_dump_mode = !!!@_dump_mode
end

# List of schemas blocked by the application in the current connection
def schemas_blacklist
@schemas_blacklist ||= Torque::PostgreSQL.config.schemas.blacklist +
(@config.dig(:schemas, 'blacklist') || [])
end

# List of schemas used by the application in the current connection
def schemas_whitelist
@schemas_whitelist ||= Torque::PostgreSQL.config.schemas.whitelist +
(@config.dig(:schemas, 'whitelist') || [])
end

# A list of schemas on the search path sanitized
def schemas_search_path_sanitized
@schemas_search_path_sanitized ||= begin
db_user = @config[:username] || ENV['USER'] || ENV['USERNAME']
schema_search_path.split(',').map { |item| item.strip.sub('"$user"', db_user) }
end
end

# Check if a given type is valid.
def valid_type?(type)
super || extended_types.include?(type)
Expand All @@ -22,6 +42,17 @@ def extended_types
EXTENDED_DATABASE_TYPES
end

# Checks if a given schema exists in the database. If +filtered+ is
# given as false, then it will check regardless of whitelist and
# blacklist
def schema_exists?(name, filtered: true)
return user_defined_schemas.include?(name.to_s) if filtered

query_value(<<-SQL) == 1
SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = '#{name}'
SQL
end

# Returns true if type exists.
def type_exists?(name)
user_defined_types.key? name.to_s
Expand Down Expand Up @@ -115,18 +146,41 @@ def user_defined_types(*categories)
# Get the list of inherited tables associated with their parent tables
def inherited_tables
tables = query(<<-SQL, 'SCHEMA')
SELECT child.relname AS table_name,
array_agg(parent.relname) AS inheritances
SELECT inhrelid::regclass AS table_name,
inhparent::regclass AS inheritances
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
GROUP BY child.relname, pg_inherits.inhrelid
ORDER BY pg_inherits.inhrelid
ORDER BY inhrelid
SQL

tables.map do |(table, refs)|
[table, PG::TextDecoder::Array.new.decode(refs)]
end.to_h
tables.each_with_object({}) do |(child, parent), result|
(result[child] ||= []) << parent
end
end

# Get the list of schemas that were created by the user
def user_defined_schemas
query_values(user_defined_schemas_sql, 'SCHEMA')
end

# Build the query for allowed schemas
def user_defined_schemas_sql
conditions = []
conditions << <<-SQL if schemas_blacklist.any?
nspname NOT LIKE ANY (ARRAY['#{schemas_blacklist.join("', '")}'])
SQL

conditions << <<-SQL if schemas_whitelist.any?
nspname LIKE ANY (ARRAY['#{schemas_whitelist.join("', '")}'])
SQL

<<-SQL.squish
SELECT nspname
FROM pg_catalog.pg_namespace
WHERE 1=1 AND #{conditions.join(' AND ')}
ORDER BY oid
SQL
end

# Get the list of columns, and their definition, but only from the
Expand Down
21 changes: 19 additions & 2 deletions lib/torque/postgresql/adapter/schema_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def dump(stream) # :nodoc:

def extensions(stream) # :nodoc:
super
user_defined_schemas(stream)
user_defined_types(stream)
end

Expand Down Expand Up @@ -41,7 +42,9 @@ def schema_enum_type(column)

def tables(stream) # :nodoc:
inherited_tables = @connection.inherited_tables
sorted_tables = @connection.tables.sort - @connection.views
sorted_tables = (@connection.tables - @connection.views).sort_by do |table_name|
table_name.split(/(?:public)?\./).reverse
end

stream.puts " # These are the common tables"
(sorted_tables - inherited_tables.keys).each do |table_name|
Expand All @@ -58,7 +61,7 @@ def tables(stream) # :nodoc:

# Add the inherits setting
sub_stream.rewind
inherits.map!(&:to_sym)
inherits.map! { |parent| parent.to_s.sub(/\Apublic\./, '') }
inherits = inherits.first if inherits.size === 1
inherits = ", inherits: #{inherits.inspect} do |t|"
table_dump = sub_stream.read.gsub(/ do \|t\|$/, inherits)
Expand All @@ -84,6 +87,20 @@ def tables(stream) # :nodoc:
triggers(stream) if defined?(::Fx::SchemaDumper::Trigger)
end

# Make sure to remove the schema from the table name
def remove_prefix_and_suffix(table)
super(table.sub(/\A[a-z0-9_]*\./, ''))
end

# Dump user defined schemas
def user_defined_schemas(stream)
return if (list = (@connection.user_defined_schemas - ['public'])).empty?

stream.puts " # Custom schemas defined in this database."
list.each { |name| stream.puts " create_schema \"#{name}\", force: :cascade" }
stream.puts
end

# Dump user defined types like enum
def user_defined_types(stream)
types = @connection.user_defined_types('e')
Expand Down
40 changes: 40 additions & 0 deletions lib/torque/postgresql/adapter/schema_statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ module SchemaStatements

TableDefinition = ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition

# Create a new schema
def create_schema(name, options = {})
drop_schema(name, options) if options[:force]

check = 'IF NOT EXISTS' if options.fetch(:check, true)
execute("CREATE SCHEMA #{check} #{quote_schema_name(name.to_s)}")
end

# Drop an existing schema
def drop_schema(name, options = {})
force = options.fetch(:force, '').upcase
check = 'IF EXISTS' if options.fetch(:check, true)
execute("DROP SCHEMA #{check} #{quote_schema_name(name.to_s)} #{force}")
end

# Drops a type.
def drop_type(name, options = {})
force = options.fetch(:force, '').upcase
Expand Down Expand Up @@ -79,12 +94,37 @@ def enum_values(name)

# Rewrite the method that creates tables to easily accept extra options
def create_table(table_name, **options, &block)
table_name = "#{options[:schema]}.#{table_name}" if options[:schema].present?

options[:id] = false if options[:inherits].present? &&
options[:primary_key].blank? && options[:id].blank?

super table_name, **options, &block
end

# Add the schema option when extracting table options
def table_options(table_name)
parts = table_name.split('.').reverse
return super unless parts.size == 2 && parts[1] != 'public'

(super || {}).merge(schema: parts[1])
end

# When dumping the schema we need to add all schemas, not only those
# active for the current +schema_search_path+
def quoted_scope(name = nil, type: nil)
return super unless name.nil?

super.merge(schema: "ANY ('{#{user_defined_schemas.join(',')}}')")
end

# Fix the query to include the schema on tables names when dumping
def data_source_sql(name = nil, type: nil)
return super unless name.nil?

super.sub('SELECT c.relname FROM', "SELECT n.nspname || '.' || c.relname FROM")
end

private

def quote_enum_values(name, values, options)
Expand Down
36 changes: 23 additions & 13 deletions lib/torque/postgresql/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,27 @@ module PostgreSQL
module Base
extend ActiveSupport::Concern

##
# :singleton-method: schema
# :call-seq: schema
#
# The schema to which the table belongs to.

included do
mattr_accessor :belongs_to_many_required_by_default, instance_accessor: false
class_attribute :schema, instance_writer: false
end

module ClassMethods
delegate :distinct_on, :with, :itself_only, :cast_records, to: :all

# Wenever it's inherited, add a new list of auxiliary statements
# It also adds an auxiliary statement to load inherited records' relname
# Make sure that table name is an instance of TableName class
def reset_table_name
self.table_name = TableName.new(self, super)
end

# Whenever the base model is inherited, add a list of auxiliary
# statements like the one that loads inherited records' relname
def inherited(subclass)
super

Expand All @@ -24,6 +36,11 @@ def inherited(subclass)

# Define helper methods to return the class of the given records
subclass.auxiliary_statement record_class do |cte|
ActiveSupport::Deprecation.warn(<<~MSG.squish)
Inheritance does not use this auxiliary statement and it can be removed.
You can replace it with `model.select_extra_values << 'tableoid::regclass'`.
MSG

pg_class = ::Arel::Table.new('pg_class')
arel_query = ::Arel::SelectManager.new(pg_class)
arel_query.project(pg_class['oid'], pg_class['relname'].as(record_class.to_s))
Expand All @@ -36,18 +53,11 @@ def inherited(subclass)
# Define the dynamic attribute that returns the same information as
# the one provided by the auxiliary statement
subclass.dynamic_attribute(record_class) do
next self.class.table_name unless self.class.physically_inheritances?

pg_class = ::Arel::Table.new('pg_class')
source = ::Arel::Table.new(subclass.table_name, as: 'source')
quoted_id = ::Arel::Nodes::Quoted.new(id)

query = ::Arel::SelectManager.new(pg_class)
query.join(source).on(pg_class['oid'].eq(source['tableoid']))
query.where(source[subclass.primary_key].eq(quoted_id))
query.project(pg_class['relname'])
klass = self.class
next klass.table_name unless klass.physically_inheritances?

self.class.connection.select_value(query)
query = klass.unscoped.where(subclass.primary_key => id)
query.pluck(klass.arel_table['tableoid'].cast('regclass')).first
end
end

Expand Down
13 changes: 13 additions & 0 deletions lib/torque/postgresql/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ def config.irregular_models=(hash)
end.to_h
end

# Configure multiple schemas
config.nested(:schemas) do |schemas|

# Defines a list of LIKE-based schemas to not consider for a multiple
# schema database
schemas.blacklist = %w[information_schema pg_%]

# Defines a list of LIKE-based schemas to consider for a multiple schema
# database
schemas.whitelist = %w[public]

end

# Configure associations features
config.nested(:associations) do |assoc|

Expand Down
Loading

0 comments on commit 86f4fa5

Please sign in to comment.