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.
@@ -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)