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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5b410cf0b004c0b725119f7f0d4328165b344579181d022930a3965c53569b6
4
- data.tar.gz: 0c69947edf290b25d2fa53503f9f754a3d0d73e571ef6af525969c201bb0e469
3
+ metadata.gz: f5def4c0d7226797a8b932f16749a655b526c6278689417b712866c1eb223a78
4
+ data.tar.gz: c08d1acf4f8f36a67590b334cd175c76e87deabc47507dd7a57619a7f94302ec
5
5
  SHA512:
6
- metadata.gz: 8391f47c0f3731827d4170eaae994e230b8567a6166b344581876576d10eabf1df964ae51b1480823ba2d50b3ef08bd53a99851d29dc4db26b92fe60d3df126e
7
- data.tar.gz: 5a934a2555b71fed7b80d49546435c1141223b9d6ba00a7443bf8503e76c1aa38211c906cd95da091242a024e30da9c1007ce5491a4d70a1731cbf4df1b19b07
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 record_attributes[field_name]
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
- @records_count = @records.count
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
- association = get_resource().find(@params[:id]).send(@params[:association_name])
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 = scoped_records.find(@params[:id])
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
- @records
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
- columns << association.foreign_key.to_sym if association.macro == :has_one
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 do |association|
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
- @includes = (includes & @field_names_requested).concat(includes_for_smart_search)
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
- if SchemaUtils.polymorphic?(association)
296
- select << "#{@resource.table_name}.#{association.foreign_type}"
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 == name.to_sym }
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
- raise "Unknown context variable: #{context_variable_key}, please check the query for any typos" if value.nil?
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.16.0"
2
+ VERSION = "9.16.1"
3
3
  end
@@ -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('Unknown context variable: siths.selectedRecord.evilString, please check the query for any typos')
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.0
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-02 00:00:00.000000000 Z
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