From ece5f0e7de80c0cc6506f9176c0e3aec39c125ab Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Oct 2025 17:38:09 +0200 Subject: [PATCH 01/15] fix: support composite primary keys in resource getters --- app/services/forest_liana/has_many_getter.rb | 27 ++++++++++++++++++-- app/services/forest_liana/resource_getter.rb | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/services/forest_liana/has_many_getter.rb b/app/services/forest_liana/has_many_getter.rb index 27504116..1a140a4f 100644 --- a/app/services/forest_liana/has_many_getter.rb +++ b/app/services/forest_liana/has_many_getter.rb @@ -25,7 +25,29 @@ def perform end def count - @records_count = @records.count + association_class = model_association + + if association_class.primary_key.is_a?(Array) + adapter_name = association_class.connection.adapter_name.downcase + + if adapter_name.include?('sqlite') + # For SQLite: concatenate columns for DISTINCT count + pk_concat = association_class.primary_key.map do |pk| + "#{association_class.table_name}.#{pk}" + end.join(" || '|' || ") + + @records_count = @records.distinct.count(Arel.sql(pk_concat)) + else + # For PostgreSQL/MySQL: use DISTINCT with multiple columns + pk_columns = association_class.primary_key.map do |pk| + "#{association_class.table_name}.#{pk}" + end.join(', ') + + @records_count = @records.distinct.count(Arel.sql(pk_columns)) + end + else + @records_count = @records.count + end end def query_for_batch @@ -72,7 +94,8 @@ def model_association end def prepare_query - association = get_resource().find(@params[:id]).send(@params[:association_name]) + parent_record = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(get_resource(), @resource, @params[:id]) + association = parent_record.send(@params[:association_name]) @records = optimize_record_loading(association, @search_query_builder.perform(association)) end diff --git a/app/services/forest_liana/resource_getter.rb b/app/services/forest_liana/resource_getter.rb index 90320823..d68d1cf1 100644 --- a/app/services/forest_liana/resource_getter.rb +++ b/app/services/forest_liana/resource_getter.rb @@ -14,7 +14,7 @@ def initialize(resource, params, forest_user) def perform records = optimize_record_loading(@resource, get_resource()) scoped_records = ForestLiana::ScopeManager.apply_scopes_on_records(records, @user, @collection_name, @params[:timezone]) - @record = scoped_records.find(@params[:id]) + @record = ForestLiana::Utils::CompositePrimaryKeyHelper.find_record(scoped_records, @resource, @params[:id]) end end end From 9445988785525dae3e3872da1d5f2527f5fc2b24 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Oct 2025 17:51:59 +0200 Subject: [PATCH 02/15] chore: add new utils --- .../utils/composite_primary_key_helper.rb | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/services/forest_liana/utils/composite_primary_key_helper.rb diff --git a/app/services/forest_liana/utils/composite_primary_key_helper.rb b/app/services/forest_liana/utils/composite_primary_key_helper.rb new file mode 100644 index 00000000..7fe76afe --- /dev/null +++ b/app/services/forest_liana/utils/composite_primary_key_helper.rb @@ -0,0 +1,27 @@ +module ForestLiana + module Utils + module CompositePrimaryKeyHelper + def self.find_record(scoped_records, resource, id) + primary_key = resource.primary_key + + if primary_key.is_a?(Array) + id_values = parse_composite_id(id) + conditions = primary_key.zip(id_values).to_h + scoped_records.find_by(conditions) + else + scoped_records.find(id) + end + end + + def self.parse_composite_id(id) + return id if id.is_a?(Array) + + if id.to_s.start_with?('[') && id.to_s.end_with?(']') + JSON.parse(id.to_s) + else + raise ForestLiana::Errors::HTTP422Error.new("Composite primary key ID must be in format [value1,value2], received: #{id}") + end + end + end + end +end From 99a6c7cc1b70a537eb4bf672b65cdb337115c9b3 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 16 Oct 2025 17:37:18 +0200 Subject: [PATCH 03/15] fix: composite-id --- app/services/forest_liana/resources_getter.rb | 20 ++- .../db/migrate/20220719094127_create_cars.rb | 2 +- .../migrate/20220719094450_create_drivers.rb | 2 +- spec/dummy/db/schema.rb | 10 ++ .../resources_getter_composite_keys_spec.rb | 116 ++++++++++++++++++ 5 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 spec/services/forest_liana/resources_getter_composite_keys_spec.rb diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index 9ded9c6c..077c2886 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -84,7 +84,10 @@ def columns_for_cross_database_association(association_name) columns = association.klass.column_names.map(&:to_sym) # Ensure the foreign key is present for manual binding (especially for has_one) - columns << association.foreign_key.to_sym if association.macro == :has_one + if association.macro == :has_one + foreign_keys = Array(association.foreign_key).map(&:to_sym) + columns.concat(foreign_keys) + end columns.uniq end @@ -99,7 +102,9 @@ def compute_includes fields = @params[:fields]&.[](association_name)&.split(',') if fields&.size == 1 && fields.include?(association.klass.primary_key) - @field_names_requested << association.foreign_key + # Handle composite foreign keys + foreign_keys = Array(association.foreign_key) + foreign_keys.each { |fk| @field_names_requested << fk } @optional_includes << association.name end @@ -295,7 +300,12 @@ def compute_select_fields if SchemaUtils.polymorphic?(association) select << "#{@resource.table_name}.#{association.foreign_type}" end - select << "#{@resource.table_name}.#{association.foreign_key}" + + # Handle composite foreign keys + foreign_keys = Array(association.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end end fields = @params[:fields]&.[](path)&.split(',') @@ -319,8 +329,10 @@ def compute_select_fields end def get_one_association(name) + # Handle composite primary keys - name might be an Array + name_sym = name.is_a?(Array) ? name : name.to_sym ForestLiana::QueryHelper.get_one_associations(@resource) - .select { |association| association.name == name.to_sym } + .select { |association| association.name == name_sym } .first end end diff --git a/spec/dummy/db/migrate/20220719094127_create_cars.rb b/spec/dummy/db/migrate/20220719094127_create_cars.rb index 2e0c92d8..67e3edd8 100644 --- a/spec/dummy/db/migrate/20220719094127_create_cars.rb +++ b/spec/dummy/db/migrate/20220719094127_create_cars.rb @@ -1,6 +1,6 @@ class CreateCars < ActiveRecord::Migration[6.0] def change - Car.connection.create_table :cars do |t| + create_table :cars do |t| t.string :model t.references :driver, index: true end diff --git a/spec/dummy/db/migrate/20220719094450_create_drivers.rb b/spec/dummy/db/migrate/20220719094450_create_drivers.rb index 75fe91c5..4e3f83a7 100644 --- a/spec/dummy/db/migrate/20220719094450_create_drivers.rb +++ b/spec/dummy/db/migrate/20220719094450_create_drivers.rb @@ -1,6 +1,6 @@ class CreateDrivers < ActiveRecord::Migration[6.0] def change - Driver.connection.create_table :drivers do |t| + create_table :drivers do |t| t.string :firstname end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 81fb8816..ef23917c 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -23,6 +23,16 @@ t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable_type_and_addressable_id" end + create_table "cars", force: :cascade do |t| + t.string "model" + t.integer "driver_id" + t.index ["driver_id"], name: "index_cars_on_driver_id" + end + + create_table "drivers", force: :cascade do |t| + t.string "firstname" + end + create_table "isle", force: :cascade do |t| t.string "name" t.binary "map" diff --git a/spec/services/forest_liana/resources_getter_composite_keys_spec.rb b/spec/services/forest_liana/resources_getter_composite_keys_spec.rb new file mode 100644 index 00000000..7b287d34 --- /dev/null +++ b/spec/services/forest_liana/resources_getter_composite_keys_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +module ForestLiana + describe ResourcesGetter do + describe 'composite primary keys support' do + let(:resource) { User } + let(:params) do + { + page: { size: 10, number: 1 }, + sort: 'id', + fields: { 'User' => 'id,name' } + } + end + let(:user) { { 'id' => '1', 'rendering_id' => 13 } } + + before do + allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return({ + 'scopes' => {}, + 'team' => {'id' => '1', 'name' => 'Operations'} + }) + end + + describe '#get_one_association' do + it 'does not crash when name is a symbol' do + getter = described_class.new(resource, params, user) + expect { + getter.send(:get_one_association, :owner) + }.not_to raise_error + end + + it 'does not crash when name is a string' do + getter = described_class.new(resource, params, user) + expect { + getter.send(:get_one_association, 'owner') + }.not_to raise_error + end + + it 'does not crash when name is an array (composite key edge case)' do + getter = described_class.new(resource, params, user) + # Should not raise "undefined method `to_sym' for Array" + expect { + getter.send(:get_one_association, [:user_id, :slot_id]) + }.not_to raise_error + end + + it 'returns nil gracefully when name is an array' do + getter = described_class.new(resource, params, user) + result = getter.send(:get_one_association, [:user_id, :slot_id]) + expect(result).to be_nil + end + end + + describe 'handling composite foreign keys in associations' do + let(:mock_association) do + double('Association', + name: :test_association, + foreign_key: [:user_id, :slot_id], # Composite foreign key + klass: double('Klass', column_names: ['id', 'name']), + macro: :has_one, + options: {} + ) + end + + let(:simple_association) do + double('Association', + name: :simple_association, + foreign_key: 'user_id', # Simple foreign key + klass: double('Klass', column_names: ['id', 'name']), + macro: :has_one, + options: {} + ) + end + + describe '#columns_for_cross_database_association' do + it 'handles composite foreign keys without crashing' do + getter = described_class.new(resource, params, user) + + allow(resource).to receive(:reflect_on_association) + .with(:test_association) + .and_return(mock_association) + + expect { + getter.send(:columns_for_cross_database_association, :test_association) + }.not_to raise_error + end + + it 'includes all composite foreign key columns' do + getter = described_class.new(resource, params, user) + + allow(resource).to receive(:reflect_on_association) + .with(:test_association) + .and_return(mock_association) + + columns = getter.send(:columns_for_cross_database_association, :test_association) + + expect(columns).to include(:user_id) + expect(columns).to include(:slot_id) + end + + it 'handles simple foreign keys without breaking existing behavior' do + getter = described_class.new(resource, params, user) + + allow(resource).to receive(:reflect_on_association) + .with(:simple_association) + .and_return(simple_association) + + expect { + columns = getter.send(:columns_for_cross_database_association, :simple_association) + expect(columns).to include(:user_id) + }.not_to raise_error + end + end + end + end + end +end From 031383452567406429c912aea8a846f3e578759c Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 12:29:53 +0200 Subject: [PATCH 04/15] fix: list & csv queries --- app/services/forest_liana/resources_getter.rb | 22 +--- spec/requests/resources_spec.rb | 122 ++++++++++++++++++ 2 files changed, 128 insertions(+), 16 deletions(-) diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index 077c2886..ed3f1fa4 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -97,19 +97,7 @@ def compute_includes @optional_includes = [] if @field_names_requested && @params['searchExtended'].to_i != 1 - includes = associations_has_one.map do |association| - association_name = association.name.to_s - - fields = @params[:fields]&.[](association_name)&.split(',') - if fields&.size == 1 && fields.include?(association.klass.primary_key) - # Handle composite foreign keys - foreign_keys = Array(association.foreign_key) - foreign_keys.each { |fk| @field_names_requested << fk } - @optional_includes << association.name - end - - association.name - end + includes = associations_has_one.map(&:name) includes_for_smart_search = [] if @collection && @collection.search_fields @@ -302,9 +290,11 @@ def compute_select_fields end # Handle composite foreign keys - foreign_keys = Array(association.foreign_key) - foreign_keys.each do |fk| - select << "#{@resource.table_name}.#{fk}" + if association.macro == :belongs_to + foreign_keys = Array(association.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end end end diff --git a/spec/requests/resources_spec.rb b/spec/requests/resources_spec.rb index b6146e69..bdcde579 100644 --- a/spec/requests/resources_spec.rb +++ b/spec/requests/resources_spec.rb @@ -309,6 +309,82 @@ end end +describe 'Requesting Island resources', :type => :request do + let(:scope_filters) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} } + before do + island = Island.create(name: 'Paradise Island') + Location.create(coordinates: '10,20', island: island) + + Rails.cache.write('forest.users', {'1' => { 'id' => 1, 'roleId' => 1, 'rendering_id' => '1' }}) + Rails.cache.write('forest.has_permission', true) + Rails.cache.write( + 'forest.collections', + { + 'Island' => { + 'browse' => [1], + 'read' => [1], + 'edit' => [1], + 'add' => [1], + 'delete' => [1], + 'export' => [1], + 'actions' => {} + } + } + ) + + allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true } + allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true } + allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true } + allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scope_filters) + end + + after do + Island.destroy_all + Location.destroy_all + end + + token = JWT.encode({ + id: 1, + email: 'michael.kelso@that70.show', + first_name: 'Michael', + last_name: 'Kelso', + team: 'Operations', + rendering_id: 16, + exp: Time.now.to_i + 2.weeks.to_i, + permission_level: 'admin' + }, ForestLiana.auth_secret, 'HS256') + + headers = { + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{token}" + } + + describe 'csv' do + it 'should return CSV with has_one association without SQL error' do + params = { + fields: { 'Island' => 'id,name,location', 'location' => 'coordinates'}, + page: { 'number' => '1', 'size' => '10' }, + searchExtended: '0', + sort: '-id', + timezone: 'Europe/Paris', + header: 'id,name,location', + } + get '/forest/Island.csv', params: params, headers: headers + + expect(response.status).to eq(200) + expect(response.headers['Content-Type']).to include('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment') + + csv_content = response.body + csv_lines = csv_content.split("\n") + + expect(csv_lines.first).to eq(params[:header]) + expect(csv_lines[1]).to eq('1,Paradise Island,"10,20"') + end + end +end + describe 'Requesting Address resources', :type => :request do let(:scope_filters) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} } before do @@ -391,4 +467,50 @@ ) end end + + describe 'csv' do + it 'should return CSV with polymorphic association' do + params = { + fields: { 'Address' => 'id,line1,city,addressable', 'addressable' => 'name'}, + page: { 'number' => '1', 'size' => '10' }, + searchExtended: '0', + sort: '-id', + timezone: 'Europe/Paris', + header: 'id,line1,city,addressable', + } + get '/forest/Address.csv', params: params, headers: headers + + expect(response.status).to eq(200) + expect(response.headers['Content-Type']).to include('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment') + + csv_content = response.body + csv_lines = csv_content.split("\n") + + expect(csv_lines.first).to eq(params[:header]) + expect(csv_lines[1]).to eq('1,10 Downing Street,London,Michel') + end + + it 'should return CSV with only requested fields and ignore optional polymorphic relation' do + params = { + fields: { 'Address' => 'id,line1,city', 'addressable' => 'name'}, + page: { 'number' => '1', 'size' => '10' }, + searchExtended: '0', + sort: '-id', + timezone: 'Europe/Paris', + header: 'id,line1,city', + } + get '/forest/Address.csv', params: params, headers: headers + + expect(response.status).to eq(200) + expect(response.headers['Content-Type']).to include('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment') + + csv_content = response.body + csv_lines = csv_content.split("\n") + + expect(csv_lines.first).to eq(params[:header]) + expect(csv_lines[1]).to eq('1,10 Downing Street,London') + end + end end From 9bcc5256849979b1f24bf4ec718e47c021d2be13 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 14:22:20 +0200 Subject: [PATCH 05/15] fix(csv): polymorphic associations --- app/controllers/forest_liana/application_controller.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/forest_liana/application_controller.rb b/app/controllers/forest_liana/application_controller.rb index fbd1d1e2..c8573f87 100644 --- a/app/controllers/forest_liana/application_controller.rb +++ b/app/controllers/forest_liana/application_controller.rb @@ -173,7 +173,7 @@ def fields_per_model(params_fields, model) fields[relation_name] = relation_fields elsif model.reflect_on_association(relation_name.to_sym) model_association = model.reflect_on_association(relation_name.to_sym) - if model_association + if model_association && !model_association.polymorphic? model_name = ForestLiana.name_for(model_association.klass) # NOTICE: Join fields in case of model with self-references. if fields[model_name] @@ -184,6 +184,8 @@ def fields_per_model(params_fields, model) else fields[model_name] = relation_fields end + elsif model_association && model_association.polymorphic? + fields[relation_name] = relation_fields end else smart_relations.each do |smart_relation| From c2ffa27101e4e4b141fb7ec24f96a7915bb418ae Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 14:22:54 +0200 Subject: [PATCH 06/15] chore: add context variables on exception --- app/services/forest_liana/utils/context_variables_injector.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/forest_liana/utils/context_variables_injector.rb b/app/services/forest_liana/utils/context_variables_injector.rb index 3469330e..caf0f715 100644 --- a/app/services/forest_liana/utils/context_variables_injector.rb +++ b/app/services/forest_liana/utils/context_variables_injector.rb @@ -5,7 +5,7 @@ class ContextVariablesInjector def self.inject_context_in_value(value, context_variables) inject_context_in_value_custom(value) do |context_variable_key| value = context_variables.get_value(context_variable_key) - raise "Unknown context variable: #{context_variable_key}, please check the query for any typos" if value.nil? + raise "Unknown context variable: #{context_variable_key}, please check the query for any typos, received context #{context_variables.inspect}" if value.nil? value.to_s end end From 59f8ba49017be63a70a490aa24151b9ac867b525 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 14:31:06 +0200 Subject: [PATCH 07/15] chore: update test --- .../forest_liana/utils/context_variables_injector_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/forest_liana/utils/context_variables_injector_spec.rb b/spec/services/forest_liana/utils/context_variables_injector_spec.rb index 1cf0e211..4d956184 100644 --- a/spec/services/forest_liana/utils/context_variables_injector_spec.rb +++ b/spec/services/forest_liana/utils/context_variables_injector_spec.rb @@ -105,7 +105,7 @@ module Utils it 'raises an error when the variable is not found' do expect { described_class.inject_context_in_value("{{siths.selectedRecord.evilString}}", context_variables) - }.to raise_error('Unknown context variable: siths.selectedRecord.evilString, please check the query for any typos') + }.to raise_error(/Unknown context variable: siths\.selectedRecord\.evilString, please check the query for any typos/) end end end From 5888a288159584dad80b758d2c35618543b57c57 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 14:35:06 +0200 Subject: [PATCH 08/15] fix: restore Gemfile.lock --- Gemfile.lock | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index e69de29b..c942967f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -0,0 +1,297 @@ +PATH + remote: . + specs: + forest_liana (9.15.8) + bcrypt + deepsort + forestadmin-jsonapi-serializers (>= 0.14.0) + groupdate (>= 5.0.0) + httparty + ipaddress + json + json-jwt (>= 1.16.0) + jwt + openid_connect (= 1.4.2) + rack-cors + rails (>= 6.1.7.9) + useragent + +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (>= 2.7.1) + actionmailer (6.1.7.9) + actionpack (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.1.7.9) + actionview (= 6.1.7.9) + activesupport (= 6.1.7.9) + rack (~> 2.0, >= 2.0.9) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.7.9) + actionpack (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + nokogiri (>= 1.8.5) + actionview (6.1.7.9) + activesupport (= 6.1.7.9) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.1.7.9) + activesupport (= 6.1.7.9) + globalid (>= 0.3.6) + activemodel (6.1.7.9) + activesupport (= 6.1.7.9) + activerecord (6.1.7.9) + activemodel (= 6.1.7.9) + activesupport (= 6.1.7.9) + activestorage (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activesupport (= 6.1.7.9) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (6.1.7.9) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + aes_key_wrap (1.1.0) + attr_required (1.0.2) + base64 (0.3.0) + bcrypt (3.1.20) + bigdecimal (3.3.1) + bindata (2.5.1) + builder (3.3.0) + byebug (12.0.0) + concurrent-ruby (1.3.4) + crass (1.0.6) + date (3.4.1) + deepsort (0.5.0) + diff-lcs (1.6.2) + docile (1.4.1) + erubi (1.13.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.4.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.1) + net-http (>= 0.5.0) + forestadmin-jsonapi-serializers (2.0.0.pre.beta.2) + activesupport + globalid (1.3.0) + activesupport (>= 6.1) + groupdate (5.2.2) + activesupport (>= 5) + httparty (0.21.0) + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) + concurrent-ruby (~> 1.0) + ipaddress (0.8.3) + json (2.15.1) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects + jwt (3.1.2) + base64 + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + method_source (1.1.0) + mini_mime (1.1.5) + minitest (5.26.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + mutex_m (0.3.0) + net-http (0.6.0) + uri + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + openid_connect (1.4.2) + activemodel + attr_required (>= 1.0.0) + json-jwt (>= 1.15.0) + net-smtp + rack-oauth2 (~> 1.21) + swd (~> 1.3) + tzinfo + validate_email + validate_url + webfinger (~> 1.2) + public_suffix (6.0.2) + racc (1.8.1) + rack (2.2.20) + rack-cors (2.0.2) + rack (>= 2.0.0) + rack-oauth2 (1.21.3) + activesupport + attr_required + httpclient + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-test (2.2.0) + rack (>= 1.3) + rails (6.1.7.9) + actioncable (= 6.1.7.9) + actionmailbox (= 6.1.7.9) + actionmailer (= 6.1.7.9) + actionpack (= 6.1.7.9) + actiontext (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activemodel (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + bundler (>= 1.15.0) + railties (= 6.1.7.9) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + method_source + rake (>= 12.2) + thor (~> 1.0) + rake (13.3.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.4) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + sqlite3 (1.7.3-arm64-darwin) + swd (1.3.0) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + thor (1.4.0) + timecop (0.9.10) + timeout (0.4.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.0.4) + useragent (0.16.11) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix + webfinger (1.2.0) + activesupport + httpclient (>= 2.4) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) + +PLATFORMS + arm64-darwin-23 + +DEPENDENCIES + bcrypt + byebug + concurrent-ruby (= 1.3.4) + deepsort + forest_liana! + forestadmin-jsonapi-serializers + groupdate (= 5.2.2) + httparty (= 0.21.0) + ipaddress (= 0.8.3) + json + json-jwt (>= 1.16) + jwt + openid_connect (= 1.4.2) + rack-cors + rails (= 6.1.7.9) + rake + rspec-rails + simplecov (~> 0.22) + simplecov_json_formatter (~> 0.1.4) + sqlite3 (~> 1.4) + timecop + useragent + +BUNDLED WITH + 2.7.2 From 3cdb5d70b2751bcd5b56f61315858a05c957ff4f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Oct 2025 14:47:59 +0200 Subject: [PATCH 09/15] fix: use correct database connections for multi-db migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore Driver.connection and Car.connection in migrations to ensure tables are created in the correct databases (test3.sqlite3 for drivers, test2.sqlite3 for cars) instead of the primary database. This fixes CI test failures where Driver model couldn't find the drivers table because it was being created in the wrong database. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- spec/dummy/db/migrate/20220719094127_create_cars.rb | 2 +- spec/dummy/db/migrate/20220719094450_create_drivers.rb | 2 +- spec/dummy/db/schema.rb | 10 ---------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/spec/dummy/db/migrate/20220719094127_create_cars.rb b/spec/dummy/db/migrate/20220719094127_create_cars.rb index 67e3edd8..2e0c92d8 100644 --- a/spec/dummy/db/migrate/20220719094127_create_cars.rb +++ b/spec/dummy/db/migrate/20220719094127_create_cars.rb @@ -1,6 +1,6 @@ class CreateCars < ActiveRecord::Migration[6.0] def change - create_table :cars do |t| + Car.connection.create_table :cars do |t| t.string :model t.references :driver, index: true end diff --git a/spec/dummy/db/migrate/20220719094450_create_drivers.rb b/spec/dummy/db/migrate/20220719094450_create_drivers.rb index 4e3f83a7..75fe91c5 100644 --- a/spec/dummy/db/migrate/20220719094450_create_drivers.rb +++ b/spec/dummy/db/migrate/20220719094450_create_drivers.rb @@ -1,6 +1,6 @@ class CreateDrivers < ActiveRecord::Migration[6.0] def change - create_table :drivers do |t| + Driver.connection.create_table :drivers do |t| t.string :firstname end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index ef23917c..81fb8816 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -23,16 +23,6 @@ t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable_type_and_addressable_id" end - create_table "cars", force: :cascade do |t| - t.string "model" - t.integer "driver_id" - t.index ["driver_id"], name: "index_cars_on_driver_id" - end - - create_table "drivers", force: :cascade do |t| - t.string "firstname" - end - create_table "isle", force: :cascade do |t| t.string "name" t.binary "map" From c72bd862493c7b9f27eeb8de0fb5bac07042aeff Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Thu, 30 Oct 2025 11:20:11 +0100 Subject: [PATCH 10/15] fix: comprehensive composite key and export improvements --- Gemfile.lock | 1 + .../forest_liana/serializer_factory.rb | 9 +++++ app/services/forest_liana/resources_getter.rb | 39 +++++++++++++++++-- .../utils/context_variables_injector.rb | 9 ++++- .../utils/context_variables_injector_spec.rb | 2 +- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c942967f..6606de80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -267,6 +267,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-21 arm64-darwin-23 DEPENDENCIES diff --git a/app/serializers/forest_liana/serializer_factory.rb b/app/serializers/forest_liana/serializer_factory.rb index fdef41c3..94d5a7d5 100644 --- a/app/serializers/forest_liana/serializer_factory.rb +++ b/app/serializers/forest_liana/serializer_factory.rb @@ -122,6 +122,15 @@ def serializer_for(active_record_class) serializer = Class.new { include ForestAdmin::JSONAPI::Serializer + def id + pk = object.class.primary_key + if pk.is_a?(Array) + pk.map { |key| object.send(key) }.to_json + else + object.id.to_s + end + end + def self_link "/forest#{super.underscore}" end diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index ed3f1fa4..7cc7b653 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -112,7 +112,13 @@ def compute_includes includes_for_smart_search = includes_for_smart_search & includes_has_many end - @includes = (includes & @field_names_requested).concat(includes_for_smart_search) + filter_associations = extract_associations_from_filter + filter_has_many = filter_associations.select do |assoc_name| + assoc = @resource.reflect_on_association(assoc_name) + assoc && [:has_many, :has_and_belongs_to_many].include?(assoc.macro) + end + + @includes = (includes & @field_names_requested).concat(includes_for_smart_search).concat(filter_has_many).uniq else @includes = associations_has_one # Avoid eager loading has_one associations pointing to a different database as ORM can't join cross databases @@ -160,8 +166,13 @@ def extract_associations_from_filter conditions.each do |condition| field = condition['field'] if field&.include?(':') + # Handle association filters with : separator (e.g., "user:name") associations << field.split(':').first.to_sym @count_needs_includes = true + elsif field&.include?('.') + # Handle nested association filters with . separator (e.g., "top_level_partner.display_name") + associations << field.split('.').first.to_sym + @count_needs_includes = true end end @@ -278,6 +289,14 @@ def pagination? def compute_select_fields select = ['_forest_admin_eager_load'] + + pk = @resource.primary_key + if pk.is_a?(Array) + pk.each { |key| select << "#{@resource.table_name}.#{key}" } + else + select << "#{@resource.table_name}.#{pk}" + end + @field_names_requested.each do |path| association = get_one_association(path) if association @@ -285,12 +304,15 @@ def compute_select_fields association = get_one_association(association.options[:through]) end + if is_active_storage_association?(association) + next + end + if SchemaUtils.polymorphic?(association) select << "#{@resource.table_name}.#{association.foreign_type}" end - # Handle composite foreign keys - if association.macro == :belongs_to + if association.macro == :belongs_to || association.macro == :has_one foreign_keys = Array(association.foreign_key) foreign_keys.each do |fk| select << "#{@resource.table_name}.#{fk}" @@ -303,6 +325,8 @@ def compute_select_fields association = get_one_association(path) table_name = association.table_name + next if association && is_active_storage_association?(association) + fields.each do |association_path| if ForestLiana::SchemaHelper.is_smart_field?(association.klass, association_path) association.klass.attribute_names.each { |attribute| select << "#{table_name}.#{attribute}" } @@ -325,5 +349,14 @@ def get_one_association(name) .select { |association| association.name == name_sym } .first end + + def is_active_storage_association?(association) + return false unless association + + klass_name = association.klass.name + klass_name == 'ActiveStorage::Attachment' || + klass_name == 'ActiveStorage::Blob' || + klass_name.start_with?('ActiveStorage::') + end end end diff --git a/app/services/forest_liana/utils/context_variables_injector.rb b/app/services/forest_liana/utils/context_variables_injector.rb index caf0f715..dcf321f7 100644 --- a/app/services/forest_liana/utils/context_variables_injector.rb +++ b/app/services/forest_liana/utils/context_variables_injector.rb @@ -5,7 +5,14 @@ class ContextVariablesInjector def self.inject_context_in_value(value, context_variables) inject_context_in_value_custom(value) do |context_variable_key| value = context_variables.get_value(context_variable_key) - raise "Unknown context variable: #{context_variable_key}, please check the query for any typos, received context #{context_variables.inspect}" if value.nil? + if value.nil? + available_keys = context_variables.respond_to?(:keys) ? context_variables.keys.join(', ') : 'unknown' + available_context = context_variables.inspect + error_message = "Unknown context variable: '#{context_variable_key}'. " \ + "Please check the query for any typos. " \ + "Available context variables: #{available_keys}. " + raise error_message + end value.to_s end end diff --git a/spec/services/forest_liana/utils/context_variables_injector_spec.rb b/spec/services/forest_liana/utils/context_variables_injector_spec.rb index 4d956184..035e8156 100644 --- a/spec/services/forest_liana/utils/context_variables_injector_spec.rb +++ b/spec/services/forest_liana/utils/context_variables_injector_spec.rb @@ -105,7 +105,7 @@ module Utils it 'raises an error when the variable is not found' do expect { described_class.inject_context_in_value("{{siths.selectedRecord.evilString}}", context_variables) - }.to raise_error(/Unknown context variable: siths\.selectedRecord\.evilString, please check the query for any typos/) + }.to raise_error(/Unknown context variable: 'siths\.selectedRecord\.evilString'/) end end end From 3b283c56cfc84218184d24c5bee64790e8236906 Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Thu, 30 Oct 2025 15:49:41 +0100 Subject: [PATCH 11/15] fix: activeStorage --- app/services/forest_liana/resources_getter.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index 7cc7b653..ed681534 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -304,7 +304,21 @@ def compute_select_fields association = get_one_association(association.options[:through]) end + # For ActiveStorage associations, include all required columns if is_active_storage_association?(association) + # Include all columns from ActiveStorage tables to avoid initialization errors + table_name = association.table_name + association.klass.column_names.each do |column_name| + select << "#{table_name}.#{column_name}" + end + + # Also include the foreign key from the main resource (e.g., blob_id, record_id) + if association.macro == :belongs_to || association.macro == :has_one + foreign_keys = Array(association.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end + end next end From 567cc785300f34f64a778d0ce7c6c57c8924eb5b Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Thu, 30 Oct 2025 16:05:53 +0100 Subject: [PATCH 12/15] fix: active storage again --- app/services/forest_liana/resources_getter.rb | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index ed681534..d5cc2ded 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -297,6 +297,32 @@ def compute_select_fields select << "#{@resource.table_name}.#{pk}" end + # Handle ActiveStorage associations from both @includes and @field_names_requested + active_storage_associations_processed = Set.new + + (@includes + @field_names_requested).each do |path| + association = path.is_a?(Symbol) ? @resource.reflect_on_association(path) : get_one_association(path) + next unless association + next if active_storage_associations_processed.include?(association.name) + next unless is_active_storage_association?(association) + + # Include all columns from ActiveStorage tables to avoid initialization errors + table_name = association.table_name + association.klass.column_names.each do |column_name| + select << "#{table_name}.#{column_name}" + end + + # Include the foreign key from the main resource (e.g., blob_id, record_id) + if association.macro == :belongs_to || association.macro == :has_one + foreign_keys = Array(association.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end + end + + active_storage_associations_processed.add(association.name) + end + @field_names_requested.each do |path| association = get_one_association(path) if association @@ -304,23 +330,8 @@ def compute_select_fields association = get_one_association(association.options[:through]) end - # For ActiveStorage associations, include all required columns - if is_active_storage_association?(association) - # Include all columns from ActiveStorage tables to avoid initialization errors - table_name = association.table_name - association.klass.column_names.each do |column_name| - select << "#{table_name}.#{column_name}" - end - - # Also include the foreign key from the main resource (e.g., blob_id, record_id) - if association.macro == :belongs_to || association.macro == :has_one - foreign_keys = Array(association.foreign_key) - foreign_keys.each do |fk| - select << "#{@resource.table_name}.#{fk}" - end - end - next - end + # Skip ActiveStorage associations - already processed above + next if is_active_storage_association?(association) if SchemaUtils.polymorphic?(association) select << "#{@resource.table_name}.#{association.foreign_type}" From 313f16acdce08b6bc2514c322ea3f24a40aa65f1 Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Thu, 30 Oct 2025 16:21:50 +0100 Subject: [PATCH 13/15] fix: csv issues --- app/services/forest_liana/resources_getter.rb | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index d5cc2ded..b2802c23 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -297,6 +297,21 @@ def compute_select_fields select << "#{@resource.table_name}.#{pk}" end + # Include columns used in default ordering for batch cursor compatibility + if @resource.respond_to?(:default_scoped) && @resource.default_scoped.order_values.any? + @resource.default_scoped.order_values.each do |order_value| + if order_value.is_a?(Arel::Nodes::Ordering) + # Extract column name from Arel node + column_name = order_value.expr.name if order_value.expr.respond_to?(:name) + select << "#{@resource.table_name}.#{column_name}" if column_name + elsif order_value.is_a?(String) || order_value.is_a?(Symbol) + # Handle simple column names + column_name = order_value.to_s.split(' ').first.split('.').last + select << "#{@resource.table_name}.#{column_name}" + end + end + end + # Handle ActiveStorage associations from both @includes and @field_names_requested active_storage_associations_processed = Set.new @@ -326,21 +341,39 @@ def compute_select_fields @field_names_requested.each do |path| association = get_one_association(path) if association + # Handle :through associations - resolve to the direct association + original_association = association + through_chain = [] while association.options[:through] + through_chain << association.options[:through] association = get_one_association(association.options[:through]) end # Skip ActiveStorage associations - already processed above next if is_active_storage_association?(association) - if SchemaUtils.polymorphic?(association) - select << "#{@resource.table_name}.#{association.foreign_type}" - end + # For :through associations, only add foreign keys from the direct (first) association in the chain + # Don't try to select columns from the main table for the final :through target + if through_chain.any? + # Use the first association in the through chain + first_through = get_one_association(through_chain.first) + if first_through && (first_through.macro == :belongs_to || first_through.macro == :has_one) + foreign_keys = Array(first_through.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end + end + else + # Direct association (not :through) + if SchemaUtils.polymorphic?(association) + select << "#{@resource.table_name}.#{association.foreign_type}" + end - if association.macro == :belongs_to || association.macro == :has_one - foreign_keys = Array(association.foreign_key) - foreign_keys.each do |fk| - select << "#{@resource.table_name}.#{fk}" + if association.macro == :belongs_to || association.macro == :has_one + foreign_keys = Array(association.foreign_key) + foreign_keys.each do |fk| + select << "#{@resource.table_name}.#{fk}" + end end end end From 58315f12aaa04f60280ba52b4974c5b2e79f4a9d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 30 Oct 2025 16:38:22 +0100 Subject: [PATCH 14/15] fix: export csv --- app/controllers/forest_liana/application_controller.rb | 4 +++- app/services/forest_liana/resources_getter.rb | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/forest_liana/application_controller.rb b/app/controllers/forest_liana/application_controller.rb index c8573f87..e4d59b83 100644 --- a/app/controllers/forest_liana/application_controller.rb +++ b/app/controllers/forest_liana/application_controller.rb @@ -229,7 +229,9 @@ def render_csv getter, model included = json['included'] values = field_names_requested.map do |field_name| - if record_attributes[field_name] + if field_name == 'id' + json['data']['id'] + elsif record_attributes[field_name] record_attributes[field_name] elsif record_relationships[field_name] && record_relationships[field_name]['data'] diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index b2802c23..6da7bedd 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -386,6 +386,8 @@ def compute_select_fields next if association && is_active_storage_association?(association) fields.each do |association_path| + next if association_path == 'id' + if ForestLiana::SchemaHelper.is_smart_field?(association.klass, association_path) association.klass.attribute_names.each { |attribute| select << "#{table_name}.#{attribute}" } else From a27b27e3de55aa80a5d165c1208bfa165a6c6cba Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 30 Oct 2025 16:39:41 +0100 Subject: [PATCH 15/15] chore: remove useless association variable --- app/services/forest_liana/resources_getter.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index 6da7bedd..4a91ee7f 100644 --- a/app/services/forest_liana/resources_getter.rb +++ b/app/services/forest_liana/resources_getter.rb @@ -341,8 +341,6 @@ def compute_select_fields @field_names_requested.each do |path| association = get_one_association(path) if association - # Handle :through associations - resolve to the direct association - original_association = association through_chain = [] while association.options[:through] through_chain << association.options[:through]