activerecord-multi-tenant-next 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +264 -0
- data/LICENSE +18 -0
- data/README.md +121 -0
- data/lib/activerecord-multi-tenant/arel_visitors_depth_first.rb +209 -0
- data/lib/activerecord-multi-tenant/controller_extensions.rb +36 -0
- data/lib/activerecord-multi-tenant/copy_from_client.rb +39 -0
- data/lib/activerecord-multi-tenant/fast_truncate.rb +38 -0
- data/lib/activerecord-multi-tenant/habtm.rb +49 -0
- data/lib/activerecord-multi-tenant/migrations.rb +134 -0
- data/lib/activerecord-multi-tenant/model_extensions.rb +207 -0
- data/lib/activerecord-multi-tenant/multi_tenant.rb +160 -0
- data/lib/activerecord-multi-tenant/query_monitor.rb +36 -0
- data/lib/activerecord-multi-tenant/query_rewriter.rb +431 -0
- data/lib/activerecord-multi-tenant/relation_extension.rb +90 -0
- data/lib/activerecord-multi-tenant/sidekiq.rb +91 -0
- data/lib/activerecord-multi-tenant/table_node.rb +15 -0
- data/lib/activerecord-multi-tenant/version.rb +5 -0
- data/lib/activerecord-multi-tenant-next.rb +4 -0
- data/lib/activerecord-multi-tenant.rb +3 -0
- data/lib/activerecord_multi_tenant.rb +14 -0
- metadata +238 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiTenant
|
|
4
|
+
# Designed to be mixed into an ActiveRecord model to provide
|
|
5
|
+
# a copy_from_client method that allows for efficient bulk insertion of
|
|
6
|
+
# data into a PostgreSQL database using the COPY command
|
|
7
|
+
class CopyFromClientHelper
|
|
8
|
+
attr_reader :count
|
|
9
|
+
|
|
10
|
+
def initialize(conn, column_types)
|
|
11
|
+
@count = 0
|
|
12
|
+
@conn = conn
|
|
13
|
+
@column_types = column_types
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def <<(row)
|
|
17
|
+
row = row.map.with_index { |val, idx| @column_types[idx].serialize(val) }
|
|
18
|
+
@conn.put_copy_data(row)
|
|
19
|
+
@count += 1
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module CopyFromClient
|
|
24
|
+
def copy_from_client(columns, &block)
|
|
25
|
+
conn = connection.raw_connection
|
|
26
|
+
column_types = columns.map { |c| type_for_attribute(c.to_s) }
|
|
27
|
+
helper = MultiTenant::CopyFromClientHelper.new(conn, column_types)
|
|
28
|
+
conn.copy_data %{COPY #{quoted_table_name}("#{columns.join('","')}") FROM STDIN}, PG::TextEncoder::CopyRow.new do
|
|
29
|
+
block.call helper
|
|
30
|
+
end
|
|
31
|
+
helper.count
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add copy_from_client to ActiveRecord::Base
|
|
37
|
+
ActiveSupport.on_load(:active_record) do |base|
|
|
38
|
+
base.extend(MultiTenant::CopyFromClient)
|
|
39
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Truncates only the tables that have been modified, according to sequence
|
|
4
|
+
# values
|
|
5
|
+
# Faster alternative to DatabaseCleaner.clean_with(:truncation, pre_count: true)
|
|
6
|
+
module MultiTenant
|
|
7
|
+
module FastTruncate
|
|
8
|
+
def self.run(exclude: ['schema_migrations'])
|
|
9
|
+
# This is a slightly faster version of DatabaseCleaner.clean_with(:truncation, pre_count: true)
|
|
10
|
+
ActiveRecord::Base.connection.execute format(%(
|
|
11
|
+
DO LANGUAGE plpgsql $$
|
|
12
|
+
DECLARE
|
|
13
|
+
t record;
|
|
14
|
+
tables text[];
|
|
15
|
+
seq_exists boolean;
|
|
16
|
+
needs_truncate boolean;
|
|
17
|
+
BEGIN
|
|
18
|
+
FOR t IN SELECT schemaname, tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT IN (%s) LOOP
|
|
19
|
+
EXECUTE 'SELECT EXISTS (SELECT * from pg_class c WHERE c.relkind = ''S''
|
|
20
|
+
AND c.relname=''' || t.tablename || '_id_seq'')' into seq_exists;
|
|
21
|
+
IF seq_exists THEN
|
|
22
|
+
EXECUTE 'SELECT is_called FROM ' || t.tablename || '_id_seq' INTO needs_truncate;
|
|
23
|
+
ELSE
|
|
24
|
+
needs_truncate := true;
|
|
25
|
+
END IF;
|
|
26
|
+
|
|
27
|
+
IF needs_truncate THEN
|
|
28
|
+
tables := array_append(tables, quote_ident(t.schemaname) || '.' || quote_ident(t.tablename));
|
|
29
|
+
END IF;
|
|
30
|
+
END LOOP;
|
|
31
|
+
|
|
32
|
+
IF array_length(tables, 1) > 0 THEN
|
|
33
|
+
EXECUTE 'TRUNCATE TABLE ' || array_to_string(tables, ', ') || ' RESTART IDENTITY CASCADE';
|
|
34
|
+
END IF;
|
|
35
|
+
END$$;), exclude.map { |t| "'#{t}'" }.join('\n'))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This module extension is a monkey patch to the ActiveRecord::Associations::ClassMethods module.
|
|
4
|
+
# It overrides the has_and_belongs_to_many method to add the tenant_id to the join table if the
|
|
5
|
+
# tenant_enabled option is set to true.
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module Associations
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# rubocop:disable Naming/PredicateName
|
|
11
|
+
def has_and_belongs_to_many_with_tenant(name, scope = nil, **options, &extension)
|
|
12
|
+
# rubocop:enable Naming/PredicateName
|
|
13
|
+
has_and_belongs_to_many_without_tenant(name, scope, **options, &extension)
|
|
14
|
+
|
|
15
|
+
middle_reflection = _reflect_on_association(name.to_s).through_reflection
|
|
16
|
+
join_model = middle_reflection.klass
|
|
17
|
+
|
|
18
|
+
# get tenant_enabled from options and if it is not set, set it to false
|
|
19
|
+
tenant_enabled = options[:tenant_enabled] || false
|
|
20
|
+
|
|
21
|
+
return unless tenant_enabled
|
|
22
|
+
|
|
23
|
+
tenant_class_name = options[:tenant_class_name]
|
|
24
|
+
tenant_column = options[:tenant_column]
|
|
25
|
+
|
|
26
|
+
match = tenant_column.match(/(\w+)_id/)
|
|
27
|
+
tenant_field_name = match ? match[1] : 'tenant'
|
|
28
|
+
|
|
29
|
+
join_model.class_eval do
|
|
30
|
+
belongs_to tenant_field_name.to_sym, class_name: tenant_class_name
|
|
31
|
+
before_create :tenant_set
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# This method sets the tenant_id on the join table and executes before creation of the join table record.
|
|
36
|
+
define_method :tenant_set do
|
|
37
|
+
return unless tenant_enabled
|
|
38
|
+
raise MultiTenant::MissingTenantError, 'Tenant Id is not set' unless MultiTenant.current_tenant_id
|
|
39
|
+
|
|
40
|
+
send("#{tenant_column}=", MultiTenant.current_tenant_id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
alias has_and_belongs_to_many_without_tenant has_and_belongs_to_many
|
|
46
|
+
alias has_and_belongs_to_many has_and_belongs_to_many_with_tenant
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiTenant
|
|
4
|
+
module MigrationExtensions
|
|
5
|
+
def create_distributed_table(table_name, partition_key)
|
|
6
|
+
return unless citus_version.present?
|
|
7
|
+
|
|
8
|
+
reversible do |dir|
|
|
9
|
+
dir.up do
|
|
10
|
+
execute "SELECT create_distributed_table($$#{table_name}$$, $$#{partition_key}$$)"
|
|
11
|
+
end
|
|
12
|
+
dir.down do
|
|
13
|
+
undistribute_table(table_name)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_reference_table(table_name)
|
|
19
|
+
return unless citus_version.present?
|
|
20
|
+
|
|
21
|
+
reversible do |dir|
|
|
22
|
+
dir.up do
|
|
23
|
+
execute "SELECT create_reference_table($$#{table_name}$$)"
|
|
24
|
+
end
|
|
25
|
+
dir.down do
|
|
26
|
+
undistribute_table(table_name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def undistribute_table(table_name)
|
|
32
|
+
return unless citus_version.present?
|
|
33
|
+
|
|
34
|
+
execute "SELECT undistribute_table($$#{table_name}$$)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rebalance_table_shards
|
|
38
|
+
return unless citus_version.present?
|
|
39
|
+
|
|
40
|
+
execute 'SELECT rebalance_table_shards()'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_on_all_nodes(sql)
|
|
44
|
+
execute sql
|
|
45
|
+
|
|
46
|
+
case citus_version
|
|
47
|
+
when '6.0'
|
|
48
|
+
execute "SELECT citus_run_on_all_workers($$#{sql}$$)" # initial citus_tools.sql with different names
|
|
49
|
+
when nil
|
|
50
|
+
# Do nothing, this is regular Postgres
|
|
51
|
+
else
|
|
52
|
+
# 6.1 and newer
|
|
53
|
+
execute "SELECT run_command_on_workers($$#{sql}$$)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def enable_extension_on_all_nodes(extension)
|
|
58
|
+
execute_on_all_nodes "CREATE EXTENSION IF NOT EXISTS \"#{extension}\""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def citus_version
|
|
62
|
+
execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'").getvalue(0, 0).try(:split, '-').try(:first)
|
|
63
|
+
rescue ArgumentError => e
|
|
64
|
+
raise unless e.message == 'invalid tuple number 0'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
ActiveRecord::Migration.include MultiTenant::MigrationExtensions if defined?(ActiveRecord::Migration)
|
|
70
|
+
|
|
71
|
+
module MultiTenant
|
|
72
|
+
module SchemaStatementsExtensions
|
|
73
|
+
def create_table(table_name, options = {}, &block)
|
|
74
|
+
ret = super(table_name, **options.except(:partition_key), &block)
|
|
75
|
+
if options[:id] != false && options[:partition_key] && options[:partition_key].to_s != 'id'
|
|
76
|
+
execute "ALTER TABLE #{table_name} DROP CONSTRAINT #{table_name}_pkey"
|
|
77
|
+
execute "ALTER TABLE #{table_name} ADD PRIMARY KEY(\"#{options[:partition_key]}\", id)"
|
|
78
|
+
end
|
|
79
|
+
ret
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
ActiveRecord::ConnectionAdapters::SchemaStatements.prepend(MultiTenant::SchemaStatementsExtensions)
|
|
84
|
+
|
|
85
|
+
module MultiTenant
|
|
86
|
+
module SchemaDumperExtensions
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def initialize(connection, options = {})
|
|
90
|
+
super
|
|
91
|
+
|
|
92
|
+
citus_version =
|
|
93
|
+
begin
|
|
94
|
+
ActiveRecord::Migration.citus_version
|
|
95
|
+
rescue StandardError
|
|
96
|
+
# Handle the case where this gem is used with MySQL https://github.com/citusdata/activerecord-multi-tenant/issues/166
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
@distribution_columns =
|
|
100
|
+
if citus_version.present?
|
|
101
|
+
query_to_execute = <<-SQL.strip
|
|
102
|
+
SELECT logicalrelid::regclass AS table_name,
|
|
103
|
+
column_to_column_name(logicalrelid, partkey) AS dist_col_name
|
|
104
|
+
FROM pg_dist_partition
|
|
105
|
+
SQL
|
|
106
|
+
@connection.execute(query_to_execute).to_h do |v|
|
|
107
|
+
[v['table_name'], v['dist_col_name']]
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Support for create_distributed_table & create_reference_table
|
|
115
|
+
def table(table, stream)
|
|
116
|
+
super
|
|
117
|
+
table_name = remove_prefix_and_suffix(table)
|
|
118
|
+
distribution_column = @distribution_columns[table_name]
|
|
119
|
+
if distribution_column
|
|
120
|
+
stream.puts " create_distributed_table(#{table_name.inspect}, #{distribution_column.inspect})"
|
|
121
|
+
stream.puts
|
|
122
|
+
elsif @distribution_columns.key?(table_name)
|
|
123
|
+
stream.puts " create_reference_table(#{table_name.inspect})"
|
|
124
|
+
stream.puts
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
module ActiveRecord
|
|
131
|
+
class SchemaDumper
|
|
132
|
+
prepend MultiTenant::SchemaDumperExtensions
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'multi_tenant'
|
|
4
|
+
|
|
5
|
+
module MultiTenant
|
|
6
|
+
# Extension to the model to allow scoping of models to the current tenant. This is done by adding
|
|
7
|
+
# the multitenant method to the models that need to be scoped. This method is called in the
|
|
8
|
+
# model declaration.
|
|
9
|
+
# Adds scoped_by_tenant? partition_key, primary_key and inherited methods to the model
|
|
10
|
+
module ModelExtensionsClassMethods
|
|
11
|
+
DEFAULT_ID_FIELD = 'id'
|
|
12
|
+
# executes when multi_tenant method is called in the model. This method adds the following
|
|
13
|
+
# methods to the model that calls it.
|
|
14
|
+
# scoped_by_tenant? - returns true if the model is scoped by tenant
|
|
15
|
+
# partition_key - returns the partition key for the model
|
|
16
|
+
# primary_key - returns the primary key for the model
|
|
17
|
+
#
|
|
18
|
+
def multi_tenant(tenant_name, options = {})
|
|
19
|
+
if to_s.underscore.to_sym == tenant_name || (!table_name.nil? && table_name.singularize.to_sym == tenant_name)
|
|
20
|
+
unless MultiTenant.with_write_only_mode_enabled?
|
|
21
|
+
# This is the tenant model itself. Workaround for https://github.com/citusdata/citus/issues/687
|
|
22
|
+
before_create lambda {
|
|
23
|
+
id = if self.class.columns_hash[self.class.primary_key].type == :uuid
|
|
24
|
+
SecureRandom.uuid
|
|
25
|
+
else
|
|
26
|
+
self.class.connection.select_value(
|
|
27
|
+
"SELECT nextval('#{self.class.table_name}_#{self.class.primary_key}_seq'::regclass)"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
self.id ||= id
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
class << self
|
|
35
|
+
def scoped_by_tenant?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Allow partition_key to be set from a superclass if not already set in this class
|
|
40
|
+
def partition_key
|
|
41
|
+
@partition_key ||= ancestors.detect { |k| k.instance_variable_get(:@partition_key) }
|
|
42
|
+
.try(:instance_variable_get, :@partition_key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset_primary_key
|
|
46
|
+
primary_object_keys = Array.wrap(connection.schema_cache.primary_keys(table_name)) - [partition_key]
|
|
47
|
+
|
|
48
|
+
self.primary_key = if primary_object_keys.size == 1
|
|
49
|
+
primary_object_keys.first
|
|
50
|
+
elsif table_name &&
|
|
51
|
+
connection.schema_cache.columns_hash(table_name).include?(DEFAULT_ID_FIELD)
|
|
52
|
+
DEFAULT_ID_FIELD
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inherited(subclass)
|
|
57
|
+
super
|
|
58
|
+
MultiTenant.register_multi_tenant_model(subclass)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
MultiTenant.register_multi_tenant_model(self)
|
|
63
|
+
|
|
64
|
+
@partition_key = options[:partition_key] || MultiTenant.partition_key(tenant_name)
|
|
65
|
+
partition_key = @partition_key
|
|
66
|
+
|
|
67
|
+
# Create an implicit belongs_to association only if tenant class exists
|
|
68
|
+
if MultiTenant.tenant_klass_defined?(tenant_name, options)
|
|
69
|
+
belongs_to(
|
|
70
|
+
tenant_name,
|
|
71
|
+
**options.slice(:class_name, :inverse_of, :optional),
|
|
72
|
+
foreign_key: options[:partition_key]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# New instances should have the tenant set
|
|
77
|
+
after_initialize proc { |record|
|
|
78
|
+
if MultiTenant.current_tenant_id &&
|
|
79
|
+
(!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
|
|
80
|
+
record.public_send(:"#{partition_key}=", MultiTenant.current_tenant_id)
|
|
81
|
+
end
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Below block adds the following methods to the model that calls it.
|
|
85
|
+
# partition_key= - returns the partition key for the model.class << self 'partition' method defined above
|
|
86
|
+
# is the getter method. Here, there is additional check to assure that the tenant id is not changed once set
|
|
87
|
+
# tenant_name- returns the name of the tenant model. Its setter and getter methods defined separately
|
|
88
|
+
# Getter checks for the tenant association and if it is not loaded, returns the current tenant id set
|
|
89
|
+
# in the MultiTenant module
|
|
90
|
+
to_include = Module.new do
|
|
91
|
+
define_method "#{partition_key}=" do |tenant_id|
|
|
92
|
+
write_attribute(partition_key.to_s, tenant_id)
|
|
93
|
+
|
|
94
|
+
# Rails 5 `attribute_will_change!` uses the attribute-method-call rather than `read_attribute`
|
|
95
|
+
# and will raise ActiveModel::MissingAttributeError if that column was not selected.
|
|
96
|
+
# This is rescued as NoMethodError and in MRI attribute_was is assigned an arbitrary Object
|
|
97
|
+
was = send("#{partition_key}_was")
|
|
98
|
+
was_nil_or_skipped = was.nil? || was.instance_of?(Object)
|
|
99
|
+
|
|
100
|
+
if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
|
|
101
|
+
raise MultiTenant::TenantIsImmutable
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
tenant_id
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if MultiTenant.tenant_klass_defined?(tenant_name, options)
|
|
108
|
+
define_method "#{tenant_name}=" do |model|
|
|
109
|
+
super(model)
|
|
110
|
+
if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
|
|
111
|
+
raise MultiTenant::TenantIsImmutable
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
model
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
define_method tenant_name.to_s do
|
|
118
|
+
if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? &&
|
|
119
|
+
MultiTenant.current_tenant_id && public_send(partition_key) == MultiTenant.current_tenant_id
|
|
120
|
+
MultiTenant.current_tenant
|
|
121
|
+
else
|
|
122
|
+
super()
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
include to_include
|
|
128
|
+
|
|
129
|
+
# Below blocks sets tenant_id for the current session with the tenant_id of the record
|
|
130
|
+
# If the tenant is not set for the `session.After` the save operation current session tenant is set to nil
|
|
131
|
+
# If tenant is set for the session, save operation is performed as it is
|
|
132
|
+
around_save lambda { |record, block|
|
|
133
|
+
record_tenant = record.attribute_was(partition_key)
|
|
134
|
+
if persisted? && MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
|
|
135
|
+
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
|
136
|
+
else
|
|
137
|
+
block.call
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
around_update lambda { |record, block|
|
|
142
|
+
record_tenant = record.attribute_was(partition_key)
|
|
143
|
+
if MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
|
|
144
|
+
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
|
145
|
+
else
|
|
146
|
+
block.call
|
|
147
|
+
end
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
around_destroy lambda { |record, block|
|
|
151
|
+
if MultiTenant.current_tenant_id.nil?
|
|
152
|
+
MultiTenant.with(record.public_send(partition_key)) { block.call }
|
|
153
|
+
else
|
|
154
|
+
block.call
|
|
155
|
+
end
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Below code block is executed on Model, Associations and CollectionProxy objects
|
|
163
|
+
# when ActiveRecord is loaded and decorates defined methods with MultiTenant.with function.
|
|
164
|
+
# Additionally, adds aliases for some operators.
|
|
165
|
+
ActiveSupport.on_load(:active_record) do |base|
|
|
166
|
+
base.extend MultiTenant::ModelExtensionsClassMethods
|
|
167
|
+
|
|
168
|
+
# Ensure we have current_tenant_id in where clause when a cached ActiveRecord instance is being reloaded,
|
|
169
|
+
# or update_columns without callbacks is called
|
|
170
|
+
MultiTenant.wrap_methods(ActiveRecord::Base, 'self', :delete, :reload, :update_columns)
|
|
171
|
+
|
|
172
|
+
# Any queuries fired for fetching a singular association have the correct current_tenant_id in WHERE clause
|
|
173
|
+
# reload is called anytime any record's association is accessed
|
|
174
|
+
MultiTenant.wrap_methods(ActiveRecord::Associations::Association, 'owner', :reload)
|
|
175
|
+
|
|
176
|
+
# For collection associations, we need to wrap multiple methods in returned proxy so that
|
|
177
|
+
# any queries have the correct current_tenant_id in WHERE clause
|
|
178
|
+
ActiveRecord::Associations::CollectionProxy.alias_method \
|
|
179
|
+
:equals_mt, :== # Hack to prevent syntax error due to invalid method name
|
|
180
|
+
ActiveRecord::Associations::CollectionProxy.alias_method \
|
|
181
|
+
:append_mt, :<< # Hack to prevent syntax error due to invalid method name
|
|
182
|
+
MultiTenant.wrap_methods(ActiveRecord::Associations::CollectionProxy, '@association.owner',
|
|
183
|
+
:find, :last, :take, :build, :create, :create!, :replace, :delete_all,
|
|
184
|
+
:destroy_all, :delete, :destroy, :calculate, :pluck, :size, :empty?, :include?, :equals_mt,
|
|
185
|
+
:records, :append_mt, :find_nth_with_limit, :find_nth_from_last, :null_scope?,
|
|
186
|
+
:find_from_target?, :exec_queries)
|
|
187
|
+
ActiveRecord::Associations::CollectionProxy.alias_method :==, :equals_mt
|
|
188
|
+
ActiveRecord::Associations::CollectionProxy.alias_method :<<, :append_mt
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# skips statement caching for classes that is Multi-tenant or has a multi-tenant relation
|
|
192
|
+
module MultiTenant
|
|
193
|
+
module AssociationExtensions
|
|
194
|
+
def skip_statement_cache?(*scope)
|
|
195
|
+
return true if klass.respond_to?(:scoped_by_tenant?) && klass.scoped_by_tenant?
|
|
196
|
+
|
|
197
|
+
if reflection.through_reflection
|
|
198
|
+
through_klass = reflection.through_reflection.klass
|
|
199
|
+
return true if through_klass.respond_to?(:scoped_by_tenant?) && through_klass.scoped_by_tenant?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
super
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
ActiveRecord::Associations::Association.prepend(MultiTenant::AssociationExtensions)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/current_attributes'
|
|
4
|
+
|
|
5
|
+
module MultiTenant
|
|
6
|
+
class Current < ::ActiveSupport::CurrentAttributes
|
|
7
|
+
attribute :tenant
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.tenant_klass_defined?(tenant_name, options = {})
|
|
11
|
+
class_name = if options[:class_name].present?
|
|
12
|
+
options[:class_name]
|
|
13
|
+
else
|
|
14
|
+
tenant_name.to_s.classify
|
|
15
|
+
end
|
|
16
|
+
!!class_name.safe_constantize
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.partition_key(tenant_name)
|
|
20
|
+
"#{tenant_name.to_s.underscore}_id"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# rubocop:disable Style/ClassVars
|
|
24
|
+
# In some cases we only have an ID - if defined we'll return the default tenant class in such cases
|
|
25
|
+
def self.default_tenant_class=(tenant_class)
|
|
26
|
+
@@default_tenant_class = tenant_class
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.default_tenant_class
|
|
30
|
+
@@default_tenant_class ||= nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Write-only Mode - this only adds the tenant_id to new records, but doesn't
|
|
34
|
+
# require its presence for SELECTs/UPDATEs/DELETEs
|
|
35
|
+
def self.enable_write_only_mode
|
|
36
|
+
@@enable_write_only_mode = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.with_write_only_mode_enabled?
|
|
40
|
+
@@enable_write_only_mode ||= false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Registry that maps table names to models (used by the query rewriter)
|
|
44
|
+
def self.register_multi_tenant_model(model_klass)
|
|
45
|
+
@@multi_tenant_models ||= []
|
|
46
|
+
@@multi_tenant_models.push(model_klass)
|
|
47
|
+
|
|
48
|
+
remove_class_variable(:@@multi_tenant_model_table_names) if defined?(@@multi_tenant_model_table_names)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.multi_tenant_model_for_table(table_name)
|
|
52
|
+
@@multi_tenant_models ||= []
|
|
53
|
+
|
|
54
|
+
unless defined?(@@multi_tenant_model_table_names)
|
|
55
|
+
@@multi_tenant_model_table_names = @@multi_tenant_models.map do |model|
|
|
56
|
+
[model.table_name, model] if model.table_name
|
|
57
|
+
end.compact.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@@multi_tenant_model_table_names[table_name.to_s]
|
|
61
|
+
# rubocop:enable Style/ClassVars
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.multi_tenant_model_for_arel(arel)
|
|
65
|
+
return nil unless arel.respond_to?(:ast)
|
|
66
|
+
|
|
67
|
+
if arel.ast.relation.is_a? Arel::Nodes::JoinSource
|
|
68
|
+
MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation.left))
|
|
69
|
+
else
|
|
70
|
+
MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.current_tenant=(tenant)
|
|
75
|
+
Current.tenant = tenant
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.current_tenant
|
|
79
|
+
Current.tenant
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.current_tenant_id
|
|
83
|
+
current_tenant_is_id? ? current_tenant : current_tenant.try(:id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.current_tenant_is_id?
|
|
87
|
+
current_tenant.is_a?(String) || current_tenant.is_a?(Integer)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.current_tenant_class
|
|
91
|
+
if current_tenant_is_id?
|
|
92
|
+
MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
|
|
93
|
+
elsif current_tenant
|
|
94
|
+
MultiTenant.current_tenant.class.name
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.load_current_tenant!
|
|
99
|
+
return MultiTenant.current_tenant if MultiTenant.current_tenant && !current_tenant_is_id?
|
|
100
|
+
raise 'MultiTenant.current_tenant must be set to load' if MultiTenant.current_tenant.nil?
|
|
101
|
+
|
|
102
|
+
klass = MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
|
|
103
|
+
self.current_tenant = klass.find(MultiTenant.current_tenant_id)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.with(tenant, &block)
|
|
107
|
+
return block.call if current_tenant == tenant
|
|
108
|
+
|
|
109
|
+
old_tenant = current_tenant
|
|
110
|
+
begin
|
|
111
|
+
self.current_tenant = tenant
|
|
112
|
+
block.call
|
|
113
|
+
ensure
|
|
114
|
+
self.current_tenant = old_tenant
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.without(&block)
|
|
119
|
+
return block.call if current_tenant.nil?
|
|
120
|
+
|
|
121
|
+
old_tenant = current_tenant
|
|
122
|
+
begin
|
|
123
|
+
self.current_tenant = nil
|
|
124
|
+
block.call
|
|
125
|
+
ensure
|
|
126
|
+
self.current_tenant = old_tenant
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Wrap calls to any of `method_names` on an instance Class `klass` with MultiTenant.with
|
|
131
|
+
# when `'owner'` (evaluated in context of the klass instance) is a ActiveRecord model instance that is multi-tenant
|
|
132
|
+
# Instruments the methods provided with previously set Multitenant parameters
|
|
133
|
+
# TODO: Could not understand the use of owner here. Need to check
|
|
134
|
+
def self.wrap_methods(klass, owner, *method_names)
|
|
135
|
+
mod = Module.new
|
|
136
|
+
klass.prepend(mod)
|
|
137
|
+
|
|
138
|
+
method_names.each do |method_name|
|
|
139
|
+
mod.module_eval <<-CODE, __FILE__, __LINE__ + 1
|
|
140
|
+
def #{method_name}(...)
|
|
141
|
+
if MultiTenant.multi_tenant_model_for_table(#{owner}.class.table_name).present? && #{owner}.persisted? && MultiTenant.current_tenant_id.nil? && #{owner}.class.respond_to?(:partition_key) && #{owner}.attributes.include?(#{owner}.class.partition_key)
|
|
142
|
+
MultiTenant.with(#{owner}.public_send(#{owner}.class.partition_key)) { super }
|
|
143
|
+
else
|
|
144
|
+
super
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
CODE
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Preserve backward compatibility for people using .with_id
|
|
152
|
+
singleton_class.send(:alias_method, :with_id, :with)
|
|
153
|
+
|
|
154
|
+
# This exception is raised when a there is an attempt to change tenant
|
|
155
|
+
class TenantIsImmutable < StandardError
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class MissingTenantError < StandardError
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Add generic warning when queries fail and there is no tenant set
|
|
4
|
+
# To handle this case, a QueryMonitor hook is created and registered
|
|
5
|
+
# to sql.active_record. This hook will log a warning when a query fails
|
|
6
|
+
# This hook is executed after the query is executed.
|
|
7
|
+
module MultiTenant
|
|
8
|
+
# rubocop:disable Style/ClassVars
|
|
9
|
+
# Option to enable query monitor
|
|
10
|
+
@@enable_query_monitor = false
|
|
11
|
+
|
|
12
|
+
def self.enable_query_monitor
|
|
13
|
+
@@enable_query_monitor = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.query_monitor_enabled?
|
|
17
|
+
@@enable_query_monitor
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# rubocop:enable Style/ClassVars
|
|
21
|
+
# QueryMonitor class to log a warning when a query fails and there is no tenant set
|
|
22
|
+
# start and finish methods are required to be register sql.active_record hook
|
|
23
|
+
class QueryMonitor
|
|
24
|
+
def start(_name, _id, _payload) end
|
|
25
|
+
|
|
26
|
+
def finish(_name, _id, payload)
|
|
27
|
+
return unless MultiTenant.query_monitor_enabled?
|
|
28
|
+
|
|
29
|
+
return unless payload[:exception].present? && MultiTenant.current_tenant_id.nil?
|
|
30
|
+
|
|
31
|
+
Rails.logger.info 'WARNING: Tenant not present - make sure to add MultiTenant.with(tenant) { ... }'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
# Actual code to register the hook.
|
|
36
|
+
ActiveSupport::Notifications.subscribe('sql.active_record', MultiTenant::QueryMonitor.new)
|