forest_admin_datasource_active_record 1.34.0 → 1.34.1
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 +4 -4
- data/lib/forest_admin_datasource_active_record/collection.rb +2 -1
- data/lib/forest_admin_datasource_active_record/utils/active_record_serializer.rb +66 -34
- data/lib/forest_admin_datasource_active_record/utils/query.rb +117 -2
- data/lib/forest_admin_datasource_active_record/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c3a3205453469399c853ddd3984585890df288ad63968596b3f069c34bd3ff5
|
|
4
|
+
data.tar.gz: c565bc960082fcc9b93ddf5bef63922c6abe7ea30092301cd48a9053a1340d82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 986559472d2810f5636947ed6bfd1c539865f4323dc7c1edadaea3e225cdfe2716d388982e307e41f17e105b55bdbd09f8b54f43a1a7d15438a22a0472cc0a9b
|
|
7
|
+
data.tar.gz: 4424bbacd8dcaddb77f76f80d3121ef555cc2f6bc88242f4593b17bc66e332e9d96892bc6180bf94d8f7adab0dc25e7eda53b23054abd04c612eef0c354e1d7b
|
|
@@ -26,8 +26,9 @@ module ForestAdminDatasourceActiveRecord
|
|
|
26
26
|
|
|
27
27
|
def list(_caller, filter, projection)
|
|
28
28
|
query = Utils::Query.new(self, projection, filter)
|
|
29
|
+
records = query.get
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
records.map { |record| Utils::ActiveRecordSerializer.new(record, query.joined_relations).to_hash(projection) }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def aggregate(_caller, filter, aggregation, limit = nil)
|
|
@@ -1,53 +1,85 @@
|
|
|
1
1
|
module ForestAdminDatasourceActiveRecord
|
|
2
2
|
module Utils
|
|
3
|
-
|
|
3
|
+
# Relations in `joined_relations` (see Query#collect_joined_selects) are hydrated from the flat
|
|
4
|
+
# row's aliased columns; every other relation is read from its preloaded ActiveRecord association.
|
|
5
|
+
ActiveRecordSerializer = Struct.new(:object, :joined_relations) do
|
|
4
6
|
def to_hash(projection)
|
|
5
7
|
hash_object(object, projection)
|
|
6
8
|
end
|
|
7
9
|
|
|
8
|
-
def hash_object(object, projection = nil,
|
|
9
|
-
hash = {}
|
|
10
|
-
|
|
10
|
+
def hash_object(object, projection = nil, path: [])
|
|
11
11
|
return if object.nil?
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# root keeps all its selected columns (attributes + FKs); a related record is restricted to
|
|
14
|
+
# its projected columns, matching the JOINed hydration
|
|
15
|
+
hash = path.empty? || projection.nil? ? base_attributes(object) : projected_columns(object, projection)
|
|
14
16
|
|
|
15
|
-
serialize_associations(object, projection, hash) if
|
|
17
|
+
serialize_associations(object, projection, hash, path) if projection
|
|
16
18
|
|
|
17
19
|
hash
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
def
|
|
22
|
+
def base_attributes(object)
|
|
23
|
+
return object.attributes if join_aliases.empty?
|
|
24
|
+
|
|
25
|
+
object.attributes.except(*join_aliases)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def projected_columns(object, projection)
|
|
29
|
+
projection.columns.to_h { |column| [column, object[column]] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serialize_associations(object, projection, hash, path)
|
|
21
33
|
one_associations = %i[has_one belongs_to]
|
|
22
34
|
many_associations = %i[has_many has_and_belongs_to_many]
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
projection.relations.each_key do |association_name|
|
|
37
|
+
relation_path = path + [association_name]
|
|
38
|
+
|
|
39
|
+
if joined_relation?(relation_path)
|
|
40
|
+
hash[association_name] = hash_joined_relation(projection.relations[association_name], relation_path)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
association = object.class.reflect_on_association(association_name.to_sym)
|
|
45
|
+
next if association.nil?
|
|
46
|
+
|
|
47
|
+
if one_associations.include?(association.macro)
|
|
48
|
+
hash[association_name] = hash_object(
|
|
49
|
+
object.send(association_name),
|
|
50
|
+
projection.relations[association_name],
|
|
51
|
+
path: relation_path
|
|
52
|
+
)
|
|
53
|
+
elsif many_associations.include?(association.macro)
|
|
54
|
+
hash[association_name] = object.send(association_name).map do |item|
|
|
55
|
+
hash_object(item, projection.relations[association_name], path: relation_path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reads a JOINed relation's columns from the aliases on the root object (not a nested one).
|
|
62
|
+
def hash_joined_relation(projection, relation_path)
|
|
63
|
+
meta = joined_relations[relation_path.join('.')]
|
|
64
|
+
return nil if object[meta[:pk_alias]].nil?
|
|
65
|
+
|
|
66
|
+
hash = {}
|
|
67
|
+
projection.columns.each { |column| hash[column] = object[meta[:columns][column]] }
|
|
68
|
+
projection.relations.each_key do |nested_name|
|
|
69
|
+
hash[nested_name] = hash_joined_relation(projection.relations[nested_name], relation_path + [nested_name])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
hash
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def joined_relation?(relation_path)
|
|
78
|
+
!joined_relations.nil? && joined_relations.key?(relation_path.join('.'))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def join_aliases
|
|
82
|
+
@join_aliases ||= (joined_relations || {}).values.flat_map { |meta| meta[:columns].values }.uniq
|
|
51
83
|
end
|
|
52
84
|
end
|
|
53
85
|
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
1
3
|
module ForestAdminDatasourceActiveRecord
|
|
2
4
|
module Utils
|
|
3
5
|
class Query
|
|
4
6
|
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
|
|
5
7
|
|
|
6
|
-
attr_reader :query, :select
|
|
8
|
+
attr_reader :query, :select, :joined_relations
|
|
7
9
|
|
|
8
10
|
def initialize(collection, projection, filter)
|
|
9
11
|
@collection = collection
|
|
@@ -12,6 +14,11 @@ module ForestAdminDatasourceActiveRecord
|
|
|
12
14
|
@filter = filter
|
|
13
15
|
@arel_table = @collection.model.arel_table
|
|
14
16
|
@select = []
|
|
17
|
+
# relation path (e.g. "bank_account.organizations_view") => { columns: { col => sql_alias }, pk_alias: }
|
|
18
|
+
@joined_relations = {}
|
|
19
|
+
@alias_counter = 0
|
|
20
|
+
# tables already joined by filters/sorts, so apply_select does not join them a second time
|
|
21
|
+
@filter_joined_tables = Set.new
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def build
|
|
@@ -204,12 +211,119 @@ module ForestAdminDatasourceActiveRecord
|
|
|
204
211
|
end
|
|
205
212
|
|
|
206
213
|
def apply_select
|
|
214
|
+
unless @projection.nil?
|
|
215
|
+
join_tree, preload_tree = split_relations
|
|
216
|
+
|
|
217
|
+
@query = @query.left_outer_joins(join_tree) unless join_tree.empty?
|
|
218
|
+
@query = @query.includes(preload_tree) unless preload_tree.empty?
|
|
219
|
+
end
|
|
207
220
|
@query = @query.select(@select.join(', ')) if @select
|
|
208
|
-
@query = @query.includes(format_relation_projection(@projection)) unless @projection.nil?
|
|
209
221
|
|
|
210
222
|
@query
|
|
211
223
|
end
|
|
212
224
|
|
|
225
|
+
def split_relations
|
|
226
|
+
join_tree = {}
|
|
227
|
+
preload_tree = {}
|
|
228
|
+
used_tables = Set[@collection.model.table_name] | @filter_joined_tables
|
|
229
|
+
|
|
230
|
+
@projection.relations.each do |relation_name, sub_projection|
|
|
231
|
+
tables = joinable_tables(@collection, relation_name, sub_projection, used_tables)
|
|
232
|
+
if tables
|
|
233
|
+
used_tables |= tables
|
|
234
|
+
join_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
|
|
235
|
+
collect_joined_selects(@collection, relation_name, sub_projection, [relation_name])
|
|
236
|
+
else
|
|
237
|
+
preload_tree[relation_name.to_sym] = format_relation_projection(sub_projection)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
[join_tree, preload_tree]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def collect_joined_selects(collection, relation_name, sub_projection, path)
|
|
245
|
+
relation_schema = collection.schema[:fields][relation_name]
|
|
246
|
+
target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
|
|
247
|
+
table = target.model.table_name
|
|
248
|
+
pk_columns = Array(target.model.primary_key) # array for composite primary keys
|
|
249
|
+
|
|
250
|
+
alias_map = {}
|
|
251
|
+
# a pk column is always selected so the serializer can detect a NULL (absent) left-joined relation
|
|
252
|
+
target.model.connection_pool.with_connection do |connection|
|
|
253
|
+
(sub_projection.columns + pk_columns).uniq.each do |column|
|
|
254
|
+
sql_alias = next_join_alias
|
|
255
|
+
# quote via the adapter so identifiers are valid on every database (e.g. backticks on MySQL)
|
|
256
|
+
@select << "#{connection.quote_table_name(table)}.#{connection.quote_column_name(column)} " \
|
|
257
|
+
"AS #{connection.quote_column_name(sql_alias)}"
|
|
258
|
+
alias_map[column] = sql_alias
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
@joined_relations[path.join('.')] = { columns: alias_map, pk_alias: alias_map[pk_columns.first] }
|
|
262
|
+
|
|
263
|
+
sub_projection.relations.each do |nested_name, nested_projection|
|
|
264
|
+
collect_joined_selects(target, nested_name, nested_projection, path + [nested_name])
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def next_join_alias
|
|
269
|
+
@alias_counter += 1
|
|
270
|
+
"fa_join_#{@alias_counter}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Set of tables the subtree adds via JOIN, or nil if any relation in it can't be safely joined.
|
|
274
|
+
def joinable_tables(collection, relation_name, sub_projection, used_tables)
|
|
275
|
+
target = joinable_target(collection, relation_name, used_tables)
|
|
276
|
+
return nil if target.nil?
|
|
277
|
+
|
|
278
|
+
tables = Set[target.model.table_name]
|
|
279
|
+
sub_projection.relations.each do |nested_name, nested_projection|
|
|
280
|
+
nested = joinable_tables(target, nested_name, nested_projection, used_tables | tables)
|
|
281
|
+
return nil if nested.nil?
|
|
282
|
+
|
|
283
|
+
tables |= nested
|
|
284
|
+
end
|
|
285
|
+
tables
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The target collection when this hop is safe to collapse into a JOIN, else nil (-> preload).
|
|
289
|
+
def joinable_target(collection, relation_name, used_tables)
|
|
290
|
+
relation_schema = collection.schema[:fields][relation_name]
|
|
291
|
+
# belongs_to only: it joins on the target's primary key, so it can't duplicate the parent
|
|
292
|
+
# (a has_one child may not be unique)
|
|
293
|
+
return unless relation_schema.type == 'ManyToOne' && relation_schema.respond_to?(:foreign_collection)
|
|
294
|
+
|
|
295
|
+
# a scoped association applies its scope to the JOIN and may inject raw/unqualified SQL or
|
|
296
|
+
# extra joins (e.g. `belongs_to :x, -> { where('id > ?', 1) }`)
|
|
297
|
+
reflection = collection.model.reflect_on_association(relation_name.to_sym)
|
|
298
|
+
return if reflection.nil? || reflection.scope
|
|
299
|
+
|
|
300
|
+
target = local_ar_collection(collection.datasource, relation_schema.foreign_collection)
|
|
301
|
+
return if target.nil? || !target.model.default_scopes.empty? # same risk as a scoped association
|
|
302
|
+
return unless same_database?(collection.model, target.model)
|
|
303
|
+
return if used_tables.include?(target.model.table_name) # a table joined twice would be aliased by AR
|
|
304
|
+
|
|
305
|
+
target
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def same_database?(model_a, model_b)
|
|
309
|
+
# compare the pools, not connection_specification_name (only an owner class name, shared across shards)
|
|
310
|
+
model_a.connection_pool == model_b.connection_pool
|
|
311
|
+
rescue StandardError
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The target collection only if it is AR-backed AND belongs to this exact datasource, else nil.
|
|
316
|
+
# Guards (concrete class + identity, not just the name) against a foreign-datasource collection.
|
|
317
|
+
def local_ar_collection(datasource, name)
|
|
318
|
+
collection = datasource.get_collection(name)
|
|
319
|
+
return nil unless collection.is_a?(ForestAdminDatasourceActiveRecord::Collection)
|
|
320
|
+
return nil unless collection.datasource.equal?(datasource)
|
|
321
|
+
|
|
322
|
+
collection
|
|
323
|
+
rescue StandardError
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
213
327
|
def add_join_relation(relation_name)
|
|
214
328
|
@query = @query.left_joins(relation_name.to_sym)
|
|
215
329
|
|
|
@@ -222,6 +336,7 @@ module ForestAdminDatasourceActiveRecord
|
|
|
222
336
|
relation = @collection.schema[:fields][relation_name]
|
|
223
337
|
related_collection = @collection.datasource.get_collection(relation.foreign_collection)
|
|
224
338
|
add_join_relation(relation_name)
|
|
339
|
+
@filter_joined_tables << related_collection.model.table_name
|
|
225
340
|
|
|
226
341
|
{
|
|
227
342
|
formatted: "#{related_collection.model.table_name}.#{column_name}",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_admin_datasource_active_record
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.34.
|
|
4
|
+
version: 1.34.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matthieu
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-07-02 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activerecord
|