forest_liana 9.16.0 → 9.16.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/app/controllers/forest_liana/application_controller.rb +6 -2
- data/app/serializers/forest_liana/serializer_factory.rb +9 -0
- data/app/services/forest_liana/has_many_getter.rb +25 -2
- data/app/services/forest_liana/resource_getter.rb +1 -1
- data/app/services/forest_liana/resources_getter.rb +113 -18
- data/app/services/forest_liana/utils/composite_primary_key_helper.rb +27 -0
- data/app/services/forest_liana/utils/context_variables_injector.rb +8 -1
- data/lib/forest_liana/version.rb +1 -1
- data/spec/requests/resources_spec.rb +122 -0
- data/spec/services/forest_liana/resources_getter_composite_keys_spec.rb +116 -0
- data/spec/services/forest_liana/utils/context_variables_injector_spec.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5def4c0d7226797a8b932f16749a655b526c6278689417b712866c1eb223a78
|
|
4
|
+
data.tar.gz: c08d1acf4f8f36a67590b334cd175c76e87deabc47507dd7a57619a7f94302ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63b2d98871047ed88390ba98905430e201c7bf43b94d86d13fa1dd7a3c77ccbc8f0711e6d0f878f93ac48c09d6d3a35db630979690764038e4b85ad6e51b976a
|
|
7
|
+
data.tar.gz: 134d72f9a8b49d5f3fdf3889eb72fb08f4308ff2645fc97933c7122cffcaf35891b5bff8e3e3fa5de9725381cf9a7142bf5188146db6035048efccab6e7c33dc
|
|
@@ -173,7 +173,7 @@ module ForestLiana
|
|
|
173
173
|
fields[relation_name] = relation_fields
|
|
174
174
|
elsif model.reflect_on_association(relation_name.to_sym)
|
|
175
175
|
model_association = model.reflect_on_association(relation_name.to_sym)
|
|
176
|
-
if model_association
|
|
176
|
+
if model_association && !model_association.polymorphic?
|
|
177
177
|
model_name = ForestLiana.name_for(model_association.klass)
|
|
178
178
|
# NOTICE: Join fields in case of model with self-references.
|
|
179
179
|
if fields[model_name]
|
|
@@ -184,6 +184,8 @@ module ForestLiana
|
|
|
184
184
|
else
|
|
185
185
|
fields[model_name] = relation_fields
|
|
186
186
|
end
|
|
187
|
+
elsif model_association && model_association.polymorphic?
|
|
188
|
+
fields[relation_name] = relation_fields
|
|
187
189
|
end
|
|
188
190
|
else
|
|
189
191
|
smart_relations.each do |smart_relation|
|
|
@@ -227,7 +229,9 @@ module ForestLiana
|
|
|
227
229
|
included = json['included']
|
|
228
230
|
|
|
229
231
|
values = field_names_requested.map do |field_name|
|
|
230
|
-
if
|
|
232
|
+
if field_name == 'id'
|
|
233
|
+
json['data']['id']
|
|
234
|
+
elsif record_attributes[field_name]
|
|
231
235
|
record_attributes[field_name]
|
|
232
236
|
elsif record_relationships[field_name] &&
|
|
233
237
|
record_relationships[field_name]['data']
|
|
@@ -122,6 +122,15 @@ module ForestLiana
|
|
|
122
122
|
serializer = Class.new {
|
|
123
123
|
include ForestAdmin::JSONAPI::Serializer
|
|
124
124
|
|
|
125
|
+
def id
|
|
126
|
+
pk = object.class.primary_key
|
|
127
|
+
if pk.is_a?(Array)
|
|
128
|
+
pk.map { |key| object.send(key) }.to_json
|
|
129
|
+
else
|
|
130
|
+
object.id.to_s
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
125
134
|
def self_link
|
|
126
135
|
"/forest#{super.underscore}"
|
|
127
136
|
end
|
|
@@ -25,7 +25,29 @@ module ForestLiana
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def count
|
|
28
|
-
|
|
28
|
+
association_class = model_association
|
|
29
|
+
|
|
30
|
+
if association_class.primary_key.is_a?(Array)
|
|
31
|
+
adapter_name = association_class.connection.adapter_name.downcase
|
|
32
|
+
|
|
33
|
+
if adapter_name.include?('sqlite')
|
|
34
|
+
# For SQLite: concatenate columns for DISTINCT count
|
|
35
|
+
pk_concat = association_class.primary_key.map do |pk|
|
|
36
|
+
"#{association_class.table_name}.#{pk}"
|
|
37
|
+
end.join(" || '|' || ")
|
|
38
|
+
|
|
39
|
+
@records_count = @records.distinct.count(Arel.sql(pk_concat))
|
|
40
|
+
else
|
|
41
|
+
# For PostgreSQL/MySQL: use DISTINCT with multiple columns
|
|
42
|
+
pk_columns = association_class.primary_key.map do |pk|
|
|
43
|
+
"#{association_class.table_name}.#{pk}"
|
|
44
|
+
end.join(', ')
|
|
45
|
+
|
|
46
|
+
@records_count = @records.distinct.count(Arel.sql(pk_columns))
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
@records_count = @records.count
|
|
50
|
+
end
|
|
29
51
|
end
|
|
30
52
|
|
|
31
53
|
def query_for_batch
|
|
@@ -72,7 +94,8 @@ module ForestLiana
|
|
|
72
94
|
end
|
|
73
95
|
|
|
74
96
|
def prepare_query
|
|
75
|
-
|
|
97
|
+
parent_record = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(get_resource(), @resource, @params[:id])
|
|
98
|
+
association = parent_record.send(@params[:association_name])
|
|
76
99
|
@records = optimize_record_loading(association, @search_query_builder.perform(association))
|
|
77
100
|
end
|
|
78
101
|
|
|
@@ -14,7 +14,7 @@ module ForestLiana
|
|
|
14
14
|
def perform
|
|
15
15
|
records = optimize_record_loading(@resource, get_resource())
|
|
16
16
|
scoped_records = ForestLiana::ScopeManager.apply_scopes_on_records(records, @user, @collection_name, @params[:timezone])
|
|
17
|
-
@record =
|
|
17
|
+
@record = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(scoped_records, @resource, @params[:id])
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -16,6 +16,7 @@ module ForestLiana
|
|
|
16
16
|
@search_query_builder = SearchQueryBuilder.new(@params, @includes, @collection, @user)
|
|
17
17
|
|
|
18
18
|
prepare_query
|
|
19
|
+
@base_records_for_batch = @records
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def self.get_ids_from_request(params, user)
|
|
@@ -51,7 +52,7 @@ module ForestLiana
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def query_for_batch
|
|
54
|
-
@
|
|
55
|
+
@base_records_for_batch
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def records
|
|
@@ -84,7 +85,10 @@ module ForestLiana
|
|
|
84
85
|
columns = association.klass.column_names.map(&:to_sym)
|
|
85
86
|
|
|
86
87
|
# Ensure the foreign key is present for manual binding (especially for has_one)
|
|
87
|
-
|
|
88
|
+
if association.macro == :has_one
|
|
89
|
+
foreign_keys = Array(association.foreign_key).map(&:to_sym)
|
|
90
|
+
columns.concat(foreign_keys)
|
|
91
|
+
end
|
|
88
92
|
|
|
89
93
|
columns.uniq
|
|
90
94
|
end
|
|
@@ -94,17 +98,7 @@ module ForestLiana
|
|
|
94
98
|
|
|
95
99
|
@optional_includes = []
|
|
96
100
|
if @field_names_requested && @params['searchExtended'].to_i != 1
|
|
97
|
-
includes = associations_has_one.map
|
|
98
|
-
association_name = association.name.to_s
|
|
99
|
-
|
|
100
|
-
fields = @params[:fields]&.[](association_name)&.split(',')
|
|
101
|
-
if fields&.size == 1 && fields.include?(association.klass.primary_key)
|
|
102
|
-
@field_names_requested << association.foreign_key
|
|
103
|
-
@optional_includes << association.name
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
association.name
|
|
107
|
-
end
|
|
101
|
+
includes = associations_has_one.map(&:name)
|
|
108
102
|
|
|
109
103
|
includes_for_smart_search = []
|
|
110
104
|
if @collection && @collection.search_fields
|
|
@@ -119,7 +113,13 @@ module ForestLiana
|
|
|
119
113
|
includes_for_smart_search = includes_for_smart_search & includes_has_many
|
|
120
114
|
end
|
|
121
115
|
|
|
122
|
-
|
|
116
|
+
filter_associations = extract_associations_from_filter
|
|
117
|
+
filter_has_many = filter_associations.select do |assoc_name|
|
|
118
|
+
assoc = @resource.reflect_on_association(assoc_name)
|
|
119
|
+
assoc && [:has_many, :has_and_belongs_to_many].include?(assoc.macro)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@includes = (includes & @field_names_requested).concat(includes_for_smart_search).concat(filter_has_many).uniq
|
|
123
123
|
else
|
|
124
124
|
@includes = associations_has_one
|
|
125
125
|
# Avoid eager loading has_one associations pointing to a different database as ORM can't join cross databases
|
|
@@ -167,8 +167,13 @@ module ForestLiana
|
|
|
167
167
|
conditions.each do |condition|
|
|
168
168
|
field = condition['field']
|
|
169
169
|
if field&.include?(':')
|
|
170
|
+
# Handle association filters with : separator (e.g., "user:name")
|
|
170
171
|
associations << field.split(':').first.to_sym
|
|
171
172
|
@count_needs_includes = true
|
|
173
|
+
elsif field&.include?('.')
|
|
174
|
+
# Handle nested association filters with . separator (e.g., "top_level_partner.display_name")
|
|
175
|
+
associations << field.split('.').first.to_sym
|
|
176
|
+
@count_needs_includes = true
|
|
172
177
|
end
|
|
173
178
|
end
|
|
174
179
|
|
|
@@ -285,17 +290,91 @@ module ForestLiana
|
|
|
285
290
|
|
|
286
291
|
def compute_select_fields
|
|
287
292
|
select = ['_forest_admin_eager_load']
|
|
293
|
+
|
|
294
|
+
pk = @resource.primary_key
|
|
295
|
+
if pk.is_a?(Array)
|
|
296
|
+
pk.each { |key| select << "#{@resource.table_name}.#{key}" }
|
|
297
|
+
else
|
|
298
|
+
select << "#{@resource.table_name}.#{pk}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Include columns used in default ordering for batch cursor compatibility
|
|
302
|
+
if @resource.respond_to?(:default_scoped) && @resource.default_scoped.order_values.any?
|
|
303
|
+
@resource.default_scoped.order_values.each do |order_value|
|
|
304
|
+
if order_value.is_a?(Arel::Nodes::Ordering)
|
|
305
|
+
# Extract column name from Arel node
|
|
306
|
+
column_name = order_value.expr.name if order_value.expr.respond_to?(:name)
|
|
307
|
+
select << "#{@resource.table_name}.#{column_name}" if column_name
|
|
308
|
+
elsif order_value.is_a?(String) || order_value.is_a?(Symbol)
|
|
309
|
+
# Handle simple column names
|
|
310
|
+
column_name = order_value.to_s.split(' ').first.split('.').last
|
|
311
|
+
select << "#{@resource.table_name}.#{column_name}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Handle ActiveStorage associations from both @includes and @field_names_requested
|
|
317
|
+
active_storage_associations_processed = Set.new
|
|
318
|
+
|
|
319
|
+
(@includes + @field_names_requested).each do |path|
|
|
320
|
+
association = path.is_a?(Symbol) ? @resource.reflect_on_association(path) : get_one_association(path)
|
|
321
|
+
next unless association
|
|
322
|
+
next if active_storage_associations_processed.include?(association.name)
|
|
323
|
+
next unless is_active_storage_association?(association)
|
|
324
|
+
|
|
325
|
+
# Include all columns from ActiveStorage tables to avoid initialization errors
|
|
326
|
+
table_name = association.table_name
|
|
327
|
+
association.klass.column_names.each do |column_name|
|
|
328
|
+
select << "#{table_name}.#{column_name}"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Include the foreign key from the main resource (e.g., blob_id, record_id)
|
|
332
|
+
if association.macro == :belongs_to || association.macro == :has_one
|
|
333
|
+
foreign_keys = Array(association.foreign_key)
|
|
334
|
+
foreign_keys.each do |fk|
|
|
335
|
+
select << "#{@resource.table_name}.#{fk}"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
active_storage_associations_processed.add(association.name)
|
|
340
|
+
end
|
|
341
|
+
|
|
288
342
|
@field_names_requested.each do |path|
|
|
289
343
|
association = get_one_association(path)
|
|
290
344
|
if association
|
|
345
|
+
through_chain = []
|
|
291
346
|
while association.options[:through]
|
|
347
|
+
through_chain << association.options[:through]
|
|
292
348
|
association = get_one_association(association.options[:through])
|
|
293
349
|
end
|
|
294
350
|
|
|
295
|
-
|
|
296
|
-
|
|
351
|
+
# Skip ActiveStorage associations - already processed above
|
|
352
|
+
next if is_active_storage_association?(association)
|
|
353
|
+
|
|
354
|
+
# For :through associations, only add foreign keys from the direct (first) association in the chain
|
|
355
|
+
# Don't try to select columns from the main table for the final :through target
|
|
356
|
+
if through_chain.any?
|
|
357
|
+
# Use the first association in the through chain
|
|
358
|
+
first_through = get_one_association(through_chain.first)
|
|
359
|
+
if first_through && (first_through.macro == :belongs_to || first_through.macro == :has_one)
|
|
360
|
+
foreign_keys = Array(first_through.foreign_key)
|
|
361
|
+
foreign_keys.each do |fk|
|
|
362
|
+
select << "#{@resource.table_name}.#{fk}"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
else
|
|
366
|
+
# Direct association (not :through)
|
|
367
|
+
if SchemaUtils.polymorphic?(association)
|
|
368
|
+
select << "#{@resource.table_name}.#{association.foreign_type}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
if association.macro == :belongs_to || association.macro == :has_one
|
|
372
|
+
foreign_keys = Array(association.foreign_key)
|
|
373
|
+
foreign_keys.each do |fk|
|
|
374
|
+
select << "#{@resource.table_name}.#{fk}"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
297
377
|
end
|
|
298
|
-
select << "#{@resource.table_name}.#{association.foreign_key}"
|
|
299
378
|
end
|
|
300
379
|
|
|
301
380
|
fields = @params[:fields]&.[](path)&.split(',')
|
|
@@ -303,7 +382,11 @@ module ForestLiana
|
|
|
303
382
|
association = get_one_association(path)
|
|
304
383
|
table_name = association.table_name
|
|
305
384
|
|
|
385
|
+
next if association && is_active_storage_association?(association)
|
|
386
|
+
|
|
306
387
|
fields.each do |association_path|
|
|
388
|
+
next if association_path == 'id'
|
|
389
|
+
|
|
307
390
|
if ForestLiana::SchemaHelper.is_smart_field?(association.klass, association_path)
|
|
308
391
|
association.klass.attribute_names.each { |attribute| select << "#{table_name}.#{attribute}" }
|
|
309
392
|
else
|
|
@@ -319,9 +402,21 @@ module ForestLiana
|
|
|
319
402
|
end
|
|
320
403
|
|
|
321
404
|
def get_one_association(name)
|
|
405
|
+
# Handle composite primary keys - name might be an Array
|
|
406
|
+
name_sym = name.is_a?(Array) ? name : name.to_sym
|
|
322
407
|
ForestLiana::QueryHelper.get_one_associations(@resource)
|
|
323
|
-
.select { |association| association.name ==
|
|
408
|
+
.select { |association| association.name == name_sym }
|
|
324
409
|
.first
|
|
325
410
|
end
|
|
411
|
+
|
|
412
|
+
def is_active_storage_association?(association)
|
|
413
|
+
return false unless association
|
|
414
|
+
return false if SchemaUtils.polymorphic?(association)
|
|
415
|
+
|
|
416
|
+
klass_name = association.klass.name
|
|
417
|
+
klass_name == 'ActiveStorage::Attachment' ||
|
|
418
|
+
klass_name == 'ActiveStorage::Blob' ||
|
|
419
|
+
klass_name.start_with?('ActiveStorage::')
|
|
420
|
+
end
|
|
326
421
|
end
|
|
327
422
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
module Utils
|
|
3
|
+
module CompositePrimaryKeyHelper
|
|
4
|
+
def self.find_record(scoped_records, resource, id)
|
|
5
|
+
primary_key = resource.primary_key
|
|
6
|
+
|
|
7
|
+
if primary_key.is_a?(Array)
|
|
8
|
+
id_values = parse_composite_id(id)
|
|
9
|
+
conditions = primary_key.zip(id_values).to_h
|
|
10
|
+
scoped_records.find_by(conditions)
|
|
11
|
+
else
|
|
12
|
+
scoped_records.find(id)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.parse_composite_id(id)
|
|
17
|
+
return id if id.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
if id.to_s.start_with?('[') && id.to_s.end_with?(']')
|
|
20
|
+
JSON.parse(id.to_s)
|
|
21
|
+
else
|
|
22
|
+
raise ForestLiana::Errors::HTTP422Error.new("Composite primary key ID must be in format [value1,value2], received: #{id}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -5,7 +5,14 @@ module ForestLiana
|
|
|
5
5
|
def self.inject_context_in_value(value, context_variables)
|
|
6
6
|
inject_context_in_value_custom(value) do |context_variable_key|
|
|
7
7
|
value = context_variables.get_value(context_variable_key)
|
|
8
|
-
|
|
8
|
+
if value.nil?
|
|
9
|
+
available_keys = context_variables.respond_to?(:keys) ? context_variables.keys.join(', ') : 'unknown'
|
|
10
|
+
available_context = context_variables.inspect
|
|
11
|
+
error_message = "Unknown context variable: '#{context_variable_key}'. " \
|
|
12
|
+
"Please check the query for any typos. " \
|
|
13
|
+
"Available context variables: #{available_keys}. "
|
|
14
|
+
raise error_message
|
|
15
|
+
end
|
|
9
16
|
value.to_s
|
|
10
17
|
end
|
|
11
18
|
end
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -309,6 +309,82 @@ describe 'Requesting User resources', :type => :request do
|
|
|
309
309
|
end
|
|
310
310
|
end
|
|
311
311
|
|
|
312
|
+
describe 'Requesting Island resources', :type => :request do
|
|
313
|
+
let(:scope_filters) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} }
|
|
314
|
+
before do
|
|
315
|
+
island = Island.create(name: 'Paradise Island')
|
|
316
|
+
Location.create(coordinates: '10,20', island: island)
|
|
317
|
+
|
|
318
|
+
Rails.cache.write('forest.users', {'1' => { 'id' => 1, 'roleId' => 1, 'rendering_id' => '1' }})
|
|
319
|
+
Rails.cache.write('forest.has_permission', true)
|
|
320
|
+
Rails.cache.write(
|
|
321
|
+
'forest.collections',
|
|
322
|
+
{
|
|
323
|
+
'Island' => {
|
|
324
|
+
'browse' => [1],
|
|
325
|
+
'read' => [1],
|
|
326
|
+
'edit' => [1],
|
|
327
|
+
'add' => [1],
|
|
328
|
+
'delete' => [1],
|
|
329
|
+
'export' => [1],
|
|
330
|
+
'actions' => {}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
336
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
337
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
338
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scope_filters)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
after do
|
|
342
|
+
Island.destroy_all
|
|
343
|
+
Location.destroy_all
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
token = JWT.encode({
|
|
347
|
+
id: 1,
|
|
348
|
+
email: 'michael.kelso@that70.show',
|
|
349
|
+
first_name: 'Michael',
|
|
350
|
+
last_name: 'Kelso',
|
|
351
|
+
team: 'Operations',
|
|
352
|
+
rendering_id: 16,
|
|
353
|
+
exp: Time.now.to_i + 2.weeks.to_i,
|
|
354
|
+
permission_level: 'admin'
|
|
355
|
+
}, ForestLiana.auth_secret, 'HS256')
|
|
356
|
+
|
|
357
|
+
headers = {
|
|
358
|
+
'Accept' => 'application/json',
|
|
359
|
+
'Content-Type' => 'application/json',
|
|
360
|
+
'Authorization' => "Bearer #{token}"
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
describe 'csv' do
|
|
364
|
+
it 'should return CSV with has_one association without SQL error' do
|
|
365
|
+
params = {
|
|
366
|
+
fields: { 'Island' => 'id,name,location', 'location' => 'coordinates'},
|
|
367
|
+
page: { 'number' => '1', 'size' => '10' },
|
|
368
|
+
searchExtended: '0',
|
|
369
|
+
sort: '-id',
|
|
370
|
+
timezone: 'Europe/Paris',
|
|
371
|
+
header: 'id,name,location',
|
|
372
|
+
}
|
|
373
|
+
get '/forest/Island.csv', params: params, headers: headers
|
|
374
|
+
|
|
375
|
+
expect(response.status).to eq(200)
|
|
376
|
+
expect(response.headers['Content-Type']).to include('text/csv')
|
|
377
|
+
expect(response.headers['Content-Disposition']).to include('attachment')
|
|
378
|
+
|
|
379
|
+
csv_content = response.body
|
|
380
|
+
csv_lines = csv_content.split("\n")
|
|
381
|
+
|
|
382
|
+
expect(csv_lines.first).to eq(params[:header])
|
|
383
|
+
expect(csv_lines[1]).to eq('1,Paradise Island,"10,20"')
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
312
388
|
describe 'Requesting Address resources', :type => :request do
|
|
313
389
|
let(:scope_filters) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} }
|
|
314
390
|
before do
|
|
@@ -391,4 +467,50 @@ describe 'Requesting Address resources', :type => :request do
|
|
|
391
467
|
)
|
|
392
468
|
end
|
|
393
469
|
end
|
|
470
|
+
|
|
471
|
+
describe 'csv' do
|
|
472
|
+
it 'should return CSV with polymorphic association' do
|
|
473
|
+
params = {
|
|
474
|
+
fields: { 'Address' => 'id,line1,city,addressable', 'addressable' => 'name'},
|
|
475
|
+
page: { 'number' => '1', 'size' => '10' },
|
|
476
|
+
searchExtended: '0',
|
|
477
|
+
sort: '-id',
|
|
478
|
+
timezone: 'Europe/Paris',
|
|
479
|
+
header: 'id,line1,city,addressable',
|
|
480
|
+
}
|
|
481
|
+
get '/forest/Address.csv', params: params, headers: headers
|
|
482
|
+
|
|
483
|
+
expect(response.status).to eq(200)
|
|
484
|
+
expect(response.headers['Content-Type']).to include('text/csv')
|
|
485
|
+
expect(response.headers['Content-Disposition']).to include('attachment')
|
|
486
|
+
|
|
487
|
+
csv_content = response.body
|
|
488
|
+
csv_lines = csv_content.split("\n")
|
|
489
|
+
|
|
490
|
+
expect(csv_lines.first).to eq(params[:header])
|
|
491
|
+
expect(csv_lines[1]).to eq('1,10 Downing Street,London,Michel')
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
it 'should return CSV with only requested fields and ignore optional polymorphic relation' do
|
|
495
|
+
params = {
|
|
496
|
+
fields: { 'Address' => 'id,line1,city', 'addressable' => 'name'},
|
|
497
|
+
page: { 'number' => '1', 'size' => '10' },
|
|
498
|
+
searchExtended: '0',
|
|
499
|
+
sort: '-id',
|
|
500
|
+
timezone: 'Europe/Paris',
|
|
501
|
+
header: 'id,line1,city',
|
|
502
|
+
}
|
|
503
|
+
get '/forest/Address.csv', params: params, headers: headers
|
|
504
|
+
|
|
505
|
+
expect(response.status).to eq(200)
|
|
506
|
+
expect(response.headers['Content-Type']).to include('text/csv')
|
|
507
|
+
expect(response.headers['Content-Disposition']).to include('attachment')
|
|
508
|
+
|
|
509
|
+
csv_content = response.body
|
|
510
|
+
csv_lines = csv_content.split("\n")
|
|
511
|
+
|
|
512
|
+
expect(csv_lines.first).to eq(params[:header])
|
|
513
|
+
expect(csv_lines[1]).to eq('1,10 Downing Street,London')
|
|
514
|
+
end
|
|
515
|
+
end
|
|
394
516
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
module ForestLiana
|
|
4
|
+
describe ResourcesGetter do
|
|
5
|
+
describe 'composite primary keys support' do
|
|
6
|
+
let(:resource) { User }
|
|
7
|
+
let(:params) do
|
|
8
|
+
{
|
|
9
|
+
page: { size: 10, number: 1 },
|
|
10
|
+
sort: 'id',
|
|
11
|
+
fields: { 'User' => 'id,name' }
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
let(:user) { { 'id' => '1', 'rendering_id' => 13 } }
|
|
15
|
+
|
|
16
|
+
before do
|
|
17
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return({
|
|
18
|
+
'scopes' => {},
|
|
19
|
+
'team' => {'id' => '1', 'name' => 'Operations'}
|
|
20
|
+
})
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#get_one_association' do
|
|
24
|
+
it 'does not crash when name is a symbol' do
|
|
25
|
+
getter = described_class.new(resource, params, user)
|
|
26
|
+
expect {
|
|
27
|
+
getter.send(:get_one_association, :owner)
|
|
28
|
+
}.not_to raise_error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'does not crash when name is a string' do
|
|
32
|
+
getter = described_class.new(resource, params, user)
|
|
33
|
+
expect {
|
|
34
|
+
getter.send(:get_one_association, 'owner')
|
|
35
|
+
}.not_to raise_error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'does not crash when name is an array (composite key edge case)' do
|
|
39
|
+
getter = described_class.new(resource, params, user)
|
|
40
|
+
# Should not raise "undefined method `to_sym' for Array"
|
|
41
|
+
expect {
|
|
42
|
+
getter.send(:get_one_association, [:user_id, :slot_id])
|
|
43
|
+
}.not_to raise_error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'returns nil gracefully when name is an array' do
|
|
47
|
+
getter = described_class.new(resource, params, user)
|
|
48
|
+
result = getter.send(:get_one_association, [:user_id, :slot_id])
|
|
49
|
+
expect(result).to be_nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe 'handling composite foreign keys in associations' do
|
|
54
|
+
let(:mock_association) do
|
|
55
|
+
double('Association',
|
|
56
|
+
name: :test_association,
|
|
57
|
+
foreign_key: [:user_id, :slot_id], # Composite foreign key
|
|
58
|
+
klass: double('Klass', column_names: ['id', 'name']),
|
|
59
|
+
macro: :has_one,
|
|
60
|
+
options: {}
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
let(:simple_association) do
|
|
65
|
+
double('Association',
|
|
66
|
+
name: :simple_association,
|
|
67
|
+
foreign_key: 'user_id', # Simple foreign key
|
|
68
|
+
klass: double('Klass', column_names: ['id', 'name']),
|
|
69
|
+
macro: :has_one,
|
|
70
|
+
options: {}
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe '#columns_for_cross_database_association' do
|
|
75
|
+
it 'handles composite foreign keys without crashing' do
|
|
76
|
+
getter = described_class.new(resource, params, user)
|
|
77
|
+
|
|
78
|
+
allow(resource).to receive(:reflect_on_association)
|
|
79
|
+
.with(:test_association)
|
|
80
|
+
.and_return(mock_association)
|
|
81
|
+
|
|
82
|
+
expect {
|
|
83
|
+
getter.send(:columns_for_cross_database_association, :test_association)
|
|
84
|
+
}.not_to raise_error
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'includes all composite foreign key columns' do
|
|
88
|
+
getter = described_class.new(resource, params, user)
|
|
89
|
+
|
|
90
|
+
allow(resource).to receive(:reflect_on_association)
|
|
91
|
+
.with(:test_association)
|
|
92
|
+
.and_return(mock_association)
|
|
93
|
+
|
|
94
|
+
columns = getter.send(:columns_for_cross_database_association, :test_association)
|
|
95
|
+
|
|
96
|
+
expect(columns).to include(:user_id)
|
|
97
|
+
expect(columns).to include(:slot_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'handles simple foreign keys without breaking existing behavior' do
|
|
101
|
+
getter = described_class.new(resource, params, user)
|
|
102
|
+
|
|
103
|
+
allow(resource).to receive(:reflect_on_association)
|
|
104
|
+
.with(:simple_association)
|
|
105
|
+
.and_return(simple_association)
|
|
106
|
+
|
|
107
|
+
expect {
|
|
108
|
+
columns = getter.send(:columns_for_cross_database_association, :simple_association)
|
|
109
|
+
expect(columns).to include(:user_id)
|
|
110
|
+
}.not_to raise_error
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -105,7 +105,7 @@ module ForestLiana
|
|
|
105
105
|
it 'raises an error when the variable is not found' do
|
|
106
106
|
expect {
|
|
107
107
|
described_class.inject_context_in_value("{{siths.selectedRecord.evilString}}", context_variables)
|
|
108
|
-
}.to raise_error(
|
|
108
|
+
}.to raise_error(/Unknown context variable: 'siths\.selectedRecord\.evilString'/)
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_liana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 9.16.
|
|
4
|
+
version: 9.16.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sandro Munda
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-12-
|
|
11
|
+
date: 2025-12-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -306,6 +306,7 @@ files:
|
|
|
306
306
|
- app/services/forest_liana/stripe_subscriptions_getter.rb
|
|
307
307
|
- app/services/forest_liana/token.rb
|
|
308
308
|
- app/services/forest_liana/utils/beta_schema_utils.rb
|
|
309
|
+
- app/services/forest_liana/utils/composite_primary_key_helper.rb
|
|
309
310
|
- app/services/forest_liana/utils/context_variables.rb
|
|
310
311
|
- app/services/forest_liana/utils/context_variables_injector.rb
|
|
311
312
|
- app/services/forest_liana/value_stat_getter.rb
|
|
@@ -430,6 +431,7 @@ files:
|
|
|
430
431
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
431
432
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
432
433
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
434
|
+
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
433
435
|
- spec/services/forest_liana/resources_getter_spec.rb
|
|
434
436
|
- spec/services/forest_liana/schema_adapter_spec.rb
|
|
435
437
|
- spec/services/forest_liana/scope_manager_spec.rb
|
|
@@ -737,6 +739,7 @@ test_files:
|
|
|
737
739
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
738
740
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
739
741
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
742
|
+
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
740
743
|
- spec/services/forest_liana/resources_getter_spec.rb
|
|
741
744
|
- spec/services/forest_liana/schema_adapter_spec.rb
|
|
742
745
|
- spec/services/forest_liana/scope_manager_spec.rb
|