forest_liana 9.17.1 → 9.17.2

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: 16bdb91facdb11410af87209603b89aee45670322979b84bcb586a753fe47179
4
- data.tar.gz: 0c748d62bc3081e48a2ad46f1c7a27ce133b7e5c2b2eb9e9fa1f5c64c2a6491e
3
+ metadata.gz: 44ec79f4e42ec91b8c5306d87672aab03a6b30072e88e27fdcb311d3d7d2c8de
4
+ data.tar.gz: e5f37934ccdbf40a78499fd22fcafcd037b8eed1b12034c773bbb499d16b60c7
5
5
  SHA512:
6
- metadata.gz: 426d29525742b6b53c939838a6db17ccfc40f781fa3ae9bc1e9d085170468944d9487c52c653c5d5ff45ea991303ceb809916c1b6e49389153baf3476026a448
7
- data.tar.gz: 1f7db109fff86b8d6ac6bfb53e31aa0a6e2a449a8074243359ece98c69058a4f361aa0ce92f4d632dfd08a0e758e316e70e2b09d446fcfa646d029fe394969c5
6
+ metadata.gz: 93d789834be64fe90cdc9b58c8201df5b75396d627a1138ec1d46e437cfb60d7d63246ece332db3890a6a0cb5eb672683655abc5736dc5c85d38ab2aea47e41a
7
+ data.tar.gz: 91b3c9bf5856f4b628ca31b4140bab3f00b96bd3a7afe670cefaa8701499a86e2fca9e0c431b7a621c86283bae0eefa13afb5104f25ad89589105eb007fa34b9
@@ -1,5 +1,7 @@
1
1
  module ForestLiana
2
2
  class BaseGetter
3
+ include ForestLiana::RecordFindable
4
+
3
5
  def get_collection(collection_name)
4
6
  ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
5
7
  end
@@ -1,5 +1,7 @@
1
1
  module ForestLiana
2
2
  class BelongsToUpdater
3
+ include ForestLiana::RecordFindable
4
+
3
5
  attr_accessor :errors
4
6
 
5
7
  def initialize(resource, association, params)
@@ -12,7 +14,7 @@ module ForestLiana
12
14
 
13
15
  def perform
14
16
  begin
15
- @record = @resource.find(@params[:id])
17
+ @record = find_record(@resource, @resource, @params[:id])
16
18
  if (SchemaUtils.polymorphic?(@association))
17
19
  if @data.nil?
18
20
  new_value = nil
@@ -1,5 +1,7 @@
1
1
  module ForestLiana
2
2
  class HasManyAssociator
3
+ include ForestLiana::RecordFindable
4
+
3
5
  def initialize(resource, association, params)
4
6
  @resource = resource
5
7
  @association = association
@@ -8,8 +10,8 @@ module ForestLiana
8
10
  end
9
11
 
10
12
  def perform
11
- @record = @resource.find(@params[:id])
12
- associated_records = @resource.find(@params[:id]).send(@association.name)
13
+ @record = find_record(@resource, @resource, @params[:id])
14
+ associated_records = @record.send(@association.name)
13
15
 
14
16
  if @data.is_a?(Array)
15
17
  @data.each do |record_added|
@@ -1,5 +1,7 @@
1
1
  module ForestLiana
2
2
  class HasManyDissociator
3
+ include ForestLiana::RecordFindable
4
+
3
5
  def initialize(resource, association, params, forest_user)
4
6
  @resource = resource
5
7
  @association = association
@@ -10,8 +12,8 @@ module ForestLiana
10
12
  end
11
13
 
12
14
  def perform
13
- @record = @resource.find(@params[:id])
14
- associated_records = @resource.find(@params[:id]).send(@association.name)
15
+ @record = find_record(@resource, @resource, @params[:id])
16
+ associated_records = @record.send(@association.name)
15
17
 
16
18
  remove_association = !@with_deletion || @association.macro == :has_and_belongs_to_many
17
19
  if @data.is_a?(Array)
@@ -29,6 +29,9 @@ module ForestLiana
29
29
 
30
30
  if association_class.primary_key.is_a?(Array)
31
31
  adapter_name = association_class.connection.adapter_name.downcase
32
+ pk_columns = association_class.primary_key.map do |pk|
33
+ "#{association_class.table_name}.#{pk}"
34
+ end.join(', ')
32
35
 
33
36
  if adapter_name.include?('sqlite')
34
37
  # For SQLite: concatenate columns for DISTINCT count
@@ -37,12 +40,9 @@ module ForestLiana
37
40
  end.join(" || '|' || ")
38
41
 
39
42
  @records_count = @records.distinct.count(Arel.sql(pk_concat))
43
+ elsif adapter_name.include?('postgresql')
44
+ @records_count = @records.distinct.count(Arel.sql("ROW(#{pk_columns})"))
40
45
  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
46
  @records_count = @records.distinct.count(Arel.sql(pk_columns))
47
47
  end
48
48
  else
@@ -94,7 +94,7 @@ module ForestLiana
94
94
  end
95
95
 
96
96
  def prepare_query
97
- parent_record = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(get_resource(), @resource, @params[:id])
97
+ parent_record = find_record(get_resource(), @resource, @params[:id])
98
98
  association = parent_record.send(@params[:association_name])
99
99
  @records = optimize_record_loading(association, @search_query_builder.perform(association))
100
100
  end
@@ -0,0 +1,27 @@
1
+ module ForestLiana
2
+ module RecordFindable
3
+ private
4
+
5
+ def find_record(scope, resource, id)
6
+ primary_key = resource.primary_key
7
+
8
+ if primary_key.is_a?(Array)
9
+ id_values = parse_composite_id(id)
10
+ conditions = primary_key.zip(id_values).to_h
11
+ scope.find_by(conditions)
12
+ else
13
+ scope.find(id)
14
+ end
15
+ end
16
+
17
+ def parse_composite_id(id)
18
+ return id if id.is_a?(Array)
19
+
20
+ if id.to_s.start_with?('[') && id.to_s.end_with?(']')
21
+ JSON.parse(id.to_s)
22
+ else
23
+ raise ForestLiana::Errors::HTTP422Error.new("Composite primary key ID must be in format [value1,value2], received: #{id}")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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 = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(scoped_records, @resource, @params[:id])
17
+ @record = find_record(scoped_records, @resource, @params[:id])
18
18
  end
19
19
  end
20
20
  end
@@ -1,5 +1,7 @@
1
1
  module ForestLiana
2
2
  class ResourceUpdater
3
+ include ForestLiana::RecordFindable
4
+
3
5
  attr_accessor :record
4
6
  attr_accessor :errors
5
7
 
@@ -14,7 +16,7 @@ module ForestLiana
14
16
  begin
15
17
  collection_name = ForestLiana.name_for(@resource)
16
18
  scoped_records = ForestLiana::ScopeManager.apply_scopes_on_records(@resource, @user, collection_name, @params[:timezone])
17
- @record = scoped_records.find(@params[:id])
19
+ @record = find_record(scoped_records, @resource, @params[:id])
18
20
 
19
21
  if has_strong_parameter
20
22
  @record.update(resource_params)
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.17.1"
2
+ VERSION = "9.17.2"
3
3
  end
@@ -0,0 +1,223 @@
1
+ require 'rails_helper'
2
+
3
+ module ForestLiana
4
+ describe 'Composite Primary Keys Support' do
5
+ let(:rendering_id) { 13 }
6
+ let(:user) { { 'id' => '1', 'rendering_id' => rendering_id } }
7
+ let(:scopes) { { 'scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' } } }
8
+
9
+ before do
10
+ ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
11
+ allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scopes)
12
+ end
13
+
14
+ describe RecordFindable do
15
+ # Create a test class that includes the module to test private methods
16
+ let(:test_class) do
17
+ Class.new do
18
+ include ForestLiana::RecordFindable
19
+ # Expose private methods for testing
20
+ public :find_record, :parse_composite_id
21
+ end
22
+ end
23
+
24
+ let(:helper) { test_class.new }
25
+
26
+ describe '#parse_composite_id' do
27
+ it 'correctly parses composite ID in JSON format' do
28
+ expect(helper.parse_composite_id('[1,2]')).to eq([1, 2])
29
+ expect(helper.parse_composite_id('[10,20]')).to eq([10, 20])
30
+ expect(helper.parse_composite_id('["a","b"]')).to eq(['a', 'b'])
31
+ end
32
+
33
+ it 'returns array as-is when already an array' do
34
+ expect(helper.parse_composite_id([1, 2])).to eq([1, 2])
35
+ end
36
+
37
+ it 'raises error for invalid composite ID format' do
38
+ expect {
39
+ helper.parse_composite_id('invalid')
40
+ }.to raise_error(ForestLiana::Errors::HTTP422Error)
41
+ end
42
+ end
43
+
44
+ describe '#find_record' do
45
+ it 'finds record using composite key conditions' do
46
+ mock_resource = double('Resource', primary_key: [:user_id, :island_id])
47
+ mock_scoped_records = double('ScopedRecords')
48
+ mock_record = double('Record')
49
+
50
+ allow(mock_scoped_records).to receive(:find_by)
51
+ .with({ user_id: 1, island_id: 2 })
52
+ .and_return(mock_record)
53
+
54
+ result = helper.find_record(mock_scoped_records, mock_resource, '[1,2]')
55
+
56
+ expect(result).to eq(mock_record)
57
+ expect(mock_scoped_records).to have_received(:find_by).with({ user_id: 1, island_id: 2 })
58
+ end
59
+
60
+ it 'falls back to standard find for simple primary key' do
61
+ mock_resource = double('Resource', primary_key: 'id')
62
+ mock_scoped_records = double('ScopedRecords')
63
+ mock_record = double('Record')
64
+
65
+ allow(mock_scoped_records).to receive(:find).with(123).and_return(mock_record)
66
+
67
+ result = helper.find_record(mock_scoped_records, mock_resource, 123)
68
+
69
+ expect(result).to eq(mock_record)
70
+ expect(mock_scoped_records).to have_received(:find).with(123)
71
+ end
72
+ end
73
+ end
74
+
75
+ describe HasManyGetter do
76
+ describe '#count with composite primary key' do
77
+ let(:composite_association_class) do
78
+ mock_class = double('CompositeModel')
79
+ allow(mock_class).to receive(:primary_key).and_return([:user_id, :island_id])
80
+ allow(mock_class).to receive(:table_name).and_return('user_islands')
81
+ allow(mock_class).to receive(:connection).and_return(double('Connection', adapter_name: adapter_name))
82
+ mock_class
83
+ end
84
+
85
+ let(:mock_records) { double('Records') }
86
+
87
+ before do
88
+ allow(mock_records).to receive(:distinct).and_return(mock_records)
89
+ allow(mock_records).to receive(:count).and_return(5)
90
+ end
91
+
92
+ context 'with PostgreSQL adapter' do
93
+ let(:adapter_name) { 'PostgreSQL' }
94
+
95
+ it 'uses ROW() syntax for COUNT DISTINCT' do
96
+ getter = HasManyGetter.allocate
97
+ getter.instance_variable_set(:@records, mock_records)
98
+
99
+ allow(getter).to receive(:model_association).and_return(composite_association_class)
100
+
101
+ getter.count
102
+
103
+ expect(mock_records).to have_received(:count) do |arg|
104
+ expect(arg.to_s).to include('ROW(')
105
+ expect(arg.to_s).to include('user_islands.user_id')
106
+ expect(arg.to_s).to include('user_islands.island_id')
107
+ end
108
+ end
109
+ end
110
+
111
+ context 'with MySQL adapter' do
112
+ let(:adapter_name) { 'Mysql2' }
113
+
114
+ it 'uses standard multi-column syntax for COUNT DISTINCT' do
115
+ getter = HasManyGetter.allocate
116
+ getter.instance_variable_set(:@records, mock_records)
117
+
118
+ allow(getter).to receive(:model_association).and_return(composite_association_class)
119
+
120
+ getter.count
121
+
122
+ expect(mock_records).to have_received(:count) do |arg|
123
+ expect(arg.to_s).not_to include('ROW(')
124
+ expect(arg.to_s).to include('user_islands.user_id, user_islands.island_id')
125
+ end
126
+ end
127
+ end
128
+
129
+ context 'with SQLite adapter' do
130
+ let(:adapter_name) { 'SQLite' }
131
+
132
+ it 'uses concatenation syntax for COUNT DISTINCT' do
133
+ getter = HasManyGetter.allocate
134
+ getter.instance_variable_set(:@records, mock_records)
135
+
136
+ allow(getter).to receive(:model_association).and_return(composite_association_class)
137
+
138
+ getter.count
139
+
140
+ expect(mock_records).to have_received(:count) do |arg|
141
+ expect(arg.to_s).to include("||")
142
+ expect(arg.to_s).to include("'|'")
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ describe BelongsToUpdater do
150
+ describe 'with composite primary key parent' do
151
+ it 'uses find_record to find the parent record' do
152
+ composite_model = double('CompositeModel')
153
+ association = double('Association', name: :user, klass: User)
154
+ mock_record = double('Record', save: true)
155
+ allow(mock_record).to receive(:user=)
156
+
157
+ params = { id: '[1,2]', 'data' => { id: '5', type: 'User' } }
158
+
159
+ updater = described_class.new(composite_model, association, params)
160
+ allow(updater).to receive(:find_record).and_return(mock_record)
161
+ allow(User).to receive(:find).and_return(double('User'))
162
+ allow(SchemaUtils).to receive(:polymorphic?).and_return(false)
163
+
164
+ updater.perform
165
+
166
+ expect(updater).to have_received(:find_record)
167
+ .with(composite_model, composite_model, '[1,2]')
168
+ end
169
+ end
170
+ end
171
+
172
+ describe HasManyAssociator do
173
+ describe 'with composite primary key parent' do
174
+ it 'uses find_record to find the parent record' do
175
+ composite_model = double('CompositeModel')
176
+ association = double('Association', name: :trees, klass: Tree)
177
+ mock_record = double('Record')
178
+ mock_associated_records = double('AssociatedRecords')
179
+
180
+ allow(mock_record).to receive(:send).with(:trees).and_return(mock_associated_records)
181
+ allow(mock_associated_records).to receive(:<<)
182
+
183
+ params = { id: '[1,2]', 'data' => [{ id: '5' }] }
184
+
185
+ associator = described_class.new(composite_model, association, params)
186
+ allow(associator).to receive(:find_record).and_return(mock_record)
187
+ allow(Tree).to receive(:find).and_return(double('Tree'))
188
+
189
+ associator.perform
190
+
191
+ expect(associator).to have_received(:find_record)
192
+ .with(composite_model, composite_model, '[1,2]')
193
+ end
194
+ end
195
+ end
196
+
197
+ describe HasManyDissociator do
198
+ describe 'with composite primary key parent' do
199
+ it 'uses find_record to find the parent record' do
200
+ composite_model = double('CompositeModel')
201
+ association = double('Association', name: :trees, klass: Tree, macro: :has_many)
202
+ mock_record = double('Record')
203
+ mock_associated_records = double('AssociatedRecords')
204
+
205
+ allow(mock_record).to receive(:send).with(:trees).and_return(mock_associated_records)
206
+ allow(mock_associated_records).to receive(:delete)
207
+
208
+ params = { id: '[1,2]', 'data' => [{ id: '5' }], delete: 'false' }
209
+ forest_user = user
210
+
211
+ dissociator = described_class.new(composite_model, association, params, forest_user)
212
+ allow(dissociator).to receive(:find_record).and_return(mock_record)
213
+ allow(Tree).to receive(:find).and_return(double('Tree'))
214
+
215
+ dissociator.perform
216
+
217
+ expect(dissociator).to have_received(:find_record)
218
+ .with(composite_model, composite_model, '[1,2]')
219
+ end
220
+ end
221
+ end
222
+ end
223
+ 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.17.1
4
+ version: 9.17.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-13 00:00:00.000000000 Z
11
+ date: 2026-01-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -311,6 +311,7 @@ files:
311
311
  - app/services/forest_liana/operator_date_interval_parser.rb
312
312
  - app/services/forest_liana/pie_stat_getter.rb
313
313
  - app/services/forest_liana/query_stat_getter.rb
314
+ - app/services/forest_liana/record_findable.rb
314
315
  - app/services/forest_liana/resource_creator.rb
315
316
  - app/services/forest_liana/resource_getter.rb
316
317
  - app/services/forest_liana/resource_updater.rb
@@ -334,7 +335,6 @@ files:
334
335
  - app/services/forest_liana/stripe_subscriptions_getter.rb
335
336
  - app/services/forest_liana/token.rb
336
337
  - app/services/forest_liana/utils/beta_schema_utils.rb
337
- - app/services/forest_liana/utils/composite_primary_key_helper.rb
338
338
  - app/services/forest_liana/utils/context_variables.rb
339
339
  - app/services/forest_liana/utils/context_variables_injector.rb
340
340
  - app/services/forest_liana/value_stat_getter.rb
@@ -452,6 +452,7 @@ files:
452
452
  - spec/services/forest_liana/ability/permission/smart_action_checker_spec.rb
453
453
  - spec/services/forest_liana/ability/permission_spec.rb
454
454
  - spec/services/forest_liana/apimap_sorter_spec.rb
455
+ - spec/services/forest_liana/composite_primary_keys_spec.rb
455
456
  - spec/services/forest_liana/filters_parser_spec.rb
456
457
  - spec/services/forest_liana/forest_api_requester_spec.rb
457
458
  - spec/services/forest_liana/has_many_getter_spec.rb
@@ -761,6 +762,7 @@ test_files:
761
762
  - spec/services/forest_liana/ability/permission/smart_action_checker_spec.rb
762
763
  - spec/services/forest_liana/ability/permission_spec.rb
763
764
  - spec/services/forest_liana/apimap_sorter_spec.rb
765
+ - spec/services/forest_liana/composite_primary_keys_spec.rb
764
766
  - spec/services/forest_liana/filters_parser_spec.rb
765
767
  - spec/services/forest_liana/forest_api_requester_spec.rb
766
768
  - spec/services/forest_liana/has_many_getter_spec.rb
@@ -1,27 +0,0 @@
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