forest_liana 9.22.4 → 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 +4 -4
- data/app/services/forest_liana/ability/permission.rb +30 -5
- data/app/services/forest_liana/base_getter.rb +11 -2
- data/app/services/forest_liana/search_query_builder.rb +6 -2
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/app/models/boat.rb +8 -0
- data/spec/dummy/app/models/captain.rb +3 -0
- data/spec/dummy/app/models/harbor.rb +3 -0
- data/spec/dummy/app/models/license.rb +2 -0
- data/spec/dummy/app/models/membership.rb +5 -0
- data/spec/dummy/db/migrate/20261001120000_create_harbors_boats_captains_licenses.rb +23 -0
- data/spec/dummy/db/schema.rb +23 -1
- data/spec/dummy/lib/forest_liana/collections/boat.rb +10 -0
- data/spec/dummy/lib/forest_liana/collections/captain.rb +10 -0
- data/spec/lib/forest_liana/bootstrapper_spec.rb +4 -0
- data/spec/requests/related_list_nested_through_spec.rb +55 -0
- data/spec/requests/smart_field_named_like_unexposed_relation_spec.rb +125 -0
- data/spec/services/forest_liana/ability/assert_can_read_query_fields_spec.rb +58 -0
- data/spec/services/forest_liana/ability/redact_fields_spec.rb +95 -0
- data/spec/services/forest_liana/search_query_builder_spec.rb +83 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf311206c7fdea9b79789aba4523930ddccdf1904826d04c36e5e72995eb8640
|
|
4
|
+
data.tar.gz: 8f1aff957bd3471d3766ec99e5d1ac0e7c808fc96b7193432efde480c85cbfbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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|
|
|
@@ -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
|
-
|
|
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
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
data/spec/dummy/db/schema.rb
CHANGED
|
@@ -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:
|
|
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"
|
|
@@ -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
|
|
@@ -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,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.
|
|
4
|
+
version: 9.22.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: 2026-
|
|
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,9 +450,12 @@ 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
|
|
457
|
+
- spec/dummy/lib/forest_liana/collections/boat.rb
|
|
458
|
+
- spec/dummy/lib/forest_liana/collections/captain.rb
|
|
452
459
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
453
460
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
454
461
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -477,7 +484,9 @@ files:
|
|
|
477
484
|
- spec/requests/projection_header_spec.rb
|
|
478
485
|
- spec/requests/projection_inherited_loads_spec.rb
|
|
479
486
|
- spec/requests/query_footprint_spec.rb
|
|
487
|
+
- spec/requests/related_list_nested_through_spec.rb
|
|
480
488
|
- spec/requests/resources_spec.rb
|
|
489
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
481
490
|
- spec/requests/stats_spec.rb
|
|
482
491
|
- spec/requests/sti_record_conversion_spec.rb
|
|
483
492
|
- spec/requests/test.ru
|
|
@@ -734,11 +743,15 @@ test_files:
|
|
|
734
743
|
- spec/dummy/app/models/address.rb
|
|
735
744
|
- spec/dummy/app/models/application_record.rb
|
|
736
745
|
- spec/dummy/app/models/article.rb
|
|
746
|
+
- spec/dummy/app/models/boat.rb
|
|
747
|
+
- spec/dummy/app/models/captain.rb
|
|
737
748
|
- spec/dummy/app/models/car.rb
|
|
738
749
|
- spec/dummy/app/models/driver.rb
|
|
739
750
|
- spec/dummy/app/models/flag.rb
|
|
740
751
|
- spec/dummy/app/models/garage_record.rb
|
|
752
|
+
- spec/dummy/app/models/harbor.rb
|
|
741
753
|
- spec/dummy/app/models/island.rb
|
|
754
|
+
- spec/dummy/app/models/license.rb
|
|
742
755
|
- spec/dummy/app/models/location.rb
|
|
743
756
|
- spec/dummy/app/models/manufacturer.rb
|
|
744
757
|
- spec/dummy/app/models/membership.rb
|
|
@@ -794,9 +807,12 @@ test_files:
|
|
|
794
807
|
- spec/dummy/db/migrate/20260914120000_create_articles_and_taggings.rb
|
|
795
808
|
- spec/dummy/db/migrate/20260922120000_create_trees_users.rb
|
|
796
809
|
- spec/dummy/db/migrate/20260924120000_add_maker_name_to_products.rb
|
|
810
|
+
- spec/dummy/db/migrate/20261001120000_create_harbors_boats_captains_licenses.rb
|
|
797
811
|
- spec/dummy/db/schema.rb
|
|
798
812
|
- spec/dummy/db/user_schema.rb
|
|
799
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
|
|
800
816
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
801
817
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
802
818
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -825,7 +841,9 @@ test_files:
|
|
|
825
841
|
- spec/requests/projection_header_spec.rb
|
|
826
842
|
- spec/requests/projection_inherited_loads_spec.rb
|
|
827
843
|
- spec/requests/query_footprint_spec.rb
|
|
844
|
+
- spec/requests/related_list_nested_through_spec.rb
|
|
828
845
|
- spec/requests/resources_spec.rb
|
|
846
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
829
847
|
- spec/requests/stats_spec.rb
|
|
830
848
|
- spec/requests/sti_record_conversion_spec.rb
|
|
831
849
|
- spec/requests/test.ru
|