forest_liana 9.15.4 → 9.15.6

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: 01b3a5629878eddd5cfdeea4bcb0fadcdd46c5211ac3bf327d8cda5cc6ecb068
4
- data.tar.gz: a05b24374ac64efca265824dcd69167c690b1a7bff67fbafc06ab9d497fa2531
3
+ metadata.gz: 34a394e776ab9ef99fe30e7e367e834f84e5e0fdf0a2314aaa76ec368dc7f0a3
4
+ data.tar.gz: 619adc9e67f4d7a9182be43a383a688a7782de23718a3c2e1640cf361386b61d
5
5
  SHA512:
6
- metadata.gz: 89fcfaf157e5cc76277e85eba8ad01ba92450e8f9081a8602881ae5d7b20bdd23e11b0029ad144000b7b886f081d2f5f0a7444084990df1678a44d471dd94fd8
7
- data.tar.gz: b02ae1262881d03d225f7795e92e26764a738343ba8880b36ed13d44521a3d09686db209dda8a4ecfa260afe3990859ee2eff348892f74891d7e60b11791fcd1
6
+ metadata.gz: b56de9223cf035c62a35bd1cebf6a8390ce0887d11818940a8f945ca90295c0d4ad7d7d63e6991378c224f2f3c549ab011e0127cf41b8e2e99a83c9eabf8d559
7
+ data.tar.gz: db9cf2fa2a26ac1f1d084ffab1046b7cb9d22d1cc4bc4bf36e5dc196ca58792102a2e1d636117d96f50da32f303b4ac57074dd0bf23f8397ccb74523aea97e81
@@ -49,6 +49,10 @@ module ForestLiana
49
49
 
50
50
  def serialize_models(records, options = {}, fields_searched = [])
51
51
  options[:is_collection] = true
52
+ if options[:params] && options[:params][:fields].nil?
53
+ options[:context] = { unoptimized: true }.merge(options[:context] || {})
54
+ end
55
+
52
56
  json = ForestAdmin::JSONAPI::Serializer.serialize(records, options)
53
57
 
54
58
  if options[:params] && options[:params][:search]
@@ -63,6 +67,10 @@ module ForestLiana
63
67
  force_utf8_encoding(json)
64
68
  end
65
69
 
70
+ def get_collection
71
+ raise NotImplementedError, "#{self.class} must implement #get_collection"
72
+ end
73
+
66
74
  def authenticate_user_from_jwt
67
75
  begin
68
76
  if request.headers
@@ -14,6 +14,10 @@ module ForestLiana
14
14
  def self.decorate_for_search(records_serialized, field_names, search_value)
15
15
  match_fields = {}
16
16
  records_serialized['data'].each_with_index do |record, index|
17
+ unless record['attributes']
18
+ raise ArgumentError, "Missing 'attributes' key in record #{record}"
19
+ end
20
+
17
21
  field_names.each do |field_name|
18
22
  value = record['attributes'][field_name]
19
23
  if value
@@ -4,6 +4,8 @@ module ForestLiana
4
4
  attr_reader :includes
5
5
  attr_reader :records_count
6
6
 
7
+ SUPPORTED_ASSOCIATION_MACROS = [:belongs_to, :has_one, :has_and_belongs_to_many].freeze
8
+
7
9
  def initialize(resource, association, params, forest_user)
8
10
  @resource = resource
9
11
  @association = association
@@ -43,22 +45,21 @@ module ForestLiana
43
45
  .reflect_on_all_associations
44
46
  .select do |association|
45
47
 
48
+ next false unless SUPPORTED_ASSOCIATION_MACROS.include?(association.macro)
49
+
46
50
  if SchemaUtils.polymorphic?(association)
47
51
  inclusion = SchemaUtils.polymorphic_models(association)
48
- .all? { |model| SchemaUtils.model_included?(model) } &&
49
- [:belongs_to, :has_and_belongs_to_many].include?(association.macro)
52
+ .all? { |model| SchemaUtils.model_included?(model) }
50
53
  else
51
- inclusion = SchemaUtils.model_included?(association.klass) &&
52
- [:belongs_to, :has_and_belongs_to_many].include?(association.macro)
54
+ inclusion = SchemaUtils.model_included?(association.klass)
53
55
  end
54
56
 
55
- if @field_names_requested
56
- inclusion && @field_names_requested.include?(association.name)
57
- else
58
- inclusion
59
- end
57
+ if @field_names_requested.any?
58
+ inclusion && @field_names_requested.include?(association.name)
59
+ else
60
+ inclusion
60
61
  end
61
- .map { |association| association.name }
62
+ end.map(&:name)
62
63
  end
63
64
 
64
65
  def field_names_requested
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.15.4"
2
+ VERSION = "9.15.6"
3
3
  end
@@ -0,0 +1,234 @@
1
+ module ForestLiana
2
+ describe DecorationHelper do
3
+ describe '.detect_match_and_decorate' do
4
+ let(:record) do
5
+ {
6
+ 'type' => 'User',
7
+ 'id' => '123',
8
+ 'attributes' => {
9
+ 'id' => 123,
10
+ 'name' => 'John Doe',
11
+ 'email' => 'john@example.com'
12
+ },
13
+ 'links' => { 'self' => '/forest/user/123' },
14
+ 'relationships' => {}
15
+ }
16
+ end
17
+ let(:index) { 0 }
18
+ let(:field_name) { 'name' }
19
+ let(:value) { 'John Doe' }
20
+ let(:search_value) { 'john' }
21
+ let(:match_fields) { {} }
22
+
23
+ context 'when value matches search_value' do
24
+ it 'creates new match entry when none exists' do
25
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
26
+
27
+ expect(match_fields[index]).to eq({
28
+ id: '123',
29
+ search: ['name']
30
+ })
31
+ end
32
+
33
+ it 'appends to existing match entry' do
34
+ match_fields[index] = { id: '123', search: ['email'] }
35
+
36
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
37
+
38
+ expect(match_fields[index][:search]).to contain_exactly('email', 'name')
39
+ end
40
+
41
+ it 'performs case-insensitive matching' do
42
+ search_value = 'JOHN'
43
+
44
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
45
+
46
+ expect(match_fields[index]).not_to be_nil
47
+ expect(match_fields[index][:search]).to include('name')
48
+ end
49
+
50
+ it 'matches partial strings' do
51
+ search_value = 'oe'
52
+
53
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
54
+
55
+ expect(match_fields[index][:search]).to include('name')
56
+ end
57
+ end
58
+
59
+ context 'when value does not match search_value' do
60
+ let(:search_value) { 'jane' }
61
+
62
+ it 'does not create match entry' do
63
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
64
+
65
+ expect(match_fields).to be_empty
66
+ end
67
+
68
+ it 'does not modify existing match_fields' do
69
+ existing_data = { id: '456', search: ['other_field'] }
70
+ match_fields[1] = existing_data.dup
71
+
72
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
73
+
74
+ expect(match_fields[1]).to eq(existing_data)
75
+ expect(match_fields[index]).to be_nil
76
+ end
77
+ end
78
+
79
+ context 'when regex matching raises an exception' do
80
+ let(:search_value) { '[invalid_regex' }
81
+
82
+ it 'handles the exception gracefully' do
83
+ expect {
84
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
85
+ }.not_to raise_error
86
+
87
+ expect(match_fields).to be_empty
88
+ end
89
+ end
90
+
91
+ context 'with special regex characters in search_value' do
92
+ let(:search_value) { '.' }
93
+ let(:value) { 'test.email@domain.com' }
94
+
95
+ it 'treats special characters as literal characters' do
96
+ described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
97
+
98
+ expect(match_fields[index][:search]).to include('name')
99
+ end
100
+ end
101
+ end
102
+
103
+ describe '.decorate_for_search' do
104
+ let(:search_value) { 'john' }
105
+ let(:field_names) { ['name', 'email'] }
106
+
107
+ context 'with valid records' do
108
+ let(:records_serialized) do
109
+ {
110
+ 'data' => [
111
+ {
112
+ 'type' => 'User',
113
+ 'id' => '1',
114
+ 'attributes' => {
115
+ 'id' => 1,
116
+ 'name' => 'John Doe',
117
+ 'email' => 'john@example.com'
118
+ },
119
+ 'links' => { 'self' => '/forest/user/1' },
120
+ 'relationships' => {}
121
+ },
122
+ {
123
+ 'type' => 'User',
124
+ 'id' => '2',
125
+ 'attributes' => {
126
+ 'id' => 2,
127
+ 'name' => 'Jane Smith',
128
+ 'email' => 'jane@example.com'
129
+ },
130
+ 'links' => { 'self' => '/forest/user/2' },
131
+ 'relationships' => {}
132
+ }
133
+ ]
134
+ }
135
+ end
136
+
137
+ it 'returns match fields for matching records' do
138
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
139
+
140
+ expect(result).to eq({
141
+ 0 => {
142
+ id: '1',
143
+ search: %w[name email]
144
+ }
145
+ })
146
+ end
147
+
148
+ it 'includes ID field in search when ID matches' do
149
+ search_value = '2'
150
+
151
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
152
+
153
+ expect(result[1][:search]).to include('id')
154
+ end
155
+
156
+ it 'handles multiple matches across different records' do
157
+ records_serialized['data'][1]['attributes']['name'] = 'Johnny Cash'
158
+
159
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
160
+
161
+ expect(result).to have_key(0)
162
+ expect(result).to have_key(1)
163
+ expect(result[0][:search]).to contain_exactly('name', 'email')
164
+ expect(result[1][:search]).to contain_exactly('name')
165
+ end
166
+
167
+ it 'skips fields with nil values' do
168
+ records_serialized['data'][0]['attributes']['email'] = nil
169
+
170
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
171
+
172
+ expect(result[0][:search]).to eq(['name'])
173
+ end
174
+
175
+ it 'skips fields with empty string values' do
176
+ records_serialized['data'][0]['attributes']['email'] = ''
177
+
178
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
179
+
180
+ expect(result[0][:search]).to eq(['name'])
181
+ end
182
+ end
183
+
184
+ context 'when no matches are found' do
185
+ let(:records_serialized) do
186
+ {
187
+ 'data' => [
188
+ {
189
+ 'type' => 'User',
190
+ 'id' => '1',
191
+ 'attributes' => {
192
+ 'id' => 1,
193
+ 'name' => 'Jane Doe',
194
+ 'email' => 'jane@example.com'
195
+ },
196
+ 'links' => { 'self' => '/forest/user/1' },
197
+ 'relationships' => {}
198
+ }
199
+ ]
200
+ }
201
+ end
202
+
203
+ it 'returns nil' do
204
+ result = described_class.decorate_for_search(records_serialized, field_names, search_value)
205
+
206
+ expect(result).to be_nil
207
+ end
208
+ end
209
+
210
+ context 'with invalid record structure' do
211
+ let(:records_serialized) do
212
+ {
213
+ 'data' => [
214
+ {
215
+ 'type' => 'User',
216
+ 'id' => '1',
217
+ 'links' => { 'self' => '/forest/user/1' },
218
+ 'relationships' => {
219
+ 'claim' => { 'links' => { 'related' => {} } }
220
+ }
221
+ }
222
+ ]
223
+ }
224
+ end
225
+
226
+ it 'raises ArgumentError with descriptive message' do
227
+ expect {
228
+ described_class.decorate_for_search(records_serialized, field_names, search_value)
229
+ }.to raise_error(ArgumentError, "Missing 'attributes' key in record #{records_serialized['data'][0]}")
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -1,6 +1,8 @@
1
1
  require 'rails_helper'
2
2
 
3
3
  describe 'Requesting Actions routes', :type => :request do
4
+ subject(:controller) { ForestLiana::ApplicationController.new }
5
+
4
6
  let(:rendering_id) { 13 }
5
7
  let(:scope_filters) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} }
6
8
 
@@ -11,6 +13,16 @@ describe 'Requesting Actions routes', :type => :request do
11
13
 
12
14
  ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
13
15
  allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scope_filters)
16
+
17
+
18
+ allow(ForestAdmin::JSONAPI::Serializer)
19
+ .to receive(:serialize)
20
+ .and_return(json_out)
21
+
22
+ allow(controller)
23
+ .to receive(:force_utf8_encoding) do |arg|
24
+ arg
25
+ end
14
26
  end
15
27
 
16
28
  after(:each) do
@@ -38,6 +50,10 @@ describe 'Requesting Actions routes', :type => :request do
38
50
  }
39
51
  }
40
52
 
53
+ let(:record) { Island.first }
54
+
55
+ let(:json_out){ { 'data' => [] } }
56
+
41
57
  describe 'hooks' do
42
58
  island = ForestLiana.apimap.find {|collection| collection.name.to_s == ForestLiana.name_for(Island)}
43
59
 
@@ -504,4 +520,90 @@ describe 'Requesting Actions routes', :type => :request do
504
520
  end
505
521
  end
506
522
  end
523
+
524
+ describe 'serialize model' do
525
+ it 'should set is_collection and context option correctly' do
526
+ options = { field: {"Island" => "id,name"} }
527
+
528
+ expect(ForestAdmin::JSONAPI::Serializer)
529
+ .to receive(:serialize) do |obj, opts|
530
+ expect(obj).to eq(record)
531
+ expect(opts[:is_collection]).to be(false)
532
+ expect(opts[:context]).to include(unoptimized: true)
533
+ json_out
534
+ end
535
+
536
+ expect(controller).to receive(:force_utf8_encoding).with(json_out)
537
+
538
+ res = controller.send(:serialize_model, record, options)
539
+ expect(res).to eq(json_out)
540
+ end
541
+ end
542
+
543
+ describe 'serialize models' do
544
+ let(:records) { Island.all }
545
+
546
+ context 'when params fields is not present' do
547
+ it 'merges unoptimized into context' do
548
+ options = { field: {"Island" => "id,name"}, params: { searchToEdit: 'true' }, context: { foo: 42 } }
549
+
550
+ expect(ForestAdmin::JSONAPI::Serializer)
551
+ .to receive(:serialize) do |objs, opts|
552
+ expect(objs).to eq(records)
553
+ expect(opts[:is_collection]).to be(true)
554
+ expect(opts[:context]).to include(unoptimized: true, foo: 42)
555
+ json_out
556
+ end
557
+
558
+ res = controller.send(:serialize_models, records, options)
559
+ expect(res).to eq(json_out)
560
+ end
561
+ end
562
+
563
+ context 'when params fields is present' do
564
+ it 'leaves context unchanged' do
565
+ options = { params: { fields: 'id' }, context: { foo: 1 } }
566
+
567
+ expect(ForestAdmin::JSONAPI::Serializer)
568
+ .to receive(:serialize) do |_, opts|
569
+ expect(opts[:context]).to eq(foo: 1)
570
+ json_out
571
+ end
572
+
573
+ controller.send(:serialize_models, records, options)
574
+ end
575
+ end
576
+
577
+ context 'when params[:search] is present' do
578
+ it 'adds meta.decorators via DecorationHelper and concatenates smart fields' do
579
+ options = { params: { search: 'hello' } }
580
+ fields_searched = ['existing']
581
+ collection_double = double('collection', string_smart_fields_names: %w[foo bar])
582
+
583
+ allow_any_instance_of(ForestLiana::ApplicationController)
584
+ .to receive(:get_collection)
585
+ .and_return(collection_double)
586
+
587
+ expect(ForestLiana::DecorationHelper)
588
+ .to receive(:decorate_for_search) do |json, fields, term|
589
+ expect(json).to eq(json_out)
590
+ expect(fields).to match_array(%w[existing foo bar])
591
+ expect(term).to eq('hello')
592
+ { foo: 'bar' }
593
+ end
594
+ .and_return({ foo: 'bar' })
595
+
596
+ res = controller.send(:serialize_models, records, options, fields_searched)
597
+
598
+ expect(res['meta']).to eq(decorators: { foo: 'bar' })
599
+ expect(fields_searched).to match_array(%w[existing foo bar])
600
+ end
601
+ end
602
+
603
+ it 'calls force_utf8_encoding with the final JSON' do
604
+ options = {}
605
+ expect(controller).to receive(:force_utf8_encoding).with(json_out)
606
+ controller.send(:serialize_models, records, options)
607
+ end
608
+ end
507
609
  end
@@ -113,6 +113,95 @@ module ForestLiana
113
113
  end
114
114
  end
115
115
  end
116
+
117
+ describe 'compute_includes' do
118
+ it 'should include has_one relation from association' do
119
+ expect(subject.includes).to include(:location)
120
+ end
121
+
122
+ it 'should include belongs_to relations from association' do
123
+ expect(subject.includes).to include(:owner, :cutter, :island, :eponymous_island)
124
+ end
125
+
126
+ it 'should exclude has_many relations' do
127
+ has_many_associations = Tree.reflect_on_all_associations
128
+ .select { |a| a.macro == :has_many }
129
+ .map(&:name)
130
+
131
+ has_many_associations.each do |assoc|
132
+ expect(subject.includes).not_to include(assoc)
133
+ end
134
+ end
135
+
136
+ it 'should include all supported associations from association by default' do
137
+ expected_associations = Tree.reflect_on_all_associations
138
+ .select { |a| [:belongs_to, :has_one, :has_and_belongs_to_many].include?(a.macro) }
139
+ .map(&:name)
140
+
141
+ expect(subject.includes).to match_array(expected_associations)
142
+ end
143
+
144
+ it 'should respect fields filter for associations' do
145
+ params[:fields] = { 'Tree' => 'owner,island' }
146
+ getter = described_class.new(Island, association, params, user)
147
+
148
+ expect(getter.includes).to include(:owner, :island)
149
+ expect(getter.includes).not_to include(:cutter, :eponymous_island, :location)
150
+ end
151
+
152
+ it 'should exclude Tree associations when models not included' do
153
+ allow(SchemaUtils).to receive(:model_included?).and_return(false)
154
+ expect(subject.includes).to be_empty
155
+ end
156
+
157
+ context 'on polymorphic associations' do
158
+ let(:base_params) do
159
+ {
160
+ id: Island.first&.id || 1,
161
+ association_name: 'trees',
162
+ page: { size: 15, number: 1 },
163
+ timezone: 'UTC'
164
+ }
165
+ end
166
+
167
+ before do
168
+ # temporarily add a polymorphic association on Tree
169
+ Tree.class_eval { belongs_to :addressable, polymorphic: true, optional: true }
170
+
171
+ allow_any_instance_of(described_class).to receive(:prepare_query).and_return(nil)
172
+ allow(ForestLiana).to receive(:name_for).and_return('trees')
173
+ end
174
+
175
+ after do
176
+ %w[addressable].each do |name|
177
+ Tree._reflections.delete(name)
178
+ Tree.reflections.delete(name)
179
+ end
180
+ %w[addressable addressable= addressable_id addressable_type].each do |m|
181
+ Tree.undef_method(m) rescue nil
182
+ end
183
+ end
184
+
185
+ it 'should exclude the polymorphic association when not all target models are includable' do
186
+ params = base_params.merge(fields: { 'trees' => 'addressable' })
187
+
188
+ allow(SchemaUtils).to receive(:model_included?).and_return(true, false)
189
+
190
+ getter = described_class.new(Island, association, params, user)
191
+ expect(getter.includes).to eq([])
192
+ end
193
+
194
+ it 'should include the polymorphic association only when all target models are includable' do
195
+ params = base_params.merge(fields: { 'trees' => 'addressable' })
196
+
197
+ allow(SchemaUtils).to receive(:model_included?).and_return(true, true)
198
+
199
+ getter = described_class.new(Island, association, params, user)
200
+ expect(getter.includes).to contain_exactly(:addressable)
201
+ end
202
+
203
+ end
204
+ end
116
205
  end
117
206
  end
118
207
  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.15.4
4
+ version: 9.15.6
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-08-12 00:00:00.000000000 Z
11
+ date: 2025-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -404,6 +404,7 @@ files:
404
404
  - spec/dummy/lib/forest_liana/collections/user.rb
405
405
  - spec/dummy/lib/forest_liana/controllers/owner_trees_controller.rb
406
406
  - spec/dummy/lib/forest_liana/controllers/owners_controller.rb
407
+ - spec/helpers/forest_liana/decoration_helper_spec.rb
407
408
  - spec/helpers/forest_liana/query_helper_spec.rb
408
409
  - spec/helpers/forest_liana/schema_helper_spec.rb
409
410
  - spec/lib/forest_liana/bootstrapper_spec.rb
@@ -710,6 +711,7 @@ test_files:
710
711
  - spec/dummy/lib/forest_liana/collections/user.rb
711
712
  - spec/dummy/lib/forest_liana/controllers/owner_trees_controller.rb
712
713
  - spec/dummy/lib/forest_liana/controllers/owners_controller.rb
714
+ - spec/helpers/forest_liana/decoration_helper_spec.rb
713
715
  - spec/helpers/forest_liana/query_helper_spec.rb
714
716
  - spec/helpers/forest_liana/schema_helper_spec.rb
715
717
  - spec/lib/forest_liana/bootstrapper_spec.rb