forest_liana 9.22.3 → 9.22.5

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: 277b2499887f0ff82ea53ca55fc79843541cef4709f064c70f5efa55a4735d53
4
- data.tar.gz: f0fbeb9771dae6c1db93581279e433815f47eca0e257543f24bf012057da51d3
3
+ metadata.gz: 6ca262c2db5e774393d0dd9b9266bbd360cea72c8caeea733c7df95a32bd6f3c
4
+ data.tar.gz: 5917abff7f56a4df5bf38c885ab375dc7240f666826c46a5ddb92a4709110d8e
5
5
  SHA512:
6
- metadata.gz: 8e02b44b8348a306b0e7e60b44da087c0effb4ba538947e9165e6db2e156698b4a276a0288335ea8b7768454e0d4309ef4232e37c994abd2adffc04763f66f2b
7
- data.tar.gz: 2be7bc1df7f51619407f1fe8f2b49a8e7b5629ada6151677e6f1f9a57a5548ffbeec5cfdb33f31cb8ca015a26937fb73e96f7decb9b63e959903e69497791f1b
6
+ metadata.gz: c60d6abc5f591ac151cf592e56f7790fc24db53984f4a646da46bf61ca1030b77d21713a4ee7d63a334ad9a70c5b934c982ce1fd76669e485be2c58e634fddcf
7
+ data.tar.gz: 55b8f9f4a71a17dcb8cb3c55b0a240e0576697ae5ca42116f266aa9b429d15aea49193222ea0f599a6feb892405f3fdfb565e1d1a033e4595c22f351e277e112
@@ -110,15 +110,16 @@ module ForestLiana
110
110
  private
111
111
 
112
112
  def sort_array_of_objects(array)
113
- array.sort! do |element1, element2|
114
- [element1['type'], element1['id']] <=> [element2['type'], element2['id']]
115
- end
113
+ array.sort_by! { |element| [element['type'].to_s, element['id'].to_s] }
116
114
  end
117
115
 
116
+ # NOTICE: A field 'type' is a String ('String'), an Array (['String'] for an array column)
117
+ # or a Hash (a nested type), and those are not comparable with one another:
118
+ # `['String'] <=> 'String'` returns nil, which makes `sort` raise an ArgumentError as soon
119
+ # as two fields share a name. Only the tie-breaker ever needs to be total, so JSON-ifying
120
+ # that one alone keeps the primary key byte-for-byte identical and the apimap churn-free.
118
121
  def sort_array_of_fields(array)
119
- array.sort do |field1, field2|
120
- [field1['field'], field1['type']] <=> [field2['field'], field2['type']]
121
- end
122
+ array.sort_by { |field| [field['field'].to_s, field['type'].to_json] }
122
123
  end
123
124
 
124
125
  def reorder_keys_basic(object)
@@ -544,8 +544,13 @@ module ForestLiana
544
544
  # Skip ActiveStorage associations - already processed above
545
545
  next if is_active_storage_association?(association)
546
546
 
547
+ # NOTICE: A preloaded :through only reads keys off the rows this query builds: the root
548
+ # row, and the narrowed rows of a first hop it joins. Walking the whole chain would
549
+ # name tables it never joins.
550
+ if through_chain.any? && !joined?(association, joined_relations)
551
+ select_preload_hop_keys(select, [association.name], joined_relations)
547
552
  # For :through associations, recursively add all intermediate foreign keys
548
- if through_chain.any?
553
+ elsif through_chain.any?
549
554
  current_resource = projected_resource
550
555
  through_chain.reverse.each do |through_name|
551
556
  through_assoc = current_resource.reflect_on_association(through_name)
@@ -694,7 +699,11 @@ module ForestLiana
694
699
  # those rows are the narrowed ones built off the JOIN, so its key has to be named here too.
695
700
  # It was not, and the list answered 500 rather than the one field (PRD-1316).
696
701
  def select_dependency_preload_keys(select, relation_path, joined_relations)
697
- chains = flatten_dependency_hops(relation_path.relations)
702
+ select_preload_hop_keys(select, relation_path.relations, joined_relations)
703
+ end
704
+
705
+ def select_preload_hop_keys(select, relations, joined_relations)
706
+ chains = flatten_dependency_hops(relations)
698
707
  return if chains.empty?
699
708
 
700
709
  narrowed_hops(chains, joined_relations).each do |owner, reflection|
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.3"
2
+ VERSION = "9.22.5"
3
3
  end
@@ -0,0 +1,8 @@
1
+ class Boat < ActiveRecord::Base
2
+ belongs_to :harbor
3
+ belongs_to :captain
4
+
5
+ # Its second hop is keyed on captains.license_number, which the preloader reads off the captain
6
+ # rows: narrowed ones when Harbor's boats related list joins captain to sort on it.
7
+ has_one :captain_license, through: :captain, source: :license
8
+ end
@@ -0,0 +1,3 @@
1
+ class Captain < ActiveRecord::Base
2
+ has_one :license, primary_key: :license_number, foreign_key: :number
3
+ end
@@ -0,0 +1,3 @@
1
+ class Harbor < ActiveRecord::Base
2
+ has_many :boats
3
+ end
@@ -0,0 +1,2 @@
1
+ class License < ActiveRecord::Base
2
+ end
@@ -1,4 +1,9 @@
1
1
  class Membership < ActiveRecord::Base
2
2
  belongs_to :island
3
3
  belongs_to :user
4
+
5
+ # A :through of a :through: displayed on Island's memberships related list, the hop past the
6
+ # first reads `locations`, a table that list preloads rather than joins (issue #818).
7
+ has_one :location, through: :island
8
+ has_one :location_island, through: :location, source: :island
4
9
  end
@@ -0,0 +1,23 @@
1
+ class CreateHarborsBoatsCaptainsLicenses < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :harbors do |t|
4
+ t.string :name
5
+ end
6
+
7
+ create_table :captains do |t|
8
+ t.string :name
9
+ t.string :license_number
10
+ end
11
+
12
+ create_table :licenses do |t|
13
+ t.string :number
14
+ t.date :expires_on
15
+ end
16
+
17
+ create_table :boats do |t|
18
+ t.string :name
19
+ t.references :harbor
20
+ t.references :captain
21
+ end
22
+ end
23
+ end
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema.define(version: 2026_09_24_120000) do
13
+ ActiveRecord::Schema.define(version: 2026_10_01_120000) do
14
14
 
15
15
  create_table "addresses", force: :cascade do |t|
16
16
  t.string "line1"
@@ -29,6 +29,19 @@ ActiveRecord::Schema.define(version: 2026_09_24_120000) do
29
29
  t.datetime "updated_at", precision: 6, null: false
30
30
  end
31
31
 
32
+ create_table "boats", force: :cascade do |t|
33
+ t.string "name"
34
+ t.integer "harbor_id"
35
+ t.integer "captain_id"
36
+ t.index ["captain_id"], name: "index_boats_on_captain_id"
37
+ t.index ["harbor_id"], name: "index_boats_on_harbor_id"
38
+ end
39
+
40
+ create_table "captains", force: :cascade do |t|
41
+ t.string "name"
42
+ t.string "license_number"
43
+ end
44
+
32
45
  create_table "flags", force: :cascade do |t|
33
46
  t.integer "island_id"
34
47
  t.string "color"
@@ -37,6 +50,10 @@ ActiveRecord::Schema.define(version: 2026_09_24_120000) do
37
50
  t.index ["island_id"], name: "index_flags_on_island_id"
38
51
  end
39
52
 
53
+ create_table "harbors", force: :cascade do |t|
54
+ t.string "name"
55
+ end
56
+
40
57
  create_table "isle", force: :cascade do |t|
41
58
  t.string "name"
42
59
  t.binary "map"
@@ -44,6 +61,11 @@ ActiveRecord::Schema.define(version: 2026_09_24_120000) do
44
61
  t.datetime "updated_at"
45
62
  end
46
63
 
64
+ create_table "licenses", force: :cascade do |t|
65
+ t.string "number"
66
+ t.date "expires_on"
67
+ end
68
+
47
69
  create_table "locations", force: :cascade do |t|
48
70
  t.string "coordinates"
49
71
  t.integer "island_id"
@@ -66,8 +66,12 @@ module ForestLiana
66
66
  [
67
67
  Address,
68
68
  Article,
69
+ Boat,
70
+ Captain,
69
71
  Flag,
72
+ Harbor,
70
73
  Island,
74
+ License,
71
75
  Location,
72
76
  Manufacturer,
73
77
  Membership,
@@ -0,0 +1,55 @@
1
+ require 'rails_helper'
2
+
3
+ describe 'A related list displaying a has_one :through', type: :request do
4
+ before do
5
+ allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
6
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
7
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
8
+ allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
9
+ Rails.cache.write('forest.has_permission', false)
10
+ allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
11
+ .and_return('scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' })
12
+ end
13
+
14
+ token = JWT.encode({ id: 38, email: 'michael.kelso@that70.show', first_name: 'Michael',
15
+ last_name: 'Kelso', team: 'Operations', rendering_id: 16,
16
+ exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
17
+ ForestLiana.auth_secret, 'HS256')
18
+ headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
19
+ 'Authorization' => "Bearer #{token}" }
20
+
21
+ let!(:user) { User.create!(name: 'Michel', title: :king) }
22
+ let!(:island) { Island.create!(name: 'Skull') }
23
+ let!(:location) { Location.create!(coordinates: '12345', island: island) }
24
+ let!(:membership) { Membership.create!(island: island, user: user) }
25
+
26
+ it 'serves a :through going through another :through' do
27
+ get "/forest/Island/#{island.id}/relationships/memberships",
28
+ params: { fields: { 'Membership' => 'id,location_island', 'location_island' => 'name' },
29
+ page: { 'number' => '1', 'size' => '15' }, sort: '-id', timezone: 'Europe/Paris' },
30
+ headers: headers
31
+
32
+ expect(response.status).to eq(200)
33
+ body = JSON.parse(response.body)
34
+ expect(body['data'].map { |row| row.dig('relationships', 'location_island', 'data', 'id') }).to eq([island.id.to_s])
35
+ expect(body['included'].map { |record| record.dig('attributes', 'name') }).to eq(['Skull'])
36
+ end
37
+
38
+ # Sorting on captain joins it with only the columns the list shows, and the preloader reads the
39
+ # license's key, captains.license_number, off those narrowed rows.
40
+ it 'serves a :through whose first hop is joined and keys the next hop on a non-primary column' do
41
+ harbor = Harbor.create!(name: 'Saint-Malo')
42
+ captain = Captain.create!(name: 'Haddock', license_number: 'FR-4421')
43
+ license = License.create!(number: 'FR-4421', expires_on: Date.new(2030, 1, 1))
44
+ Boat.create!(name: 'Karaboudjan', harbor: harbor, captain: captain)
45
+
46
+ get "/forest/Harbor/#{harbor.id}/relationships/boats",
47
+ params: { fields: { 'Boat' => 'id,captain,captain_license', 'captain' => 'name', 'captain_license' => 'number' },
48
+ page: { 'number' => '1', 'size' => '15' }, sort: 'captain.name', timezone: 'Europe/Paris' },
49
+ headers: headers
50
+
51
+ expect(response.status).to eq(200)
52
+ body = JSON.parse(response.body)
53
+ expect(body['data'].map { |row| row.dig('relationships', 'captain_license', 'data', 'id') }).to eq([license.id.to_s])
54
+ end
55
+ end
@@ -171,6 +171,80 @@ module ForestLiana
171
171
  expect(apimap_sorted['meta']['stack'].keys).to eq(['database_type', 'orm_version'])
172
172
  end
173
173
  end
174
+
175
+ context 'on an apimap with fields sharing a name but not a comparable type' do
176
+ # NOTICE: a field 'type' is a String ('String'), an Array (['String'] for an array
177
+ # column) or a Hash (a nested type). The sort used to compare those values
178
+ # directly, so `['String'] <=> 'String'` returned nil and `sort` raised
179
+ # `ArgumentError: comparison of Hash with Hash failed`. The rescue in `perform`
180
+ # swallowed it and returned a half-processed apimap: collections that were never
181
+ # reached kept attributes outside KEYS_COLLECTION (such as 'search_fields'), and
182
+ # the Forest API rejects those with `HTTP 400 ValidationFailedError`.
183
+ let(:apimap) do
184
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode({
185
+ 'meta': {
186
+ stack: {
187
+ 'orm_version': '4.34.9',
188
+ 'database_type': 'postgresql',
189
+ },
190
+ liana: 'forest-rails',
191
+ 'liana_version': '1.5.24',
192
+ },
193
+ 'data': [{
194
+ id: 'posts',
195
+ type: 'collections',
196
+ attributes: {
197
+ name: 'posts',
198
+ fields: [
199
+ { field: 'tags', type: ['String'] },
200
+ { field: 'tags', type: 'String' },
201
+ { field: 'meta', type: { fields: [{ field: 'locale', type: 'String' }] } },
202
+ { field: 'meta', type: 'String' },
203
+ { field: 'id', type: 'Number' },
204
+ ],
205
+ }
206
+ }, {
207
+ id: 'zebras',
208
+ type: 'collections',
209
+ attributes: {
210
+ 'search_fields': ['name'],
211
+ fields: [
212
+ { field: 'id', type: 'Number' },
213
+ ],
214
+ name: 'zebras',
215
+ }
216
+ }]
217
+ }))
218
+ end
219
+
220
+ let(:apimap_sorted) { ApimapSorter.new(apimap).perform }
221
+
222
+ it 'should reorder the whole apimap without bailing out' do
223
+ expect(FOREST_LOGGER).not_to receive(:warn)
224
+
225
+ apimap_sorted
226
+ end
227
+
228
+ it 'should sort the fields of the offending collection' do
229
+ expect(apimap_sorted['data'][0]['attributes']['fields'].map { |field| field['field'] })
230
+ .to eq(['id', 'meta', 'meta', 'tags', 'tags'])
231
+ end
232
+
233
+ it 'should keep a deterministic order between the non-comparable types' do
234
+ tag_types = apimap_sorted['data'][0]['attributes']['fields']
235
+ .select { |field| field['field'] == 'tags' }
236
+ .map { |field| field['type'] }
237
+ expect(tag_types).to eq(['String', ['String']])
238
+ end
239
+
240
+ it 'should keep processing the collections after the offending one' do
241
+ expect(apimap_sorted['data'][1]['attributes'].keys).to eq(['name', 'fields'])
242
+ end
243
+
244
+ it 'should not leak collection attributes that are not part of the schema' do
245
+ expect(apimap_sorted['data'][1]['attributes']).not_to have_key('search_fields')
246
+ end
247
+ end
174
248
  end
175
249
 
176
250
  describe 'a smart field dependencies key' do
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.22.3
4
+ version: 9.22.5
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-09-25 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -386,11 +386,15 @@ files:
386
386
  - spec/dummy/app/models/address.rb
387
387
  - spec/dummy/app/models/application_record.rb
388
388
  - spec/dummy/app/models/article.rb
389
+ - spec/dummy/app/models/boat.rb
390
+ - spec/dummy/app/models/captain.rb
389
391
  - spec/dummy/app/models/car.rb
390
392
  - spec/dummy/app/models/driver.rb
391
393
  - spec/dummy/app/models/flag.rb
392
394
  - spec/dummy/app/models/garage_record.rb
395
+ - spec/dummy/app/models/harbor.rb
393
396
  - spec/dummy/app/models/island.rb
397
+ - spec/dummy/app/models/license.rb
394
398
  - spec/dummy/app/models/location.rb
395
399
  - spec/dummy/app/models/manufacturer.rb
396
400
  - spec/dummy/app/models/membership.rb
@@ -446,6 +450,7 @@ files:
446
450
  - spec/dummy/db/migrate/20260914120000_create_articles_and_taggings.rb
447
451
  - spec/dummy/db/migrate/20260922120000_create_trees_users.rb
448
452
  - spec/dummy/db/migrate/20260924120000_add_maker_name_to_products.rb
453
+ - spec/dummy/db/migrate/20261001120000_create_harbors_boats_captains_licenses.rb
449
454
  - spec/dummy/db/schema.rb
450
455
  - spec/dummy/db/user_schema.rb
451
456
  - spec/dummy/lib/forest_liana/collections/address.rb
@@ -477,6 +482,7 @@ files:
477
482
  - spec/requests/projection_header_spec.rb
478
483
  - spec/requests/projection_inherited_loads_spec.rb
479
484
  - spec/requests/query_footprint_spec.rb
485
+ - spec/requests/related_list_nested_through_spec.rb
480
486
  - spec/requests/resources_spec.rb
481
487
  - spec/requests/stats_spec.rb
482
488
  - spec/requests/sti_record_conversion_spec.rb
@@ -734,11 +740,15 @@ test_files:
734
740
  - spec/dummy/app/models/address.rb
735
741
  - spec/dummy/app/models/application_record.rb
736
742
  - spec/dummy/app/models/article.rb
743
+ - spec/dummy/app/models/boat.rb
744
+ - spec/dummy/app/models/captain.rb
737
745
  - spec/dummy/app/models/car.rb
738
746
  - spec/dummy/app/models/driver.rb
739
747
  - spec/dummy/app/models/flag.rb
740
748
  - spec/dummy/app/models/garage_record.rb
749
+ - spec/dummy/app/models/harbor.rb
741
750
  - spec/dummy/app/models/island.rb
751
+ - spec/dummy/app/models/license.rb
742
752
  - spec/dummy/app/models/location.rb
743
753
  - spec/dummy/app/models/manufacturer.rb
744
754
  - spec/dummy/app/models/membership.rb
@@ -794,6 +804,7 @@ test_files:
794
804
  - spec/dummy/db/migrate/20260914120000_create_articles_and_taggings.rb
795
805
  - spec/dummy/db/migrate/20260922120000_create_trees_users.rb
796
806
  - spec/dummy/db/migrate/20260924120000_add_maker_name_to_products.rb
807
+ - spec/dummy/db/migrate/20261001120000_create_harbors_boats_captains_licenses.rb
797
808
  - spec/dummy/db/schema.rb
798
809
  - spec/dummy/db/user_schema.rb
799
810
  - spec/dummy/lib/forest_liana/collections/address.rb
@@ -825,6 +836,7 @@ test_files:
825
836
  - spec/requests/projection_header_spec.rb
826
837
  - spec/requests/projection_inherited_loads_spec.rb
827
838
  - spec/requests/query_footprint_spec.rb
839
+ - spec/requests/related_list_nested_through_spec.rb
828
840
  - spec/requests/resources_spec.rb
829
841
  - spec/requests/stats_spec.rb
830
842
  - spec/requests/sti_record_conversion_spec.rb