activerecord-multi-tenant 2.0.0 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/active-record-multi-tenant-tests.yml +83 -0
  3. data/.gitignore +6 -0
  4. data/.readthedocs.yaml +15 -0
  5. data/.rspec +0 -0
  6. data/.rubocop.yml +51 -0
  7. data/Appraisals +6 -22
  8. data/CHANGELOG.md +42 -0
  9. data/Gemfile +3 -1
  10. data/LICENSE +0 -0
  11. data/README.md +3 -2
  12. data/Rakefile +1 -1
  13. data/activerecord-multi-tenant.gemspec +28 -22
  14. data/docker-compose.yml +24 -18
  15. data/docs/.gitignore +3 -0
  16. data/docs/Makefile +28 -0
  17. data/docs/api-reference.sh +10 -0
  18. data/docs/requirements.in +4 -0
  19. data/docs/requirements.txt +62 -0
  20. data/docs/source/_static/api-reference/ActiveRecord/Associations/Association.html +285 -0
  21. data/docs/source/_static/api-reference/ActiveRecord/Associations/ClassMethods.html +255 -0
  22. data/docs/source/_static/api-reference/ActiveRecord/Associations.html +117 -0
  23. data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters/SchemaStatements.html +232 -0
  24. data/docs/source/_static/api-reference/ActiveRecord/ConnectionAdapters.html +126 -0
  25. data/docs/source/_static/api-reference/ActiveRecord/QueryMethods.html +336 -0
  26. data/docs/source/_static/api-reference/ActiveRecord/SchemaDumper.html +121 -0
  27. data/docs/source/_static/api-reference/ActiveRecord.html +130 -0
  28. data/docs/source/_static/api-reference/MultiTenant/ArelTenantVisitor.html +755 -0
  29. data/docs/source/_static/api-reference/MultiTenant/ArelVisitorsDepthFirst.html +208 -0
  30. data/docs/source/_static/api-reference/MultiTenant/BaseTenantEnforcementClause.html +462 -0
  31. data/docs/source/_static/api-reference/MultiTenant/Context.html +659 -0
  32. data/docs/source/_static/api-reference/MultiTenant/ControllerExtensions.html +202 -0
  33. data/docs/source/_static/api-reference/MultiTenant/CopyFromClient.html +186 -0
  34. data/docs/source/_static/api-reference/MultiTenant/CopyFromClientHelper.html +362 -0
  35. data/docs/source/_static/api-reference/MultiTenant/Current.html +124 -0
  36. data/docs/source/_static/api-reference/MultiTenant/DatabaseStatements.html +366 -0
  37. data/docs/source/_static/api-reference/MultiTenant/FastTruncate.html +226 -0
  38. data/docs/source/_static/api-reference/MultiTenant/MigrationExtensions.html +554 -0
  39. data/docs/source/_static/api-reference/MultiTenant/MissingTenantError.html +124 -0
  40. data/docs/source/_static/api-reference/MultiTenant/ModelExtensionsClassMethods.html +492 -0
  41. data/docs/source/_static/api-reference/MultiTenant/QueryMonitor.html +257 -0
  42. data/docs/source/_static/api-reference/MultiTenant/Table.html +419 -0
  43. data/docs/source/_static/api-reference/MultiTenant/TenantEnforcementClause.html +148 -0
  44. data/docs/source/_static/api-reference/MultiTenant/TenantIsImmutable.html +135 -0
  45. data/docs/source/_static/api-reference/MultiTenant/TenantJoinEnforcementClause.html +310 -0
  46. data/docs/source/_static/api-reference/MultiTenant/TenantValueVisitor.html +239 -0
  47. data/docs/source/_static/api-reference/MultiTenant.html +1454 -0
  48. data/docs/source/_static/api-reference/MultiTenantFindBy.html +180 -0
  49. data/docs/source/_static/api-reference/Sidekiq/Client.html +302 -0
  50. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Client.html +217 -0
  51. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant/Server.html +219 -0
  52. data/docs/source/_static/api-reference/Sidekiq/Middleware/MultiTenant.html +126 -0
  53. data/docs/source/_static/api-reference/Sidekiq.html +126 -0
  54. data/docs/source/_static/api-reference/_index.html +399 -0
  55. data/docs/source/_static/api-reference/class_list.html +51 -0
  56. data/docs/source/_static/api-reference/css/common.css +1 -0
  57. data/docs/source/_static/api-reference/css/full_list.css +58 -0
  58. data/docs/source/_static/api-reference/css/style.css +497 -0
  59. data/docs/source/_static/api-reference/file.README.html +167 -0
  60. data/docs/source/_static/api-reference/file_list.html +56 -0
  61. data/docs/source/_static/api-reference/frames.html +17 -0
  62. data/docs/source/_static/api-reference/index.html +167 -0
  63. data/docs/source/_static/api-reference/js/app.js +314 -0
  64. data/docs/source/_static/api-reference/js/full_list.js +216 -0
  65. data/docs/source/_static/api-reference/js/jquery.js +4 -0
  66. data/docs/source/_static/api-reference/method_list.html +715 -0
  67. data/docs/source/_static/api-reference/top-level-namespace.html +126 -0
  68. data/docs/source/_templates/.gitignore +4 -0
  69. data/docs/source/api-reference.rst +8 -0
  70. data/docs/source/appendix.rst +26 -0
  71. data/docs/source/changelog.rst +8 -0
  72. data/docs/source/community-and-support.rst +26 -0
  73. data/docs/source/conf.py +30 -0
  74. data/docs/source/contributing.rst +70 -0
  75. data/docs/source/getting-started.rst +37 -0
  76. data/docs/source/guides-and-tutorials.rst +129 -0
  77. data/docs/source/index.rst +54 -0
  78. data/docs/source/introduction.rst +33 -0
  79. data/docs/source/license.rst +22 -0
  80. data/docs/source/troubleshooting.rst +41 -0
  81. data/docs/source/usage-guide.rst +59 -0
  82. data/lib/activerecord-multi-tenant/arel_visitors_depth_first.rb +183 -174
  83. data/lib/activerecord-multi-tenant/controller_extensions.rb +15 -4
  84. data/lib/activerecord-multi-tenant/copy_from_client.rb +4 -0
  85. data/lib/activerecord-multi-tenant/fast_truncate.rb +4 -2
  86. data/lib/activerecord-multi-tenant/habtm.rb +50 -0
  87. data/lib/activerecord-multi-tenant/migrations.rb +87 -10
  88. data/lib/activerecord-multi-tenant/model_extensions.rb +96 -38
  89. data/lib/activerecord-multi-tenant/multi_tenant.rb +83 -24
  90. data/lib/activerecord-multi-tenant/query_monitor.rb +21 -5
  91. data/lib/activerecord-multi-tenant/query_rewriter.rb +121 -87
  92. data/lib/activerecord-multi-tenant/sidekiq.rb +46 -19
  93. data/lib/activerecord-multi-tenant/table_node.rb +13 -0
  94. data/lib/activerecord-multi-tenant/version.rb +1 -1
  95. data/lib/activerecord-multi-tenant.rb +3 -13
  96. data/lib/activerecord_multi_tenant.rb +13 -0
  97. data/spec/activerecord-multi-tenant/associations_spec.rb +42 -0
  98. data/spec/activerecord-multi-tenant/controller_extensions_spec.rb +3 -2
  99. data/spec/activerecord-multi-tenant/fast_truncate_spec.rb +8 -6
  100. data/spec/activerecord-multi-tenant/model_extensions_spec.rb +300 -147
  101. data/spec/activerecord-multi-tenant/multi_tenant_spec.rb +69 -13
  102. data/spec/activerecord-multi-tenant/query_rewriter_spec.rb +60 -59
  103. data/spec/activerecord-multi-tenant/record_callback_spec.rb +0 -0
  104. data/spec/activerecord-multi-tenant/record_finding_spec.rb +11 -11
  105. data/spec/activerecord-multi-tenant/record_modifications_spec.rb +23 -4
  106. data/spec/activerecord-multi-tenant/sidekiq_spec.rb +10 -10
  107. data/spec/database.yml +0 -0
  108. data/spec/schema.rb +20 -3
  109. data/spec/spec_helper.rb +46 -17
  110. data/spec/support/format_sql.rb +20 -0
  111. metadata +134 -32
  112. data/.github/workflows/CI.yml +0 -73
  113. data/gemfiles/.bundle/config +0 -2
  114. data/gemfiles/active_record_5.2.3.gemfile +0 -16
  115. data/gemfiles/active_record_5.2.gemfile +0 -16
  116. data/gemfiles/active_record_6.0.gemfile +0 -8
  117. data/gemfiles/active_record_6.1.gemfile +0 -8
  118. data/gemfiles/active_record_7.0.gemfile +0 -8
  119. data/gemfiles/rails_5.2.3.gemfile +0 -16
  120. data/gemfiles/rails_5.2.gemfile +0 -16
  121. data/gemfiles/rails_6.0.gemfile +0 -8
  122. data/gemfiles/rails_6.1.gemfile +0 -8
  123. data/gemfiles/rails_7.0.gemfile +0 -8
  124. data/lib/activerecord-multi-tenant/persistence_extension.rb +0 -13
  125. data/lib/activerecord-multi-tenant/with_lock.rb +0 -15
  126. data/spec/activerecord-multi-tenant/schema_dumper_tester.rb +0 -0
@@ -2,12 +2,40 @@ module MultiTenant
2
2
  module MigrationExtensions
3
3
  def create_distributed_table(table_name, partition_key)
4
4
  return unless citus_version.present?
5
- execute "SELECT create_distributed_table($$#{table_name}$$, $$#{partition_key}$$)"
5
+
6
+ reversible do |dir|
7
+ dir.up do
8
+ execute "SELECT create_distributed_table($$#{table_name}$$, $$#{partition_key}$$)"
9
+ end
10
+ dir.down do
11
+ undistribute_table(table_name)
12
+ end
13
+ end
6
14
  end
7
15
 
8
16
  def create_reference_table(table_name)
9
17
  return unless citus_version.present?
10
- execute "SELECT create_reference_table($$#{table_name}$$)"
18
+
19
+ reversible do |dir|
20
+ dir.up do
21
+ execute "SELECT create_reference_table($$#{table_name}$$)"
22
+ end
23
+ dir.down do
24
+ undistribute_table(table_name)
25
+ end
26
+ end
27
+ end
28
+
29
+ def undistribute_table(table_name)
30
+ return unless citus_version.present?
31
+
32
+ execute "SELECT undistribute_table($$#{table_name}$$))"
33
+ end
34
+
35
+ def rebalance_table_shards
36
+ return unless citus_version.present?
37
+
38
+ execute 'SELECT rebalance_table_shards()'
11
39
  end
12
40
 
13
41
  def execute_on_all_nodes(sql)
@@ -18,7 +46,8 @@ module MultiTenant
18
46
  execute "SELECT citus_run_on_all_workers($$#{sql}$$)" # initial citus_tools.sql with different names
19
47
  when nil
20
48
  # Do nothing, this is regular Postgres
21
- else # 6.1 and newer
49
+ else
50
+ # 6.1 and newer
22
51
  execute "SELECT run_command_on_workers($$#{sql}$$)"
23
52
  end
24
53
  end
@@ -28,24 +57,23 @@ module MultiTenant
28
57
  end
29
58
 
30
59
  def citus_version
31
- execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'").getvalue(0,0).try(:split, '-').try(:first)
60
+ execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'").getvalue(0, 0).try(:split, '-').try(:first)
32
61
  rescue ArgumentError => e
33
- raise unless e.message == "invalid tuple number 0"
62
+ raise unless e.message == 'invalid tuple number 0'
34
63
  end
35
64
  end
36
65
  end
37
66
 
38
- if defined?(ActiveRecord::Migration)
39
- ActiveRecord::Migration.send(:include, MultiTenant::MigrationExtensions)
40
- end
67
+ ActiveRecord::Migration.include MultiTenant::MigrationExtensions if defined?(ActiveRecord::Migration)
41
68
 
42
69
  module ActiveRecord
43
70
  module ConnectionAdapters # :nodoc:
44
71
  module SchemaStatements
45
- alias :orig_create_table :create_table
72
+ alias orig_create_table create_table
73
+
46
74
  def create_table(table_name, options = {}, &block)
47
75
  ret = orig_create_table(table_name, **options.except(:partition_key), &block)
48
- if options[:partition_key] && options[:partition_key].to_s != 'id'
76
+ if options[:id] != false && options[:partition_key] && options[:partition_key].to_s != 'id'
49
77
  execute "ALTER TABLE #{table_name} DROP CONSTRAINT #{table_name}_pkey"
50
78
  execute "ALTER TABLE #{table_name} ADD PRIMARY KEY(\"#{options[:partition_key]}\", id)"
51
79
  end
@@ -54,3 +82,52 @@ module ActiveRecord
54
82
  end
55
83
  end
56
84
  end
85
+
86
+ module ActiveRecord
87
+ class SchemaDumper
88
+ private
89
+
90
+ alias initialize_without_citus initialize
91
+
92
+ def initialize(connection, options = {})
93
+ initialize_without_citus(connection, options)
94
+
95
+ citus_version =
96
+ begin
97
+ ActiveRecord::Migration.citus_version
98
+ rescue StandardError
99
+ # Handle the case where this gem is used with MySQL https://github.com/citusdata/activerecord-multi-tenant/issues/166
100
+ nil
101
+ end
102
+ @distribution_columns =
103
+ if citus_version.present?
104
+ query_to_execute = <<-SQL.strip
105
+ SELECT logicalrelid::regclass AS table_name,
106
+ column_to_column_name(logicalrelid, partkey) AS dist_col_name
107
+ FROM pg_dist_partition
108
+ SQL
109
+ @connection.execute(query_to_execute).to_h do |v|
110
+ [v['table_name'], v['dist_col_name']]
111
+ end
112
+ else
113
+ {}
114
+ end
115
+ end
116
+
117
+ # Support for create_distributed_table & create_reference_table
118
+ alias table_without_citus table
119
+
120
+ def table(table, stream)
121
+ table_without_citus(table, stream)
122
+ table_name = remove_prefix_and_suffix(table)
123
+ distribution_column = @distribution_columns[table_name]
124
+ if distribution_column
125
+ stream.puts " create_distributed_table(#{table_name.inspect}, #{distribution_column.inspect})"
126
+ stream.puts
127
+ elsif @distribution_columns.key?(table_name)
128
+ stream.puts " create_reference_table(#{table_name.inspect})"
129
+ stream.puts
130
+ end
131
+ end
132
+ end
133
+ end
@@ -1,18 +1,32 @@
1
+ require_relative './multi_tenant'
2
+
1
3
  module MultiTenant
4
+ # Extension to the model to allow scoping of models to the current tenant. This is done by adding
5
+ # the multitenant method to the models that need to be scoped. This method is called in the
6
+ # model declaration.
7
+ # Adds scoped_by_tenant? partition_key, primary_key and inherited methods to the model
2
8
  module ModelExtensionsClassMethods
3
9
  DEFAULT_ID_FIELD = 'id'.freeze
4
-
10
+ # executes when multi_tenant method is called in the model. This method adds the following
11
+ # methods to the model that calls it.
12
+ # scoped_by_tenant? - returns true if the model is scoped by tenant
13
+ # partition_key - returns the partition key for the model
14
+ # primary_key - returns the primary key for the model
15
+ #
5
16
  def multi_tenant(tenant_name, options = {})
6
- if to_s.underscore.to_sym == tenant_name
17
+ if to_s.underscore.to_sym == tenant_name || (!table_name.nil? && table_name.singularize.to_sym == tenant_name)
7
18
  unless MultiTenant.with_write_only_mode_enabled?
8
19
  # This is the tenant model itself. Workaround for https://github.com/citusdata/citus/issues/687
9
- before_create -> do
10
- if self.class.columns_hash[self.class.primary_key].type == :uuid
11
- self.id ||= SecureRandom.uuid
12
- else
13
- self.id ||= self.class.connection.select_value("SELECT nextval('#{self.class.table_name}_#{self.class.primary_key}_seq'::regclass)")
14
- end
15
- end
20
+ before_create lambda {
21
+ id = if self.class.columns_hash[self.class.primary_key].type == :uuid
22
+ SecureRandom.uuid
23
+ else
24
+ self.class.connection.select_value(
25
+ "SELECT nextval('#{self.class.table_name}_#{self.class.primary_key}_seq'::regclass)"
26
+ )
27
+ end
28
+ self.id ||= id
29
+ }
16
30
  end
17
31
  else
18
32
  class << self
@@ -22,24 +36,23 @@ module MultiTenant
22
36
 
23
37
  # Allow partition_key to be set from a superclass if not already set in this class
24
38
  def partition_key
25
- @partition_key ||= ancestors.detect{ |k| k.instance_variable_get(:@partition_key) }
26
- .try(:instance_variable_get, :@partition_key)
39
+ @partition_key ||= ancestors.detect { |k| k.instance_variable_get(:@partition_key) }
40
+ .try(:instance_variable_get, :@partition_key)
27
41
  end
28
42
 
29
43
  # Avoid primary_key errors when using composite primary keys (e.g. id, tenant_id)
30
44
  def primary_key
31
- return @primary_key if @primary_key
45
+ if defined?(PRIMARY_KEY_NOT_SET) ? !PRIMARY_KEY_NOT_SET.equal?(@primary_key) : @primary_key
46
+ return @primary_key
47
+ end
32
48
 
33
49
  primary_object_keys = Array.wrap(connection.schema_cache.primary_keys(table_name)) - [partition_key]
34
50
 
35
- if primary_object_keys.size == 1
36
- @primary_key = primary_object_keys.first
37
- elsif connection.schema_cache.columns_hash(table_name).include? DEFAULT_ID_FIELD
38
- @primary_key = DEFAULT_ID_FIELD
39
- else
40
- # table without a primary key and DEFAULT_ID_FIELD is not present in the table
41
- @primary_key = nil
42
- end
51
+ @primary_key = if primary_object_keys.size == 1
52
+ primary_object_keys.first
53
+ elsif connection.schema_cache.columns_hash(table_name).include? DEFAULT_ID_FIELD
54
+ DEFAULT_ID_FIELD
55
+ end
43
56
  end
44
57
 
45
58
  def inherited(subclass)
@@ -54,43 +67,56 @@ module MultiTenant
54
67
  partition_key = @partition_key
55
68
 
56
69
  # Create an implicit belongs_to association only if tenant class exists
57
- if MultiTenant.tenant_klass_defined?(tenant_name)
58
- belongs_to tenant_name, **options.slice(:class_name, :inverse_of, :optional).merge(foreign_key: options[:partition_key])
70
+ if MultiTenant.tenant_klass_defined?(tenant_name, options)
71
+ belongs_to tenant_name, **options.slice(:class_name, :inverse_of, :optional)
72
+ .merge(foreign_key: options[:partition_key])
59
73
  end
60
74
 
61
75
  # New instances should have the tenant set
62
- after_initialize Proc.new { |record|
76
+ after_initialize proc { |record|
63
77
  if MultiTenant.current_tenant_id &&
64
- (!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
78
+ (!record.attribute_present?(partition_key) || record.public_send(partition_key.to_sym).nil?)
65
79
  record.public_send("#{partition_key}=".to_sym, MultiTenant.current_tenant_id)
66
80
  end
67
81
  }
68
82
 
83
+ # Below block adds the following methods to the model that calls it.
84
+ # partition_key= - returns the partition key for the model.class << self 'partition' method defined above
85
+ # is the getter method. Here, there is additional check to assure that the tenant id is not changed once set
86
+ # tenant_name- returns the name of the tenant model. Its setter and getter methods defined separately
87
+ # Getter checks for the tenant association and if it is not loaded, returns the current tenant id set
88
+ # in the MultiTenant module
69
89
  to_include = Module.new do
70
90
  define_method "#{partition_key}=" do |tenant_id|
71
- write_attribute("#{partition_key}", tenant_id)
91
+ write_attribute(partition_key.to_s, tenant_id)
72
92
 
73
93
  # Rails 5 `attribute_will_change!` uses the attribute-method-call rather than `read_attribute`
74
94
  # and will raise ActiveModel::MissingAttributeError if that column was not selected.
75
95
  # This is rescued as NoMethodError and in MRI attribute_was is assigned an arbitrary Object
76
- # This is still true after the Rails 5.2 refactor
77
96
  was = send("#{partition_key}_was")
78
- was_nil_or_skipped = was.nil? || was.class == Object
97
+ was_nil_or_skipped = was.nil? || was.instance_of?(Object)
98
+
99
+ if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
100
+ raise MultiTenant::TenantIsImmutable
101
+ end
79
102
 
80
- raise MultiTenant::TenantIsImmutable if send("#{partition_key}_changed?") && persisted? && !was_nil_or_skipped
81
103
  tenant_id
82
104
  end
83
105
 
84
- if MultiTenant.tenant_klass_defined?(tenant_name)
106
+ if MultiTenant.tenant_klass_defined?(tenant_name, options)
85
107
  define_method "#{tenant_name}=" do |model|
86
108
  super(model)
87
- raise MultiTenant::TenantIsImmutable if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
109
+ if send("#{partition_key}_changed?") && persisted? && !send("#{partition_key}_was").nil?
110
+ raise MultiTenant::TenantIsImmutable
111
+ end
112
+
88
113
  model
89
114
  end
90
115
 
91
- define_method "#{tenant_name}" do
92
- if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? && MultiTenant.current_tenant_id && public_send(partition_key) == MultiTenant.current_tenant_id
93
- return MultiTenant.current_tenant
116
+ define_method tenant_name.to_s do
117
+ if !association(tenant_name.to_sym).loaded? && !MultiTenant.current_tenant_is_id? &&
118
+ MultiTenant.current_tenant_id && public_send(partition_key) == MultiTenant.current_tenant_id
119
+ MultiTenant.current_tenant
94
120
  else
95
121
  super()
96
122
  end
@@ -99,23 +125,28 @@ module MultiTenant
99
125
  end
100
126
  include to_include
101
127
 
102
- around_save -> (record, block) {
103
- if persisted? && MultiTenant.current_tenant_id.nil?
128
+ # Below blocks sets tenant_id for the current session with the tenant_id of the record
129
+ # If the tenant is not set for the `session.After` the save operation current session tenant is set to nil
130
+ # If tenant is set for the session, save operation is performed as it is
131
+ around_save lambda { |record, block|
132
+ record_tenant = record.attribute_was(partition_key)
133
+ if persisted? && MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
104
134
  MultiTenant.with(record.public_send(partition_key)) { block.call }
105
135
  else
106
136
  block.call
107
137
  end
108
138
  }
109
139
 
110
- around_update -> (record, block) {
111
- if MultiTenant.current_tenant_id.nil?
140
+ around_update lambda { |record, block|
141
+ record_tenant = record.attribute_was(partition_key)
142
+ if MultiTenant.current_tenant_id.nil? && !record_tenant.nil?
112
143
  MultiTenant.with(record.public_send(partition_key)) { block.call }
113
144
  else
114
145
  block.call
115
146
  end
116
147
  }
117
148
 
118
- around_destroy -> (record, block) {
149
+ around_destroy lambda { |record, block|
119
150
  if MultiTenant.current_tenant_id.nil?
120
151
  MultiTenant.with(record.public_send(partition_key)) { block.call }
121
152
  else
@@ -127,12 +158,39 @@ module MultiTenant
127
158
  end
128
159
  end
129
160
 
161
+ # Below code block is executed on Model, Associations and CollectionProxy objects
162
+ # when ActiveRecord is loaded and decorates defined methods with MultiTenant.with function.
163
+ # Additionally, adds aliases for some operators.
130
164
  ActiveSupport.on_load(:active_record) do |base|
131
165
  base.extend MultiTenant::ModelExtensionsClassMethods
166
+
167
+ # Ensure we have current_tenant_id in where clause when a cached ActiveRecord instance is being reloaded,
168
+ # or update_columns without callbacks is called
169
+ MultiTenant.wrap_methods(ActiveRecord::Base, 'self', :delete, :reload, :update_columns)
170
+
171
+ # Any queuries fired for fetching a singular association have the correct current_tenant_id in WHERE clause
172
+ # reload is called anytime any record's association is accessed
173
+ MultiTenant.wrap_methods(ActiveRecord::Associations::Association, 'owner', :reload)
174
+
175
+ # For collection associations, we need to wrap multiple methods in returned proxy so that
176
+ # any queries have the correct current_tenant_id in WHERE clause
177
+ ActiveRecord::Associations::CollectionProxy.alias_method \
178
+ :equals_mt, :== # Hack to prevent syntax error due to invalid method name
179
+ ActiveRecord::Associations::CollectionProxy.alias_method \
180
+ :append_mt, :<< # Hack to prevent syntax error due to invalid method name
181
+ MultiTenant.wrap_methods(ActiveRecord::Associations::CollectionProxy, '@association.owner',
182
+ :find, :last, :take, :build, :create, :create!, :replace, :delete_all,
183
+ :destroy_all, :delete, :destroy, :calculate, :pluck, :size, :empty?, :include?, :equals_mt,
184
+ :records, :append_mt, :find_nth_with_limit, :find_nth_from_last, :null_scope?,
185
+ :find_from_target?, :exec_queries)
186
+ ActiveRecord::Associations::CollectionProxy.alias_method :==, :equals_mt
187
+ ActiveRecord::Associations::CollectionProxy.alias_method :<<, :append_mt
132
188
  end
133
189
 
190
+ # skips statement caching for classes that is Multi-tenant or has a multi-tenant relation
134
191
  class ActiveRecord::Associations::Association
135
192
  alias skip_statement_cache_orig skip_statement_cache?
193
+
136
194
  def skip_statement_cache?(*scope)
137
195
  return true if klass.respond_to?(:scoped_by_tenant?) && klass.scoped_by_tenant?
138
196
 
@@ -5,27 +5,38 @@ module MultiTenant
5
5
  attribute :tenant
6
6
  end
7
7
 
8
- def self.tenant_klass_defined?(tenant_name)
9
- !!tenant_name.to_s.classify.safe_constantize
8
+ def self.tenant_klass_defined?(tenant_name, options = {})
9
+ class_name = if options[:class_name].present?
10
+ options[:class_name]
11
+ else
12
+ tenant_name.to_s.classify
13
+ end
14
+ !!class_name.safe_constantize
10
15
  end
11
16
 
12
17
  def self.partition_key(tenant_name)
13
- "#{tenant_name.to_s}_id"
18
+ "#{tenant_name}_id"
14
19
  end
15
20
 
21
+ # rubocop:disable Style/ClassVars
16
22
  # In some cases we only have an ID - if defined we'll return the default tenant class in such cases
17
- def self.default_tenant_class=(tenant_class); @@default_tenant_class = tenant_class; end
18
- def self.default_tenant_class; @@default_tenant_class ||= nil; end
23
+ def self.default_tenant_class=(tenant_class)
24
+ @@default_tenant_class = tenant_class
25
+ end
26
+
27
+ def self.default_tenant_class
28
+ @@default_tenant_class ||= nil
29
+ end
19
30
 
20
31
  # Write-only Mode - this only adds the tenant_id to new records, but doesn't
21
32
  # require its presence for SELECTs/UPDATEs/DELETEs
22
- def self.enable_write_only_mode; @@enable_write_only_mode = true; end
23
- def self.with_write_only_mode_enabled?; @@enable_write_only_mode ||= false; end
33
+ def self.enable_write_only_mode
34
+ @@enable_write_only_mode = true
35
+ end
24
36
 
25
- # Workaroud to make "with_lock" work until https://github.com/citusdata/citus/issues/1236 is fixed
26
- @@enable_with_lock_workaround = false
27
- def self.enable_with_lock_workaround; @@enable_with_lock_workaround = true; end
28
- def self.with_lock_workaround_enabled?; @@enable_with_lock_workaround; end
37
+ def self.with_write_only_mode_enabled?
38
+ @@enable_write_only_mode ||= false
39
+ end
29
40
 
30
41
  # Registry that maps table names to models (used by the query rewriter)
31
42
  def self.register_multi_tenant_model(model_klass)
@@ -38,21 +49,23 @@ module MultiTenant
38
49
  def self.multi_tenant_model_for_table(table_name)
39
50
  @@multi_tenant_models ||= []
40
51
 
41
- if !defined?(@@multi_tenant_model_table_names)
42
- @@multi_tenant_model_table_names = @@multi_tenant_models.map { |model|
52
+ unless defined?(@@multi_tenant_model_table_names)
53
+ @@multi_tenant_model_table_names = @@multi_tenant_models.map do |model|
43
54
  [model.table_name, model] if model.table_name
44
- }.compact.to_h
55
+ end.compact.to_h
45
56
  end
46
57
 
47
58
  @@multi_tenant_model_table_names[table_name.to_s]
59
+ # rubocop:enable Style/ClassVars
48
60
  end
49
61
 
50
62
  def self.multi_tenant_model_for_arel(arel)
51
63
  return nil unless arel.respond_to?(:ast)
64
+
52
65
  if arel.ast.relation.is_a? Arel::Nodes::JoinSource
53
- MultiTenant.multi_tenant_model_for_table(arel.ast.relation.left.table_name)
66
+ MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation.left))
54
67
  else
55
- MultiTenant.multi_tenant_model_for_table(arel.ast.relation.table_name)
68
+ MultiTenant.multi_tenant_model_for_table(TableNode.table_name(arel.ast.relation))
56
69
  end
57
70
  end
58
71
 
@@ -74,7 +87,7 @@ module MultiTenant
74
87
 
75
88
  def self.current_tenant_class
76
89
  if current_tenant_is_id?
77
- MultiTenant.default_tenant_class || fail('Only have tenant id, and no default tenant class set')
90
+ MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
78
91
  elsif current_tenant
79
92
  MultiTenant.current_tenant.class.name
80
93
  end
@@ -83,35 +96,81 @@ module MultiTenant
83
96
  def self.load_current_tenant!
84
97
  return MultiTenant.current_tenant if MultiTenant.current_tenant && !current_tenant_is_id?
85
98
  raise 'MultiTenant.current_tenant must be set to load' if MultiTenant.current_tenant.nil?
86
- klass = MultiTenant.default_tenant_class || fail('Only have tenant id, and no default tenant class set')
99
+
100
+ klass = MultiTenant.default_tenant_class || raise('Only have tenant id, and no default tenant class set')
87
101
  self.current_tenant = klass.find(MultiTenant.current_tenant_id)
88
102
  end
89
103
 
90
104
  def self.with(tenant, &block)
91
- return block.call if self.current_tenant == tenant
92
- old_tenant = self.current_tenant
105
+ return block.call if current_tenant == tenant
106
+
107
+ old_tenant = current_tenant
93
108
  begin
94
109
  self.current_tenant = tenant
95
- return block.call
110
+ block.call
96
111
  ensure
97
112
  self.current_tenant = old_tenant
98
113
  end
99
114
  end
100
115
 
101
116
  def self.without(&block)
102
- return block.call if self.current_tenant.nil?
103
- old_tenant = self.current_tenant
117
+ return block.call if current_tenant.nil?
118
+
119
+ old_tenant = current_tenant
104
120
  begin
105
121
  self.current_tenant = nil
106
- return block.call
122
+ block.call
107
123
  ensure
108
124
  self.current_tenant = old_tenant
109
125
  end
110
126
  end
111
127
 
128
+ # Wrap calls to any of `method_names` on an instance Class `klass` with MultiTenant.with
129
+ # when `'owner'` (evaluated in context of the klass instance) is a ActiveRecord model instance that is multi-tenant
130
+ # Instruments the methods provided with previously set Multitenant parameters
131
+ # In Ruby 2 using splat (*) operator with `&block` is not supported, so we need to use `method(...)` syntax
132
+ # TODO: Could not understand the use of owner here. Need to check
133
+ if Gem::Version.create(RUBY_VERSION) < Gem::Version.new('3.0.0')
134
+ def self.wrap_methods(klass, owner, *method_names)
135
+ method_names.each do |method_name|
136
+ original_method_name = :"_mt_original_#{method_name}"
137
+ klass.class_eval <<-CODE, __FILE__, __LINE__ + 1
138
+ alias_method :#{original_method_name}, :#{method_name}
139
+ def #{method_name}(*args, &block)
140
+ 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)
141
+ MultiTenant.with(#{owner}.public_send(#{owner}.class.partition_key)) { #{original_method_name}(*args, &block) }
142
+ else
143
+ #{original_method_name}(*args, &block)
144
+ end
145
+ end
146
+ CODE
147
+ end
148
+ end
149
+ else
150
+ def self.wrap_methods(klass, owner, *method_names)
151
+ method_names.each do |method_name|
152
+ original_method_name = :"_mt_original_#{method_name}"
153
+ klass.class_eval <<-CODE, __FILE__, __LINE__ + 1
154
+ alias_method :#{original_method_name}, :#{method_name}
155
+ def #{method_name}(...)
156
+ 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)
157
+ MultiTenant.with(#{owner}.public_send(#{owner}.class.partition_key)) { #{original_method_name}(...) }
158
+ else
159
+ #{original_method_name}(...)
160
+ end
161
+ end
162
+ CODE
163
+ end
164
+ end
165
+ end
166
+
112
167
  # Preserve backward compatibility for people using .with_id
113
168
  singleton_class.send(:alias_method, :with_id, :with)
114
169
 
170
+ # This exception is raised when a there is an attempt to change tenant
115
171
  class TenantIsImmutable < StandardError
116
172
  end
173
+
174
+ class MissingTenantError < StandardError
175
+ end
117
176
  end
@@ -1,18 +1,34 @@
1
1
  # Add generic warning when queries fail and there is no tenant set
2
+ # To handle this case, a QueryMonitor hook is created and registered
3
+ # to sql.active_record. This hook will log a warning when a query fails
4
+ # This hook is executed after the query is executed.
2
5
  module MultiTenant
6
+ # rubocop:disable Style/ClassVars
3
7
  # Option to enable query monitor
4
8
  @@enable_query_monitor = false
5
- def self.enable_query_monitor; @@enable_query_monitor = true; end
6
- def self.query_monitor_enabled?; @@enable_query_monitor; end
7
9
 
10
+ def self.enable_query_monitor
11
+ @@enable_query_monitor = true
12
+ end
13
+
14
+ def self.query_monitor_enabled?
15
+ @@enable_query_monitor
16
+ end
17
+
18
+ # rubocop:enable Style/ClassVars
19
+ # QueryMonitor class to log a warning when a query fails and there is no tenant set
20
+ # start and finish methods are required to be register sql.active_record hook
8
21
  class QueryMonitor
9
- def start(name, id, payload); end
10
- def finish(name, id, payload)
22
+ def start(_name, _id, _payload) end
23
+
24
+ def finish(_name, _id, payload)
11
25
  return unless MultiTenant.query_monitor_enabled?
26
+
12
27
  return unless payload[:exception].present? && MultiTenant.current_tenant_id.nil?
28
+
13
29
  Rails.logger.info 'WARNING: Tenant not present - make sure to add MultiTenant.with(tenant) { ... }'
14
30
  end
15
31
  end
16
32
  end
17
-
33
+ # Actual code to register the hook.
18
34
  ActiveSupport::Notifications.subscribe('sql.active_record', MultiTenant::QueryMonitor.new)