forest_liana 9.22.5 → 9.22.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: 6ca262c2db5e774393d0dd9b9266bbd360cea72c8caeea733c7df95a32bd6f3c
4
- data.tar.gz: 5917abff7f56a4df5bf38c885ab375dc7240f666826c46a5ddb92a4709110d8e
3
+ metadata.gz: bf311206c7fdea9b79789aba4523930ddccdf1904826d04c36e5e72995eb8640
4
+ data.tar.gz: 8f1aff957bd3471d3766ec99e5d1ac0e7c808fc96b7193432efde480c85cbfbe
5
5
  SHA512:
6
- metadata.gz: c60d6abc5f591ac151cf592e56f7790fc24db53984f4a646da46bf61ca1030b77d21713a4ee7d63a334ad9a70c5b934c982ce1fd76669e485be2c58e634fddcf
7
- data.tar.gz: 55b8f9f4a71a17dcb8cb3c55b0a240e0576697ae5ca42116f266aa9b429d15aea49193222ea0f599a6feb892405f3fdfb565e1d1a033e4595c22f351e277e112
6
+ metadata.gz: edd1873169d8762a7c8687de26c98dd062aca95b3a2c522f6c9ac8c1167ef9d9c26810c148dd6113053414b5cc7e5ae63b3d722ba6a12938f141f1d9a8e2fa5e
7
+ data.tar.gz: f80f9add4df869e43af24d2944e379d8deb2b27207b66d7fa1ec1e8bc91c8be8dcd4c570ce01ae06423823756cf65574d0c49c57de020626123c0b79726bd718
@@ -335,18 +335,40 @@ module ForestLiana
335
335
  # association, so FieldPath would otherwise resolve it to a column of +model+ itself — the
336
336
  # collection its `reference` actually points to is checked instead, the same target
337
337
  # `fields_per_model` already resolves a caller-named smart relation to.
338
- def resolve_owner(model, field_name)
338
+ def resolve_owner(model, field_name, smart_field_fallback: true)
339
339
  smart_field = smart_belongs_to_field(model, field_name)
340
340
 
341
341
  return [smart_field[:reference].split('.').first] if smart_field
342
342
 
343
- FieldPath.leaf_collection_names(model, field_name)
343
+ targets = FieldPath.leaf_collection_names(model, field_name)
344
+ return targets if targets.any? { |name| collection_exposed?(name) }
345
+
346
+ # No relation to an unexposed target reaches the caller, so a computed smart field of the same
347
+ # name is the only field this name can mean: its block reads the root row. A polymorphic
348
+ # relation is kept whatever its targets, and keeps its check.
349
+ return [ForestLiana.name_for(model)] if smart_field_fallback && only_computed_smart_field?(model, field_name, targets)
350
+
351
+ targets
344
352
  end
345
353
 
346
354
  def smart_belongs_to_field(model, field_name)
347
- forest_collection = ForestLiana.apimap.find { |collection| collection.name.to_s == ForestLiana.name_for(model) }
355
+ forest_collection_for(model)&.fields_smart_belongs_to&.find { |field| field[:field].to_s == field_name }
356
+ end
357
+
358
+ # SchemaAdapter drops a relation to an excluded model, but keeps one whose target is missing from
359
+ # the apimap otherwise (a table absent at boot, a secondary database unreachable): skipped alike.
360
+ # A polymorphic relation's reference names the association, never one of its targets, so it stays.
361
+ def only_computed_smart_field?(model, field_name, unexposed_targets)
362
+ fields = forest_collection_for(model)&.fields.to_a.select { |field| field[:field].to_s == field_name }
363
+ fields = fields.reject do |field|
364
+ !field[:is_virtual] && unexposed_targets.include?(field[:reference].to_s.split('.').first)
365
+ end
366
+
367
+ fields.any? && fields.all? { |field| field[:is_virtual] && field[:reference].nil? && field[:integration].nil? }
368
+ end
348
369
 
349
- forest_collection&.fields_smart_belongs_to&.find { |field| field[:field].to_s == field_name }
370
+ def forest_collection_for(model)
371
+ ForestLiana.apimap.find { |collection| collection.name.to_s == ForestLiana.name_for(model) }
350
372
  end
351
373
 
352
374
  # No role can ever be granted `read` on a collection absent from the apimap — a denial
@@ -378,7 +400,10 @@ module ForestLiana
378
400
  # partition (not split) so an empty or colon-only path resolves to '' (the root, pinned
379
401
  # readable) instead of nil.
380
402
  def query_target_collections(root_model, path)
381
- resolve_owner(root_model, path.partition(':').first)
403
+ head, separator, = path.partition(':')
404
+ # A traversal names the relation itself, never a smart field: a String has no sub-field, and
405
+ # what the query joins for it is the relation's table.
406
+ resolve_owner(root_model, head, smart_field_fallback: separator.empty?)
382
407
  end
383
408
 
384
409
  def query_usage(action, root_model, path)
@@ -15,6 +15,7 @@ module ForestLiana
15
15
  @search = @params[:search].presence
16
16
  @user = user
17
17
  @filter_joins = []
18
+ @search_field_paths = []
18
19
  end
19
20
 
20
21
  def perform(resource)
@@ -31,6 +32,7 @@ module ForestLiana
31
32
  # already excludes itself on it and so can never be the reason @conditions_pushed is true.
32
33
  @conditions_pushed = false
33
34
  @lambda_contributed = false
35
+ @search_footprint_tracked = false
34
36
  @records = search_param
35
37
 
36
38
  caller_filter = @params[:filters].present? ? ForestLiana::ScopeManager.inject_context_variables(@params[:filters], @user) : nil
@@ -90,12 +92,13 @@ module ForestLiana
90
92
  # hadn't run at all — this can't currently tell "ran and did nothing" apart from "ran and
91
93
  # found real rows". Logged rather than silently discarded, since nothing else would ever
92
94
  # surface it to whoever built the smart-search hook.
93
- if @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
95
+ if @search_footprint_tracked && @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
94
96
  FOREST_LOGGER.info "A smart-search lambda's result on the \"#{ForestLiana.name_for(root_model)}\" " \
95
97
  "collection was discarded: the search term (#{@search.inspect}) is UUID-shaped but " \
96
98
  'invalid, and no other condition constrained the query.'
97
99
  end
98
- @records = @records.none unless @conditions_pushed || (@lambda_contributed && !malformed_uuid_search?)
100
+ # A host override of search_param that never calls super pushes no condition: its result is served as is.
101
+ @records = @records.none if @search_footprint_tracked && !@conditions_pushed && !(@lambda_contributed && !malformed_uuid_search?)
99
102
  end
100
103
 
101
104
  @records = sort_query
@@ -125,6 +128,7 @@ module ForestLiana
125
128
  end
126
129
 
127
130
  def search_param
131
+ @search_footprint_tracked = true
128
132
  @search_field_paths = []
129
133
 
130
134
  if @search
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.5"
2
+ VERSION = "9.22.6"
3
3
  end
@@ -0,0 +1,10 @@
1
+ class Forest::Boat
2
+ include ForestLiana::Collection
3
+
4
+ collection :Boat
5
+
6
+ # Named like `belongs_to :harbor` on the model, as Qonto's `legal_form` (PRD-1429).
7
+ field :harbor, type: 'String' do
8
+ object.harbor&.name
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ class Forest::Captain
2
+ include ForestLiana::Collection
3
+
4
+ collection :Captain
5
+
6
+ # Named like `has_one :license` on the model (PRD-1429).
7
+ field :license, type: 'String' do
8
+ object.license&.number
9
+ end
10
+ end
@@ -0,0 +1,125 @@
1
+ require 'rails_helper'
2
+
3
+ # Forest::Boat#harbor and Forest::Captain#license share their name with a relation of the model.
4
+ # With the relation's target left out of the apimap, SchemaAdapter drops the relation and only the
5
+ # smart field remains: the request reads it off the root row, not off the unexposed target.
6
+ describe 'A computed smart field named like a relation to an unexposed collection', type: :request do
7
+ token = JWT.encode({ id: 38, email: 'michael.kelso@that70.show', first_name: 'Michael',
8
+ last_name: 'Kelso', team: 'Operations', rendering_id: 16,
9
+ exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
10
+ ForestLiana.auth_secret, 'HS256')
11
+ headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
12
+ 'Authorization' => "Bearer #{token}" }
13
+ page = { 'number' => '1', 'size' => '15' }
14
+
15
+ let!(:harbor) { Harbor.create!(name: 'Saint-Malo') }
16
+ let!(:captain) { Captain.create!(name: 'Haddock', license_number: 'FR-4421') }
17
+ let!(:license) { License.create!(number: 'FR-4421', expires_on: Date.new(2030, 1, 1)) }
18
+ let!(:boat) { Boat.create!(name: 'Karaboudjan', harbor: harbor, captain: captain) }
19
+
20
+ let(:unexposed) { [] }
21
+ let(:relations_kept) { false }
22
+
23
+ before do
24
+ allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
25
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
26
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
27
+ allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
28
+ allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
29
+ .and_return('scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' })
30
+ allow(ForestLiana).to receive(:apimap).and_wrap_original do |original|
31
+ original.call.reject { |collection| unexposed.include?(collection.name.to_s) }
32
+ end
33
+ # The apimap was built at boot with every target exposed: drop the relations to the unexposed
34
+ # ones, as SchemaAdapter does for a model left out of it.
35
+ ForestLiana.apimap.each do |collection|
36
+ allow(collection).to receive(:fields).and_wrap_original do |original|
37
+ next original.call if relations_kept
38
+
39
+ original.call.reject { |field| unexposed.include?(field[:reference].to_s.split('.').first) }
40
+ end
41
+ end
42
+ end
43
+
44
+ def body
45
+ JSON.parse(response.body)
46
+ end
47
+
48
+ shared_examples 'serving the smart field off the root' do
49
+ context 'when the belongs_to target is unexposed' do
50
+ let(:unexposed) { ['Harbor'] }
51
+
52
+ it 'serves the list' do
53
+ get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor' }, page: page, timezone: 'Europe/Paris' },
54
+ headers: headers
55
+
56
+ expect(response.status).to eq(200)
57
+ expect(body['data'].map { |row| row['attributes']['harbor'] }).to eq(['Saint-Malo'])
58
+ end
59
+
60
+ it 'serves the get-one' do
61
+ get "/forest/Boat/#{boat.id}", params: { fields: { 'Boat' => 'id,name,harbor' }, timezone: 'Europe/Paris' },
62
+ headers: headers
63
+
64
+ expect(response.status).to eq(200)
65
+ expect(body['data']['attributes']['harbor']).to eq('Saint-Malo')
66
+ end
67
+ end
68
+
69
+ context 'when the has_one target is unexposed' do
70
+ let(:unexposed) { ['License'] }
71
+
72
+ it 'serves the list' do
73
+ get '/forest/Captain', params: { fields: { 'Captain' => 'id,name,license' }, page: page, timezone: 'Europe/Paris' },
74
+ headers: headers
75
+
76
+ expect(response.status).to eq(200)
77
+ expect(body['data'].map { |row| row['attributes']['license'] }).to eq(['FR-4421'])
78
+ end
79
+
80
+ it 'serves the get-one' do
81
+ get "/forest/Captain/#{captain.id}", params: { fields: { 'Captain' => 'id,name,license' }, timezone: 'Europe/Paris' },
82
+ headers: headers
83
+
84
+ expect(response.status).to eq(200)
85
+ expect(body['data']['attributes']['license']).to eq('FR-4421')
86
+ end
87
+ end
88
+ end
89
+
90
+ # A target missing from the apimap without being excluded (a table absent at boot, a secondary
91
+ # database unreachable) leaves its relation in the schema next to the smart field.
92
+ context 'when the relation to the unexposed target stays in the schema' do
93
+ let(:relations_kept) { true }
94
+
95
+ before { Rails.cache.write('forest.has_permission', false) }
96
+
97
+ include_examples 'serving the smart field off the root'
98
+
99
+ it "still refuses the unexposed target's own fields" do
100
+ unexposed << 'Harbor'
101
+
102
+ get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor', 'harbor' => 'name' }, page: page,
103
+ timezone: 'Europe/Paris' },
104
+ headers: headers
105
+
106
+ expect(response.status).to eq(403)
107
+ expect(body['errors'].first['data']['unexposed_fields']).to eq(['name'])
108
+ end
109
+ end
110
+
111
+ context 'with skip_relation_read_permissions, as Qonto runs it' do
112
+ before do
113
+ allow(ForestLiana).to receive(:skip_relation_read_permissions?) { true }
114
+ Rails.cache.write('forest.has_permission', true)
115
+ end
116
+
117
+ include_examples 'serving the smart field off the root'
118
+ end
119
+
120
+ context 'without a permission system' do
121
+ before { Rails.cache.write('forest.has_permission', false) }
122
+
123
+ include_examples 'serving the smart field off the root'
124
+ end
125
+ end
@@ -284,6 +284,64 @@ module ForestLiana
284
284
  end
285
285
  end
286
286
 
287
+ # PRD-1429: with `island` left out of the apimap, a computed smart field of the same name is
288
+ # what a bare path means. A traversal still means the relation, whose table the query joins.
289
+ describe 'a computed smart field sharing its name with a relation absent from the apimap' do
290
+ before do
291
+ forest_collection = double('forest_collection')
292
+ allow(forest_collection).to receive(:name).and_return('Tree')
293
+ allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
294
+ allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
295
+ allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
296
+ write_permissions('Tree' => true)
297
+ end
298
+
299
+ it 'serves a sort on the smart field off the root' do
300
+ expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
301
+ .not_to raise_error
302
+ end
303
+
304
+ it 'still refuses a filter traversing the relation' do
305
+ expect { dummy_class.assert_can_read_query_fields(user, Tree, filter_paths: ['island:name']) }
306
+ .to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
307
+ end
308
+
309
+ context 'when the relation stays in the schema next to the smart field' do
310
+ before do
311
+ allow(ForestLiana.apimap.first).to receive(:fields).and_return(
312
+ [{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
313
+ { field: :island, is_virtual: true }]
314
+ )
315
+ end
316
+
317
+ it 'serves a sort on the smart field off the root all the same' do
318
+ expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
319
+ .not_to raise_error
320
+ end
321
+ end
322
+ end
323
+
324
+ # A polymorphic relation's reference names the association (`addressable.id`), never one of
325
+ # its targets, so it stays next to the smart field and keeps the name.
326
+ describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
327
+ before do
328
+ forest_collection = double('forest_collection')
329
+ allow(forest_collection).to receive(:name).and_return('Address')
330
+ allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
331
+ allow(forest_collection).to receive(:fields).and_return(
332
+ [{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
333
+ { field: :addressable, is_virtual: true }]
334
+ )
335
+ allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
336
+ write_permissions('Address' => true)
337
+ end
338
+
339
+ it 'keeps refusing a sort on it' do
340
+ expect { dummy_class.assert_can_read_query_fields(user, Address, sort_paths: ['addressable']) }
341
+ .to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
342
+ end
343
+ end
344
+
287
345
  describe 'polymorphic' do
288
346
  it 'serves a filter on a relation whose every target is readable' do
289
347
  write_permissions('Address' => true, 'User' => true, 'Island' => true)
@@ -299,13 +299,56 @@ module ForestLiana
299
299
  end
300
300
 
301
301
  describe 'a collection absent from the apimap' do
302
+ let(:fields) { [] }
303
+
302
304
  before do
303
305
  forest_collection = double('forest_collection')
304
306
  allow(forest_collection).to receive(:name).and_return('Tree')
305
307
  allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
308
+ allow(forest_collection).to receive(:fields).and_return(fields)
306
309
  allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
307
310
  end
308
311
 
312
+ # SchemaAdapter drops `island` from the schema here, leaving a computed smart field of the
313
+ # same name as the only field the caller can mean (PRD-1429).
314
+ context 'when a computed smart field shares its name with the relation' do
315
+ let(:fields) { [{ field: :island, is_virtual: true }] }
316
+
317
+ it 'serves it off the root instead of refusing the request' do
318
+ write_permissions('Tree' => true)
319
+
320
+ expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
321
+ .to eq('Tree' => 'name,island')
322
+ end
323
+ end
324
+
325
+ # SchemaAdapter keeps the relation when Island is missing from the apimap without being
326
+ # excluded (a table absent at boot, a secondary database unreachable).
327
+ context 'when the relation stays in the schema next to the smart field' do
328
+ let(:fields) do
329
+ [{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
330
+ { field: :island, is_virtual: true }]
331
+ end
332
+
333
+ it 'serves the smart field off the root all the same' do
334
+ write_permissions('Tree' => true)
335
+
336
+ expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
337
+ .to eq('Tree' => 'name,island')
338
+ end
339
+ end
340
+
341
+ context 'when only the relation stays in the schema' do
342
+ let(:fields) { [{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false }] }
343
+
344
+ it 'keeps refusing it' do
345
+ write_permissions('Tree' => true)
346
+
347
+ expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
348
+ .to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
349
+ end
350
+ end
351
+
309
352
  it 'names it as unexposed rather than as merely denied, since no role can be granted read on it' do
310
353
  write_permissions('Tree' => true, 'Island' => false)
311
354
 
@@ -328,6 +371,58 @@ module ForestLiana
328
371
  end
329
372
  end
330
373
 
374
+ # The exposed relation still reaches the response alongside the smart field, so its read
375
+ # check stands: only an unexposed target hands the name over to the smart field.
376
+ describe 'a computed smart field sharing its name with an exposed relation' do
377
+ before do
378
+ forest_collection = double('forest_collection')
379
+ allow(forest_collection).to receive(:name).and_return('Tree')
380
+ allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
381
+ allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
382
+ island_collection = double('island_collection')
383
+ allow(island_collection).to receive(:name).and_return('Island')
384
+ allow(ForestLiana).to receive(:apimap).and_return([forest_collection, island_collection])
385
+ end
386
+
387
+ it 'keeps checking the relation' do
388
+ write_permissions('Tree' => true, 'Island' => false)
389
+
390
+ expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
391
+ .to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError) do |error|
392
+ expect(error.message).to eq("You are not allowed to read 'island' from the 'Island' collection.")
393
+ expect(error.data[:unexposed_fields]).to be_nil
394
+ end
395
+ end
396
+ end
397
+
398
+ # SchemaAdapter keeps a polymorphic relation in the schema even when none of its targets is
399
+ # exposed, so a computed smart field of the same name does not take the name over.
400
+ describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
401
+ before do
402
+ forest_collection = double('forest_collection')
403
+ allow(forest_collection).to receive(:name).and_return('Address')
404
+ allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
405
+ allow(forest_collection).to receive(:fields).and_return(
406
+ [{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
407
+ { field: :addressable, is_virtual: true }]
408
+ )
409
+ allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
410
+ write_permissions('Address' => true)
411
+ end
412
+
413
+ it 'keeps refusing the root field' do
414
+ expect { dummy_class.redact_fields(user, Address, { 'Address' => 'line1,addressable' }, named_collections: ['Address']) }
415
+ .to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
416
+ end
417
+
418
+ it "keeps refusing the targets' own fields" do
419
+ expect do
420
+ dummy_class.redact_fields(user, Address, { 'Address' => 'line1', 'addressable' => 'name' },
421
+ named_collections: %w[Address addressable])
422
+ end.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
423
+ end
424
+ end
425
+
331
426
  # Every real collection stays in the apimap here (only Island is rejected), unlike the
332
427
  # describe block above — so 'owner' resolves to an exposed-but-denied User, not a second
333
428
  # unexposed collection, and the two entries' differently-shaped clauses still join as one
@@ -540,6 +540,89 @@ module ForestLiana
540
540
  end
541
541
  end
542
542
 
543
+ describe '#initialize' do
544
+ it 'starts with an empty search footprint' do
545
+ expect(builder.search_field_paths).to eq([])
546
+ end
547
+ end
548
+
549
+ describe 'when a host app overrides search_param without calling super' do
550
+ let(:builder_class) do
551
+ Class.new(described_class) do
552
+ def search_param
553
+ return @resource unless @search
554
+
555
+ @resource.where('LOWER(trees.name) LIKE ?', "%#{@search.downcase}%")
556
+ end
557
+ end
558
+ end
559
+ let(:builder) { builder_class.new(params, [], collection, user) }
560
+
561
+ before do
562
+ Tree.create!(name: 'Oak')
563
+ Tree.create!(name: 'Pine')
564
+ end
565
+ after { Tree.destroy_all }
566
+
567
+ context 'without a search' do
568
+ let(:params) { { searchExtended: '0' } }
569
+
570
+ it 'serves every record' do
571
+ expect(builder.perform(Tree.all).count).to eq(2)
572
+ end
573
+ end
574
+
575
+ context 'with a search the override matches' do
576
+ let(:params) { { search: 'oak', searchExtended: '0' } }
577
+
578
+ it "serves the override's result instead of emptying it" do
579
+ expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
580
+ end
581
+ end
582
+
583
+ context 'with a smart-search lambda and a malformed-UUID-shaped search' do
584
+ let(:builder_class) do
585
+ Class.new(described_class) do
586
+ def search_param
587
+ @resource
588
+ end
589
+ end
590
+ end
591
+
592
+ before do
593
+ allow(ForestLiana).to receive(:schema_for_resource).and_return(
594
+ ForestLiana::Model::Collection.new(
595
+ name: 'Tree', fields: [{ field: :custom, type: 'String', search: ->(query, _search) { query.where(name: 'Oak') } }]
596
+ )
597
+ )
598
+ end
599
+
600
+ let(:params) { { search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '0' } }
601
+
602
+ it "serves the lambda's result without logging it as discarded" do
603
+ expect(FOREST_LOGGER).not_to receive(:info).with(/was discarded/)
604
+
605
+ expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
606
+ end
607
+ end
608
+
609
+ context 'when the override calls super' do
610
+ let(:builder_class) do
611
+ Class.new(described_class) do
612
+ def search_param
613
+ super
614
+ end
615
+ end
616
+ end
617
+ let(:collection) { ForestLiana::Model::Collection.new(name: 'Tree', fields: [], search_fields: ['nonexistent']) }
618
+ let(:params) { { search: 'nothing-matches-this', searchExtended: '0' } }
619
+
620
+ it 'still answers no records when nothing could match' do
621
+ expect(builder.perform(Tree.all).count).to eq(0)
622
+ end
623
+ end
624
+ end
625
+
543
626
  describe 'a blank or whitespace-only search' do
544
627
  let(:params) { ActiveSupport::HashWithIndifferentAccess.new(search: ' ', searchExtended: '1') }
545
628
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.22.5
4
+ version: 9.22.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
@@ -454,6 +454,8 @@ files:
454
454
  - spec/dummy/db/schema.rb
455
455
  - spec/dummy/db/user_schema.rb
456
456
  - spec/dummy/lib/forest_liana/collections/address.rb
457
+ - spec/dummy/lib/forest_liana/collections/boat.rb
458
+ - spec/dummy/lib/forest_liana/collections/captain.rb
457
459
  - spec/dummy/lib/forest_liana/collections/island.rb
458
460
  - spec/dummy/lib/forest_liana/collections/location.rb
459
461
  - spec/dummy/lib/forest_liana/collections/owner.rb
@@ -484,6 +486,7 @@ files:
484
486
  - spec/requests/query_footprint_spec.rb
485
487
  - spec/requests/related_list_nested_through_spec.rb
486
488
  - spec/requests/resources_spec.rb
489
+ - spec/requests/smart_field_named_like_unexposed_relation_spec.rb
487
490
  - spec/requests/stats_spec.rb
488
491
  - spec/requests/sti_record_conversion_spec.rb
489
492
  - spec/requests/test.ru
@@ -808,6 +811,8 @@ test_files:
808
811
  - spec/dummy/db/schema.rb
809
812
  - spec/dummy/db/user_schema.rb
810
813
  - spec/dummy/lib/forest_liana/collections/address.rb
814
+ - spec/dummy/lib/forest_liana/collections/boat.rb
815
+ - spec/dummy/lib/forest_liana/collections/captain.rb
811
816
  - spec/dummy/lib/forest_liana/collections/island.rb
812
817
  - spec/dummy/lib/forest_liana/collections/location.rb
813
818
  - spec/dummy/lib/forest_liana/collections/owner.rb
@@ -838,6 +843,7 @@ test_files:
838
843
  - spec/requests/query_footprint_spec.rb
839
844
  - spec/requests/related_list_nested_through_spec.rb
840
845
  - spec/requests/resources_spec.rb
846
+ - spec/requests/smart_field_named_like_unexposed_relation_spec.rb
841
847
  - spec/requests/stats_spec.rb
842
848
  - spec/requests/sti_record_conversion_spec.rb
843
849
  - spec/requests/test.ru