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,431 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require_relative 'arel_visitors_depth_first' unless Arel::Visitors.const_defined?(:DepthFirst)
|
|
5
|
+
|
|
6
|
+
# Iterates AST and adds tenant enforcement clauses to all relations
|
|
7
|
+
module MultiTenant
|
|
8
|
+
class Table
|
|
9
|
+
attr_reader :arel_table
|
|
10
|
+
|
|
11
|
+
def initialize(arel_table)
|
|
12
|
+
@arel_table = arel_table
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def eql?(other)
|
|
16
|
+
self.class == other.class &&
|
|
17
|
+
equality_fields.eql?(other.equality_fields)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def hash
|
|
21
|
+
equality_fields.hash
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def equality_fields
|
|
27
|
+
[arel_table.name, arel_table.table_alias]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Context
|
|
32
|
+
attr_reader :arel_node, :known_relations, :handled_relations
|
|
33
|
+
|
|
34
|
+
def initialize(arel_node)
|
|
35
|
+
@arel_node = arel_node
|
|
36
|
+
@known_relations = []
|
|
37
|
+
@handled_relations = []
|
|
38
|
+
@discovering = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def discover_relations
|
|
42
|
+
old_discovering = @discovering
|
|
43
|
+
@discovering = true
|
|
44
|
+
yield
|
|
45
|
+
@discovering = old_discovering
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visited_relation(relation)
|
|
49
|
+
return unless @discovering
|
|
50
|
+
|
|
51
|
+
@known_relations << Table.new(relation)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def visited_handled_relation(relation)
|
|
55
|
+
@handled_relations << Table.new(relation)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unhandled_relations
|
|
59
|
+
known_relations.uniq - handled_relations
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ArelTenantVisitor < if Arel::Visitors.const_defined?(:DepthFirst)
|
|
64
|
+
Arel::Visitors::DepthFirst
|
|
65
|
+
else
|
|
66
|
+
::MultiTenant::ArelVisitorsDepthFirst
|
|
67
|
+
end
|
|
68
|
+
def initialize(arel)
|
|
69
|
+
super(proc {})
|
|
70
|
+
@statement_node_id = nil
|
|
71
|
+
|
|
72
|
+
@contexts = []
|
|
73
|
+
@current_context = nil
|
|
74
|
+
accept(arel.ast)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attr_reader :contexts
|
|
78
|
+
|
|
79
|
+
# rubocop:disable Naming/MethodName
|
|
80
|
+
def visit_Arel_Attributes_Attribute(*args)
|
|
81
|
+
return if @current_context.nil?
|
|
82
|
+
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def visit_Arel_Nodes_Equality(obj, *args)
|
|
87
|
+
if obj.left.is_a?(Arel::Attributes::Attribute)
|
|
88
|
+
table_name = MultiTenant::TableNode.table_name(obj.left.relation)
|
|
89
|
+
model = MultiTenant.multi_tenant_model_for_table(table_name)
|
|
90
|
+
if model.present? && obj.left.name.to_s == model.partition_key.to_s
|
|
91
|
+
@current_context.visited_handled_relation(obj.left.relation)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def visit_MultiTenant_TenantEnforcementClause(obj, *)
|
|
98
|
+
@current_context.visited_handled_relation(obj.tenant_attribute.relation)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def visit_MultiTenant_TenantJoinEnforcementClause(obj, *)
|
|
102
|
+
@current_context.visited_handled_relation(obj.tenant_attribute.relation)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def visit_Arel_Table(obj, _collector = nil)
|
|
106
|
+
@current_context.visited_relation(obj) if tenant_relation?(MultiTenant::TableNode.table_name(obj))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
alias visit_Arel_Nodes_TableAlias visit_Arel_Table
|
|
110
|
+
|
|
111
|
+
# A SelectManager can appear inside a condition, e.g. as the right-hand
|
|
112
|
+
# side of an IN. Neither Arel's DepthFirst (Rails 6.0) nor the gem's copy
|
|
113
|
+
# of it (Rails 6.1+) knows how to visit one, so descend into its AST here.
|
|
114
|
+
def visit_Arel_SelectManager(obj, *)
|
|
115
|
+
visit obj.ast
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visit_Arel_Nodes_SelectCore(obj, *_args)
|
|
119
|
+
nest_context(obj) do
|
|
120
|
+
@current_context.discover_relations do
|
|
121
|
+
visit obj.source
|
|
122
|
+
end
|
|
123
|
+
visit obj.wheres
|
|
124
|
+
visit obj.groups
|
|
125
|
+
visit obj.windows
|
|
126
|
+
if defined?(obj.having)
|
|
127
|
+
visit obj.having
|
|
128
|
+
else
|
|
129
|
+
visit obj.havings
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# rubocop:enable Naming/MethodName
|
|
135
|
+
|
|
136
|
+
# rubocop:disable Naming/MethodName
|
|
137
|
+
def visit_Arel_Nodes_OuterJoin(obj, _collector = nil)
|
|
138
|
+
nest_context(obj) do
|
|
139
|
+
@current_context.discover_relations do
|
|
140
|
+
visit obj.left
|
|
141
|
+
visit obj.right
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# rubocop:enable Naming/MethodName
|
|
147
|
+
|
|
148
|
+
alias visit_Arel_Nodes_FullOuterJoin visit_Arel_Nodes_OuterJoin
|
|
149
|
+
alias visit_Arel_Nodes_RightOuterJoin visit_Arel_Nodes_OuterJoin
|
|
150
|
+
|
|
151
|
+
alias visit_ActiveModel_Attribute terminal
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def tenant_relation?(table_name)
|
|
156
|
+
MultiTenant.multi_tenant_model_for_table(table_name).present?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
DISPATCH = Hash.new do |hash, klass|
|
|
160
|
+
hash[klass] = "visit_#{(klass.name || '').gsub('::', '_')}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def dispatch
|
|
164
|
+
DISPATCH
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
168
|
+
def get_dispatch_cache
|
|
169
|
+
dispatch
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
173
|
+
|
|
174
|
+
def nest_context(obj)
|
|
175
|
+
old_context = @current_context
|
|
176
|
+
@current_context = Context.new(obj)
|
|
177
|
+
@contexts << @current_context
|
|
178
|
+
|
|
179
|
+
yield
|
|
180
|
+
|
|
181
|
+
@current_context = old_context
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class BaseTenantEnforcementClause < Arel::Nodes::Node
|
|
186
|
+
attr_reader :tenant_attribute
|
|
187
|
+
|
|
188
|
+
def initialize(tenant_attribute)
|
|
189
|
+
super()
|
|
190
|
+
@tenant_attribute = tenant_attribute
|
|
191
|
+
@tenant_model = MultiTenant.multi_tenant_model_for_table(
|
|
192
|
+
MultiTenant::TableNode.table_name(tenant_attribute.relation)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_s
|
|
197
|
+
to_sql
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def to_str
|
|
201
|
+
to_sql
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_sql(*)
|
|
205
|
+
collector = Arel::Collectors::SQLString.new
|
|
206
|
+
collector = @tenant_model.connection.visitor.accept tenant_arel, collector
|
|
207
|
+
collector.value
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
class TenantEnforcementClause < BaseTenantEnforcementClause
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def tenant_arel
|
|
215
|
+
if defined?(Arel::Nodes::Quoted)
|
|
216
|
+
@tenant_attribute.eq(Arel::Nodes::Quoted.new(MultiTenant.current_tenant_id))
|
|
217
|
+
else
|
|
218
|
+
@tenant_attribute.eq(MultiTenant.current_tenant_id)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
class TenantJoinEnforcementClause < BaseTenantEnforcementClause
|
|
224
|
+
attr_reader :table_left
|
|
225
|
+
|
|
226
|
+
def initialize(tenant_attribute, table_left)
|
|
227
|
+
super(tenant_attribute)
|
|
228
|
+
@table_left = table_left
|
|
229
|
+
@model_left = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(table_left))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def tenant_arel
|
|
235
|
+
@tenant_attribute.eq(@table_left[@model_left.partition_key])
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
module TenantValueVisitor
|
|
240
|
+
# rubocop:disable Naming/MethodName
|
|
241
|
+
def visit_MultiTenant_TenantEnforcementClause(obj, collector)
|
|
242
|
+
collector << obj
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def visit_MultiTenant_TenantJoinEnforcementClause(obj, collector)
|
|
246
|
+
collector << obj
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# rubocop:enable Naming/MethodName
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
module DatabaseStatements
|
|
253
|
+
def join_to_update(update, *args)
|
|
254
|
+
update = super
|
|
255
|
+
model = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(update.ast.relation))
|
|
256
|
+
if model.present? && !MultiTenant.with_write_only_mode_enabled? && MultiTenant.current_tenant_id.present?
|
|
257
|
+
update.where(MultiTenant::TenantEnforcementClause.new(model.arel_table[model.partition_key]))
|
|
258
|
+
end
|
|
259
|
+
update
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def join_to_delete(delete, *args)
|
|
263
|
+
delete = super
|
|
264
|
+
model = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(delete.ast.left))
|
|
265
|
+
if model.present? && !MultiTenant.with_write_only_mode_enabled? && MultiTenant.current_tenant_id.present?
|
|
266
|
+
delete.where(MultiTenant::TenantEnforcementClause.new(model.arel_table[model.partition_key]))
|
|
267
|
+
end
|
|
268
|
+
delete
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def update(arel, name = nil, binds = [])
|
|
272
|
+
model = MultiTenant.multi_tenant_model_for_arel(arel)
|
|
273
|
+
if model.present? && !MultiTenant.with_write_only_mode_enabled? && MultiTenant.current_tenant_id.present?
|
|
274
|
+
arel.where(MultiTenant::TenantEnforcementClause.new(model.arel_table[model.partition_key]))
|
|
275
|
+
end
|
|
276
|
+
super
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def delete(arel, name = nil, binds = [])
|
|
280
|
+
model = MultiTenant.multi_tenant_model_for_arel(arel)
|
|
281
|
+
if model.present? && !MultiTenant.with_write_only_mode_enabled? && MultiTenant.current_tenant_id.present?
|
|
282
|
+
arel.where(MultiTenant::TenantEnforcementClause.new(model.arel_table[model.partition_key]))
|
|
283
|
+
end
|
|
284
|
+
super
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
require 'active_record/connection_adapters/abstract_adapter'
|
|
290
|
+
ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(MultiTenant::DatabaseStatements)
|
|
291
|
+
|
|
292
|
+
Arel::Visitors::ToSql.include(MultiTenant::TenantValueVisitor)
|
|
293
|
+
|
|
294
|
+
module MultiTenant
|
|
295
|
+
# Rails 8.1 compiles update_all on a joined relation for PostgreSQL as
|
|
296
|
+
#
|
|
297
|
+
# UPDATE t AS __active_record_update_alias SET ...
|
|
298
|
+
# FROM t INNER JOIN ... WHERE t.id = __active_record_update_alias.id
|
|
299
|
+
#
|
|
300
|
+
# Citus only plans that self-join when it is also on the distribution
|
|
301
|
+
# column, so for multi-tenant tables add the partition key to it. This also
|
|
302
|
+
# keeps the rows being updated inside the tenant. Older Rails versions use
|
|
303
|
+
# an IN (subquery) here and never build the alias, so this is a no-op there.
|
|
304
|
+
module UpdateJoinPartitionKey
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def prepare_update_statement(statement)
|
|
308
|
+
stmt = super
|
|
309
|
+
return stmt unless stmt.relation.respond_to?(:left) && stmt.relation.left.is_a?(Arel::Nodes::TableAlias)
|
|
310
|
+
|
|
311
|
+
table = statement.relation.left
|
|
312
|
+
model = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(table))
|
|
313
|
+
return stmt if model.nil? || model.partition_key.blank? || model.partition_key.to_s == model.primary_key.to_s
|
|
314
|
+
|
|
315
|
+
stmt.wheres << stmt.relation.left[model.partition_key].eq(table[model.partition_key])
|
|
316
|
+
stmt
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
Arel::Visitors::PostgreSQL.prepend(MultiTenant::UpdateJoinPartitionKey)
|
|
322
|
+
|
|
323
|
+
module MultiTenant
|
|
324
|
+
module QueryMethodsExtensions
|
|
325
|
+
def build_arel(*)
|
|
326
|
+
arel = super
|
|
327
|
+
|
|
328
|
+
unless MultiTenant.with_write_only_mode_enabled?
|
|
329
|
+
visitor = MultiTenant::ArelTenantVisitor.new(arel)
|
|
330
|
+
|
|
331
|
+
visitor.contexts.each do |context|
|
|
332
|
+
node = context.arel_node
|
|
333
|
+
|
|
334
|
+
context.unhandled_relations.each do |relation|
|
|
335
|
+
model = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(relation.arel_table))
|
|
336
|
+
|
|
337
|
+
if MultiTenant.current_tenant_id
|
|
338
|
+
enforcement_clause = MultiTenant::TenantEnforcementClause.new(relation.arel_table[model.partition_key])
|
|
339
|
+
case node
|
|
340
|
+
when Arel::Nodes::Join # Arel::Nodes::OuterJoin, Arel::Nodes::RightOuterJoin, Arel::Nodes::FullOuterJoin
|
|
341
|
+
node.right.expr = node.right.expr.and(enforcement_clause)
|
|
342
|
+
when Arel::Nodes::SelectCore
|
|
343
|
+
if node.wheres.empty?
|
|
344
|
+
node.wheres = [enforcement_clause]
|
|
345
|
+
elsif node.wheres[0].is_a?(Arel::Nodes::And)
|
|
346
|
+
node.wheres[0].children << enforcement_clause
|
|
347
|
+
else
|
|
348
|
+
node.wheres[0] = enforcement_clause.and(node.wheres[0])
|
|
349
|
+
end
|
|
350
|
+
else
|
|
351
|
+
raise 'UnknownContext'
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
next unless node.is_a?(Arel::Nodes::SelectCore) || node.is_a?(Arel::Nodes::Join)
|
|
356
|
+
|
|
357
|
+
node_list = if node.is_a? Arel::Nodes::Join
|
|
358
|
+
[node]
|
|
359
|
+
else
|
|
360
|
+
node.source.right
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
node_list.select { |n| n.is_a? Arel::Nodes::Join }.each do |node_join|
|
|
364
|
+
next unless node_join.right
|
|
365
|
+
|
|
366
|
+
relation_right, relation_left = relations_from_node_join(node_join)
|
|
367
|
+
|
|
368
|
+
next unless relation_right && relation_left
|
|
369
|
+
|
|
370
|
+
model_right = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(relation_left))
|
|
371
|
+
model_left = MultiTenant.multi_tenant_model_for_table(MultiTenant::TableNode.table_name(relation_right))
|
|
372
|
+
next unless model_right && model_left
|
|
373
|
+
|
|
374
|
+
join_enforcement_clause = MultiTenant::TenantJoinEnforcementClause.new(
|
|
375
|
+
relation_right[model_right.partition_key], relation_left
|
|
376
|
+
)
|
|
377
|
+
node_join.right.expr = node_join.right.expr.and(join_enforcement_clause)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
arel
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
private
|
|
387
|
+
|
|
388
|
+
def relations_from_node_join(node_join)
|
|
389
|
+
if node_join.right.expr.is_a?(Arel::Nodes::Equality)
|
|
390
|
+
return node_join.right.expr.right.relation, node_join.right.expr.left.relation
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
children = [node_join.right.expr.children].flatten
|
|
394
|
+
|
|
395
|
+
tenant_applied = children.any? do |c|
|
|
396
|
+
c.is_a?(MultiTenant::TenantEnforcementClause) || c.is_a?(MultiTenant::TenantJoinEnforcementClause)
|
|
397
|
+
end
|
|
398
|
+
return nil, nil if tenant_applied || children.empty?
|
|
399
|
+
|
|
400
|
+
child = children.first.respond_to?(:children) ? children.first.children.first : children.first
|
|
401
|
+
if child.right.respond_to?(:relation) && child.left.respond_to?(:relation)
|
|
402
|
+
return child.right.relation, child.left.relation
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
[nil, nil]
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
require 'active_record/relation'
|
|
411
|
+
ActiveRecord::QueryMethods.prepend(MultiTenant::QueryMethodsExtensions)
|
|
412
|
+
|
|
413
|
+
module MultiTenantFindBy
|
|
414
|
+
if ActiveRecord.gem_version >= Gem::Version.create('7.2.0')
|
|
415
|
+
def cached_find_by_statement(connection, key, &block)
|
|
416
|
+
return super unless respond_to?(:scoped_by_tenant?) && scoped_by_tenant?
|
|
417
|
+
|
|
418
|
+
super(connection, Array.wrap(key) + [MultiTenant.current_tenant_id.to_s], &block)
|
|
419
|
+
end
|
|
420
|
+
else
|
|
421
|
+
def cached_find_by_statement(key, &block)
|
|
422
|
+
return super unless respond_to?(:scoped_by_tenant?) && scoped_by_tenant?
|
|
423
|
+
|
|
424
|
+
super(Array.wrap(key) + [MultiTenant.current_tenant_id.to_s], &block)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
ActiveSupport.on_load(:active_record) do |base|
|
|
430
|
+
base.singleton_class.prepend(MultiTenantFindBy)
|
|
431
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arel
|
|
4
|
+
module ActiveRecordRelationExtension
|
|
5
|
+
# Overrides the delete_all method to include tenant scoping
|
|
6
|
+
def delete_all
|
|
7
|
+
model = MultiTenant.multi_tenant_model_for_table(table_name)
|
|
8
|
+
|
|
9
|
+
# Call the original delete_all method if the current tenant is identified by an ID
|
|
10
|
+
return super if model.nil? || MultiTenant.current_tenant_is_id? || MultiTenant.current_tenant.nil?
|
|
11
|
+
|
|
12
|
+
stmt = Arel::DeleteManager.new.from(table)
|
|
13
|
+
stmt.wheres = [generate_in_condition_subquery]
|
|
14
|
+
|
|
15
|
+
# Execute the delete statement using the connection and return the result
|
|
16
|
+
klass.connection.delete(stmt, "#{klass} Delete All").tap { reset }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Overrides the update_all method to include tenant scoping
|
|
20
|
+
def update_all(updates)
|
|
21
|
+
model = MultiTenant.multi_tenant_model_for_table(table_name)
|
|
22
|
+
|
|
23
|
+
# Call the original update_all method if the current tenant is identified by an ID
|
|
24
|
+
return super if model.nil? || MultiTenant.current_tenant_is_id? || MultiTenant.current_tenant.nil?
|
|
25
|
+
|
|
26
|
+
if updates.is_a?(Hash)
|
|
27
|
+
if klass.locking_enabled? &&
|
|
28
|
+
!updates.key?(klass.locking_column) &&
|
|
29
|
+
!updates.key?(klass.locking_column.to_sym)
|
|
30
|
+
attr = table[klass.locking_column]
|
|
31
|
+
updates[attr.name] = _increment_attribute(attr)
|
|
32
|
+
end
|
|
33
|
+
values = _substitute_values(updates)
|
|
34
|
+
else
|
|
35
|
+
values = Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
stmt = Arel::UpdateManager.new
|
|
39
|
+
stmt.table(table)
|
|
40
|
+
stmt.set values
|
|
41
|
+
stmt.wheres = [generate_in_condition_subquery]
|
|
42
|
+
|
|
43
|
+
klass.connection.update(stmt, "#{klass} Update All").tap { reset }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The generate_in_condition_subquery method generates a subquery that selects
|
|
49
|
+
# records associated with the current tenant.
|
|
50
|
+
def generate_in_condition_subquery
|
|
51
|
+
# Get the tenant key and tenant ID based on the current tenant
|
|
52
|
+
tenant_key = MultiTenant.partition_key(MultiTenant.current_tenant_class)
|
|
53
|
+
tenant_id = MultiTenant.current_tenant_id
|
|
54
|
+
|
|
55
|
+
# Build an Arel query. Only Active Record 7.2 and 8.0 take the
|
|
56
|
+
# connection as the first argument of build_arel; every other version
|
|
57
|
+
# takes only aliases, which is required (without a default) in 6.0 and 8.1.
|
|
58
|
+
arel = if eager_loading?
|
|
59
|
+
apply_join_dependency.arel
|
|
60
|
+
elsif ActiveRecord.gem_version >= Gem::Version.create('7.2.0') &&
|
|
61
|
+
ActiveRecord.gem_version < Gem::Version.create('8.1.0')
|
|
62
|
+
build_arel(klass.connection)
|
|
63
|
+
else
|
|
64
|
+
build_arel(nil)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
arel.source.left = table
|
|
68
|
+
|
|
69
|
+
# If the tenant ID is present and the tenant key is a column in the model,
|
|
70
|
+
# add a condition to only include records where the tenant key equals the tenant ID
|
|
71
|
+
if tenant_id && klass.column_names.include?(tenant_key)
|
|
72
|
+
tenant_condition = table[tenant_key].eq(tenant_id)
|
|
73
|
+
unless arel.constraints.any? { |node| node.to_sql.include?(tenant_condition.to_sql) }
|
|
74
|
+
arel = arel.where(tenant_condition)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Clone the query, clear its projections, and set its projection to the primary key of the table
|
|
79
|
+
subquery = arel.clone
|
|
80
|
+
subquery.projections.clear
|
|
81
|
+
subquery = subquery.project(table[primary_key])
|
|
82
|
+
|
|
83
|
+
# Create an IN condition node with the primary key of the table and the subquery
|
|
84
|
+
Arel::Nodes::In.new(table[primary_key], subquery.ast)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Patch ActiveRecord::Relation with the extension module
|
|
90
|
+
ActiveRecord::Relation.prepend(Arel::ActiveRecordRelationExtension)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sidekiq/client'
|
|
4
|
+
|
|
5
|
+
# Adds methods to handle tenant information both in the client and server.
|
|
6
|
+
module Sidekiq::Middleware::MultiTenant
|
|
7
|
+
# Get the current tenant and store in the message to be sent to Sidekiq.
|
|
8
|
+
class Client
|
|
9
|
+
def call(_worker_class, msg, _queue, _redis_pool)
|
|
10
|
+
if MultiTenant.current_tenant.present?
|
|
11
|
+
msg['multi_tenant'] ||=
|
|
12
|
+
{
|
|
13
|
+
'class' => MultiTenant.current_tenant_class,
|
|
14
|
+
'id' => MultiTenant.current_tenant_id
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
yield
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Pull the tenant out and run the current thread with it.
|
|
23
|
+
class Server
|
|
24
|
+
def call(_worker_class, msg, _queue, &block)
|
|
25
|
+
if msg.key?('multi_tenant')
|
|
26
|
+
tenant = begin
|
|
27
|
+
msg['multi_tenant']['class'].constantize.find(msg['multi_tenant']['id'])
|
|
28
|
+
rescue ActiveRecord::RecordNotFound
|
|
29
|
+
msg['multi_tenant']['id']
|
|
30
|
+
end
|
|
31
|
+
MultiTenant.with(tenant, &block)
|
|
32
|
+
else
|
|
33
|
+
yield
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Configure Sidekiq to use the multi-tenant client and server middleware to add (client/server)/process(server)
|
|
40
|
+
# tenant information.
|
|
41
|
+
Sidekiq.configure_server do |config|
|
|
42
|
+
config.server_middleware do |chain|
|
|
43
|
+
chain.add Sidekiq::Middleware::MultiTenant::Server
|
|
44
|
+
end
|
|
45
|
+
config.client_middleware do |chain|
|
|
46
|
+
chain.add Sidekiq::Middleware::MultiTenant::Client
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Sidekiq.configure_client do |config|
|
|
51
|
+
config.client_middleware do |chain|
|
|
52
|
+
chain.add Sidekiq::Middleware::MultiTenant::Client
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Bulk push support for Sidekiq while setting multi-tenant information.
|
|
57
|
+
# This is a copy of the Sidekiq::Client#push_bulk method with the addition of
|
|
58
|
+
# setting the multi-tenant information for each job.
|
|
59
|
+
module Sidekiq
|
|
60
|
+
class Client
|
|
61
|
+
# Allows the caller to enqueue multiple Sidekiq jobs with
|
|
62
|
+
# tenant information in a single call. It ensures that each job is processed
|
|
63
|
+
# within the correct tenant context and returns an array of job IDs for the enqueued jobs
|
|
64
|
+
def push_bulk_with_tenants(items)
|
|
65
|
+
first_job = items['jobs'].first
|
|
66
|
+
return [] unless first_job # no jobs to push
|
|
67
|
+
unless first_job.is_a?(Hash)
|
|
68
|
+
raise ArgumentError, "Bulk arguments must be an Array of Hashes: [{ 'args' => [1], 'tenant_id' => 1 }, ...]"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
normed = normalize_item(items.except('jobs').merge('args' => []))
|
|
72
|
+
payloads = items['jobs'].map do |job|
|
|
73
|
+
MultiTenant.with(job['tenant_id']) do
|
|
74
|
+
copy = normed.merge('args' => job['args'], 'jid' => SecureRandom.hex(12), 'enqueued_at' => Time.now.to_f)
|
|
75
|
+
result = process_single(items['class'], copy)
|
|
76
|
+
result || nil
|
|
77
|
+
end
|
|
78
|
+
end.compact
|
|
79
|
+
|
|
80
|
+
raw_push(payloads) unless payloads.empty?
|
|
81
|
+
payloads.collect { |payload| payload['jid'] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Enabling the push_bulk_with_tenants method to be called directly on the Sidekiq::Client class
|
|
85
|
+
class << self
|
|
86
|
+
def push_bulk_with_tenants(items)
|
|
87
|
+
new.push_bulk_with_tenants(items)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiTenant
|
|
4
|
+
module TableNode
|
|
5
|
+
# Return table name
|
|
6
|
+
def self.table_name(node)
|
|
7
|
+
# NOTE: Arel::Nodes::Table#table_name is removed in Rails 7.1
|
|
8
|
+
if node.is_a?(Arel::Nodes::TableAlias)
|
|
9
|
+
node.table_name
|
|
10
|
+
else
|
|
11
|
+
node.name
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'activerecord-multi-tenant/controller_extensions' if Object.const_defined?(:ActionController)
|
|
4
|
+
require_relative 'activerecord-multi-tenant/copy_from_client'
|
|
5
|
+
require_relative 'activerecord-multi-tenant/fast_truncate'
|
|
6
|
+
require_relative 'activerecord-multi-tenant/migrations'
|
|
7
|
+
require_relative 'activerecord-multi-tenant/model_extensions'
|
|
8
|
+
require_relative 'activerecord-multi-tenant/multi_tenant'
|
|
9
|
+
require_relative 'activerecord-multi-tenant/query_rewriter'
|
|
10
|
+
require_relative 'activerecord-multi-tenant/query_monitor'
|
|
11
|
+
require_relative 'activerecord-multi-tenant/table_node'
|
|
12
|
+
require_relative 'activerecord-multi-tenant/version'
|
|
13
|
+
require_relative 'activerecord-multi-tenant/habtm'
|
|
14
|
+
require_relative 'activerecord-multi-tenant/relation_extension'
|