diff --git a/Gemfile.lock b/Gemfile.lock index e69de29b..6606de80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -0,0 +1,298 @@ +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-21 + 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 diff --git a/app/controllers/forest_liana/application_controller.rb b/app/controllers/forest_liana/application_controller.rb index fbd1d1e2..e4d59b83 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| @@ -227,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/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/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 diff --git a/app/services/forest_liana/resources_getter.rb b/app/services/forest_liana/resources_getter.rb index 9ded9c6c..4a91ee7f 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 @@ -94,17 +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) - @field_names_requested << association.foreign_key - @optional_includes << association.name - end - - association.name - end + includes = associations_has_one.map(&:name) includes_for_smart_search = [] if @collection && @collection.search_fields @@ -119,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 @@ -167,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 @@ -285,17 +289,91 @@ 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 + + # 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 + + (@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 + through_chain = [] while association.options[:through] + through_chain << association.options[:through] association = get_one_association(association.options[:through]) end - if SchemaUtils.polymorphic?(association) - select << "#{@resource.table_name}.#{association.foreign_type}" + # Skip ActiveStorage associations - already processed above + next if is_active_storage_association?(association) + + # 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}" + end + end end - select << "#{@resource.table_name}.#{association.foreign_key}" end fields = @params[:fields]&.[](path)&.split(',') @@ -303,7 +381,11 @@ 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| + 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 @@ -319,9 +401,20 @@ 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 + + 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/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 diff --git a/app/services/forest_liana/utils/context_variables_injector.rb b/app/services/forest_liana/utils/context_variables_injector.rb index 3469330e..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" 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/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 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 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..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