From b172e2d8ce0bfd55985063336558f3c07200cc9f Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 7 Oct 2026 10:21:07 +0200 Subject: [PATCH] feat: add disable field filtering option --- .../utils/schema/generator_field.rb | 10 +- .../routes/resources/list_spec.rb | 2 +- .../utils/query_string_parser_spec.rb | 2 +- .../schema/generator_field_one_to_one_spec.rb | 60 +++++++++ .../collection_customizer.rb | 11 ++ .../schema/schema_collection_decorator.rb | 47 +++++++- .../collection_customizer_spec.rb | 52 ++++++++ ...ators_emulate_collection_decorator_spec.rb | 2 +- .../schema_collection_decorator_spec.rb | 85 +++++++++++++ .../schema/relations/many_to_one_schema.rb | 5 +- .../schema/relations/one_to_one_schema.rb | 5 +- .../validations/condition_tree_validator.rb | 17 ++- .../condition_tree_validator_spec.rb | 114 ++++++++++++++++++ 13 files changed, 401 insertions(+), 11 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb index f48624dea..c4a46a1fd 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb @@ -68,6 +68,12 @@ def convert_column_type(type) } end + def relation_filterable?(relation, foreign_collection) + return false if relation.respond_to?(:is_filterable) && relation.is_filterable == false + + foreign_collection_filterable?(foreign_collection) + end + def foreign_collection_filterable?(foreign_collection) foreign_collection.schema[:fields].values.any? do |field| field.type == 'Column' && FrontendFilterable.filterable?(field.filter_operators) @@ -124,7 +130,7 @@ def build_one_to_one_schema(relation, collection, foreign_collection, base_schem { type: key_field.column_type, defaultValue: nil, - isFilterable: foreign_collection_filterable?(foreign_collection), + isFilterable: relation_filterable?(relation, foreign_collection), isPrimaryKey: false, isRequired: false, isReadOnly: relation.is_read_only || key_field.is_read_only, @@ -142,7 +148,7 @@ def build_many_to_one_schema(relation, collection, foreign_collection, base_sche { type: key_field.column_type, defaultValue: key_field.default_value, - isFilterable: foreign_collection_filterable?(foreign_collection), + isFilterable: relation_filterable?(relation, foreign_collection), isPrimaryKey: false, isRequired: key_field.validation.any? { |v| v[:operator] == 'Present' }, isReadOnly: key_field.is_read_only, diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb index c9f4eadfd..de1f9bcbb 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb @@ -204,7 +204,7 @@ module Resources it 'throws an error when the filter operator is not allowed' do args[:params][:filters] = JSON.generate({ field: 'id', operator: 'shorter_than', value: 7 }) - expect { list.handle_request(args) }.to raise_error(ForestException, "The given operator 'shorter_than' is not supported by the column: 'id'. The column is not filterable") + expect { list.handle_request(args) }.to raise_error(ForestException, /\AThe given operator 'shorter_than' is not supported by the column: 'id'\. The allowed types are: \w/) end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb index 9675b4862..30325b65d 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb @@ -1227,7 +1227,7 @@ module Utils described_class.parse_condition_tree(collection_category, args) end.to raise_error( ForestAdminDatasourceToolkit::Exceptions::ValidationError, - "The given operator 'not_equal' is not supported by the column: 'id'. The column is not filterable" + "The given operator 'not_equal' is not supported by the column: 'id'. The allowed types are: equal" ) end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb index 7e3b56b87..8dfebe150 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb @@ -117,6 +117,66 @@ module Schema ) end end + + context 'when the relations target a filterable collection' do + let(:datasource) do + datasource = Datasource.new + collection_book = Collection.new(datasource, 'Book') + collection_book.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: ['equal']), + 'author_id' => ColumnSchema.new(column_type: 'String', filter_operators: ['equal']), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_key_target: 'id', + foreign_collection: 'Person', + is_filterable: relations_filterable + ) + } + ) + + collection_person = Collection.new(datasource, 'Person') + collection_person.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'String', is_primary_key: true, filter_operators: ['equal']), + 'book' => Relations::OneToOneSchema.new( + origin_key: 'author_id', + origin_key_target: 'id', + foreign_collection: 'Book', + is_filterable: relations_filterable + ) + } + ) + + datasource.add_collection(collection_book) + datasource.add_collection(collection_person) + datasource + end + + context 'when the relations are filterable' do + let(:relations_filterable) { true } + + it 'marks the many to one as filterable' do + expect(described_class.build_schema(datasource.get_collection('Book'), 'author')[:isFilterable]).to be true + end + + it 'marks the one to one as filterable' do + expect(described_class.build_schema(datasource.get_collection('Person'), 'book')[:isFilterable]).to be true + end + end + + context 'when the relations are not filterable' do + let(:relations_filterable) { false } + + it 'marks the many to one as not filterable' do + expect(described_class.build_schema(datasource.get_collection('Book'), 'author')[:isFilterable]).to be false + end + + it 'marks the one to one as not filterable' do + expect(described_class.build_schema(datasource.get_collection('Person'), 'book')[:isFilterable]).to be false + end + end + end end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb index bce6492de..6aa07cb29 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb @@ -67,6 +67,17 @@ def disable_search push_customization { @stack.search.get_collection(@name).disable_search } end + # Disable filtering on a column, or on a many to one / one to one relation, for the end-user. + # Filters coming from the UI or the API (including UI segments and saved views) are rejected. + # Customizations still filter on it: a column can be the key of a custom relation, and a + # relation can be used by code segments and search. + # Example: + # collection.disable_field_filtering('reference_code') + # collection.disable_field_filtering('author') + def disable_field_filtering(name) + push_customization { @stack.schema.get_collection(@name).disable_field_filtering(name) } + end + def add_field(name, definition) push_customization do collection_before_relations = @stack.early_computed.get_collection(@name) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb index 52a7405c2..91a8c0443 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb @@ -2,9 +2,14 @@ module ForestAdminDatasourceCustomizer module Decorators module Schema class SchemaCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Exceptions + + FILTER_DISABLEABLE_TYPES = %w[Column ManyToOne OneToOne].freeze + def initialize(child_collection, datasource) super @schema_override = {} + @unfilterable_fields = Set.new end def override_schema(value) @@ -12,8 +17,48 @@ def override_schema(value) mark_schema_as_dirty end + def disable_field_filtering(name) + field = child_collection.schema[:fields][name] + + raise ValidationError, "Field not found: '#{self.name}.#{name}'" if field.nil? + + unless FILTER_DISABLEABLE_TYPES.include?(field.type) + raise ValidationError, + "Unexpected field type: '#{self.name}.#{name}' " \ + "(found '#{field.type}' expected 'Column', 'ManyToOne' or 'OneToOne')" + end + + if field.type == 'Column' && field.is_primary_key + raise ValidationError, "Cannot disable filtering on primary key '#{self.name}.#{name}'" + end + + @unfilterable_fields.add(name) + mark_schema_as_dirty + end + def refine_schema(sub_schema) - sub_schema.merge(@schema_override) + schema = sub_schema.merge(@schema_override) + return schema if @unfilterable_fields.empty? + + schema[:fields] = schema[:fields].dup + @unfilterable_fields.each { |name| schema[:fields][name] = unfilterable_copy(schema[:fields][name]) } + + schema + end + + private + + # Field schemas are shared with the decorators below, which must keep filtering on this field. + def unfilterable_copy(field) + copy = field.dup + + if field.type == 'Column' + copy.filter_operators = [] + else + copy.is_filterable = false + end + + copy end end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb index bf3ff27fc..fdd0c201c 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/collection_customizer_spec.rb @@ -321,6 +321,58 @@ module ForestAdminDatasourceCustomizer end end + context 'when using disable_field_filtering' do + let(:customizer) { described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') } + + it 'removes the operators of a column from the published schema only' do + customizer.disable_field_filtering('title') + published_book = @datasource_customizer.datasource({}).get_collection('book') + + expect(published_book.schema[:fields]['title'].filter_operators).to eq([]) + expect(@datasource_customizer.stack.segment.get_collection('book').schema[:fields]['title'].filter_operators) + .to include(Operators::EQUAL) + end + + it 'marks a relation as not filterable in the published schema' do + customizer.disable_field_filtering('author') + published_book = @datasource_customizer.datasource({}).get_collection('book') + + expect(published_book.schema[:fields]['author'].is_filterable).to be false + end + + it 'rejects an end-user filter through the disabled relation' do + customizer.disable_field_filtering('author') + published_book = @datasource_customizer.datasource({}).get_collection('book') + condition_tree = Nodes::ConditionTreeLeaf.new('author:id', Operators::EQUAL, 1) + + expect do + ForestAdminDatasourceToolkit::Validations::ConditionTreeValidator.validate(condition_tree, published_book) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ValidationError, "The relation 'book.author' is not filterable") + end + + it 'still lets a code segment filter through the disabled relation' do + segment_tree = Nodes::ConditionTreeLeaf.new('author:id', Operators::EQUAL, 1) + customizer.disable_field_filtering('author').add_segment('by_author') { segment_tree } + published_book = @datasource_customizer.datasource({}).get_collection('book') + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(segment: 'by_author') + + refined = @datasource_customizer.stack.segment.get_collection('book').refine_filter(caller, filter) + + expect(published_book.schema[:fields]['author'].is_filterable).to be false + expect(refined.condition_tree).to eq(segment_tree) + expect(refined.segment).to be_nil + end + + it 'raises on a many to many when the customizations are applied' do + customizer.disable_field_filtering('persons') + + expect { @datasource_customizer.datasource({}) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ValidationError, + "Unexpected field type: 'book.persons' (found 'ManyToMany' expected 'Column', 'ManyToOne' or 'OneToOne')" + ) + end + end + context 'when adding a relation' do it 'adds a many to one' do customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator_spec.rb index e995f39ad..18533245b 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/operators_emulate/operators_emulate_collection_decorator_spec.rb @@ -125,7 +125,7 @@ module OperatorsEmulate expect do @decorated_book.list(caller, filter, projection) - end.to raise_error(Exceptions::ForestException, "The given operator 'like' is not supported by the column: 'title'. The column is not filterable") + end.to raise_error(Exceptions::ForestException, "The given operator 'like' is not supported by the column: 'title'. The allowed types are: starts_with") end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb index a4bcce012..05883202f 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb @@ -3,6 +3,9 @@ module ForestAdminDatasourceCustomizer module Decorators module Schema + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Exceptions + describe SchemaCollectionDecorator do subject(:schema_collection_decorator) { described_class } @@ -16,6 +19,88 @@ module Schema expect(collection.schema[:countable]).to be true expect(decorator.schema[:countable]).to be false end + + describe 'disable_field_filtering' do + let(:collection) do + collection = ForestAdminDatasourceToolkit::Collection.new(nil, 'book') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: ['equal']), + 'author_id' => ColumnSchema.new(column_type: 'Number', filter_operators: %w[equal in]), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', foreign_key_target: 'id', foreign_collection: 'person' + ), + 'cover' => Relations::OneToOneSchema.new( + origin_key: 'book_id', origin_key_target: 'id', foreign_collection: 'cover' + ), + 'reviews' => Relations::OneToManySchema.new( + origin_key: 'book_id', origin_key_target: 'id', foreign_collection: 'review' + ), + 'tags' => Relations::ManyToManySchema.new( + origin_key: 'book_id', origin_key_target: 'id', foreign_key: 'tag_id', foreign_key_target: 'id', + foreign_collection: 'tag', through_collection: 'book_tag' + ) + } + ) + collection + end + let(:decorator) { schema_collection_decorator.new(collection, nil) } + + it 'removes the operators of a column without touching the child collection' do + decorator.disable_field_filtering('author_id') + + expect(decorator.schema[:fields]['author_id'].filter_operators).to eq([]) + expect(collection.schema[:fields]['author_id'].filter_operators).to eq(%w[equal in]) + end + + it 'marks a many to one as not filterable without touching the child collection' do + decorator.disable_field_filtering('author') + + expect(decorator.schema[:fields]['author'].is_filterable).to be false + expect(decorator.schema[:fields]['author'].foreign_key).to eq('author_id') + expect(collection.schema[:fields]['author'].is_filterable).to be true + end + + it 'marks a one to one as not filterable' do + decorator.disable_field_filtering('cover') + + expect(decorator.schema[:fields]['cover'].is_filterable).to be false + end + + it 'keeps the schema overrides' do + decorator.override_schema(countable: false) + decorator.disable_field_filtering('author_id') + + expect(decorator.schema[:countable]).to be false + expect(decorator.schema[:fields]['author_id'].filter_operators).to eq([]) + end + + it 'raises on a primary key' do + expect { decorator.disable_field_filtering('id') }.to raise_error( + ValidationError, "Cannot disable filtering on primary key 'book.id'" + ) + end + + it 'raises on a one to many' do + expect { decorator.disable_field_filtering('reviews') }.to raise_error( + ValidationError, + "Unexpected field type: 'book.reviews' (found 'OneToMany' expected 'Column', 'ManyToOne' or 'OneToOne')" + ) + end + + it 'raises on a many to many' do + expect { decorator.disable_field_filtering('tags') }.to raise_error( + ValidationError, + "Unexpected field type: 'book.tags' (found 'ManyToMany' expected 'Column', 'ManyToOne' or 'OneToOne')" + ) + end + + it 'raises on an unknown field' do + expect { decorator.disable_field_filtering('unknown') }.to raise_error( + ValidationError, "Field not found: 'book.unknown'" + ) + end + end end end end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_one_schema.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_one_schema.rb index 7dd64fba1..f5969d53f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_one_schema.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_one_schema.rb @@ -2,10 +2,11 @@ module ForestAdminDatasourceToolkit module Schema module Relations class ManyToOneSchema < RelationSchema - attr_accessor :foreign_key, :foreign_key_target + attr_accessor :foreign_key, :foreign_key_target, :is_filterable - def initialize(foreign_key:, foreign_key_target:, foreign_collection:, is_read_only: false) + def initialize(foreign_key:, foreign_key_target:, foreign_collection:, is_read_only: false, is_filterable: true) super(foreign_collection, 'ManyToOne', is_read_only: is_read_only) + @is_filterable = is_filterable @foreign_key = foreign_key @foreign_key_target = foreign_key_target end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/one_to_one_schema.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/one_to_one_schema.rb index c68716e1e..cc78dcd49 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/one_to_one_schema.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/one_to_one_schema.rb @@ -2,10 +2,11 @@ module ForestAdminDatasourceToolkit module Schema module Relations class OneToOneSchema < RelationSchema - attr_accessor :origin_key, :origin_key_target + attr_accessor :origin_key, :origin_key_target, :is_filterable - def initialize(origin_key:, origin_key_target:, foreign_collection:, is_read_only: false) + def initialize(origin_key:, origin_key_target:, foreign_collection:, is_read_only: false, is_filterable: true) super(foreign_collection, 'OneToOne', is_read_only: is_read_only) + @is_filterable = is_filterable @origin_key = origin_key @origin_key_target = origin_key_target end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator.rb index 2c2d08226..9193b976d 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator.rb @@ -32,6 +32,7 @@ def self.validate_branch(branch, collection) def self.validate_leaf(leaf, collection) field_schema = Utils::Collection.get_field_schema(collection, leaf.field) + throw_if_relation_not_filterable(leaf, collection) throw_if_operator_not_allowed_with_column(leaf, field_schema) throw_if_value_not_allowed_with_operator(leaf, field_schema) throw_if_operator_not_allowed_with_column_type(leaf, field_schema) @@ -40,13 +41,27 @@ def self.validate_leaf(leaf, collection) nil end + def self.throw_if_relation_not_filterable(leaf, collection) + current = collection + + leaf.field.split(':')[0...-1].each do |relation_name| + relation = current.schema[:fields][relation_name] + + if relation.respond_to?(:is_filterable) && relation.is_filterable == false + raise Exceptions::ValidationError, "The relation '#{current.name}.#{relation_name}' is not filterable" + end + + current = current.datasource.get_collection(relation.foreign_collection) + end + end + def self.throw_if_operator_not_allowed_with_column(leaf, column_schema) operators = column_schema.filter_operators return if operators.include?(leaf.operator) raise Exceptions::ValidationError, "The given operator '#{leaf.operator}' is not supported by the column: '#{leaf.field}'." \ - "#{operators.empty? ? " The allowed types are: #{operators.join(",")}" : " The column is not filterable"}" + "#{operators.empty? ? " The column is not filterable" : " The allowed types are: #{operators.join(",")}"}" end def self.throw_if_value_not_allowed_with_operator(leaf, column_schema) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator/condition_tree_validator_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator/condition_tree_validator_spec.rb index 2baf9d774..761368374 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator/condition_tree_validator_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/condition_tree_validator/condition_tree_validator_spec.rb @@ -167,6 +167,32 @@ module Validations end end + context 'when the column has no operator' do + it 'raises that the column is not filterable' do + collection = build_collection({ schema: { fields: { 'target' => ColumnSchema.new(column_type: 'String') } } }) + condition_tree = ConditionTreeLeaf.new('target', Operators::EQUAL, 'value') + + expect { described_class.validate(condition_tree, collection) }.to raise_error( + Exceptions::ValidationError, + "The given operator 'equal' is not supported by the column: 'target'. The column is not filterable" + ) + end + end + + context 'when the column does not support the operator' do + it 'raises with the allowed operators' do + collection = build_collection( + { schema: { fields: { 'target' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL, Operators::IN]) } } } + ) + condition_tree = ConditionTreeLeaf.new('target', Operators::CONTAINS, 'value') + + expect { described_class.validate(condition_tree, collection) }.to raise_error( + Exceptions::ValidationError, + "The given operator 'contains' is not supported by the column: 'target'. The allowed types are: equal,in" + ) + end + end + context 'when the field has an operator incompatible with the schema type' do it 'raise an error' do collection = build_collection({ @@ -228,6 +254,94 @@ module Validations ) end end + + context 'when the condition goes through a relation that is not filterable' do + let(:datasource) do + build_datasource_with_collections( + [ + build_collection( + name: 'book', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'String', is_primary_key: true), + 'author_id' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL]), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_collection: 'person', + foreign_key_target: 'id', + is_filterable: false + ), + 'editor' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_collection: 'person', + foreign_key_target: 'id' + ) + } + } + ), + build_collection( + name: 'person', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL]), + 'passport' => Relations::OneToOneSchema.new( + origin_key: 'person_id', + origin_key_target: 'id', + foreign_collection: 'passport', + is_filterable: false + ) + } + } + ), + build_collection( + name: 'passport', + schema: { + fields: { + 'person_id' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL]) + } + } + ) + ] + ) + end + let(:book) { datasource.get_collection('book') } + + it 'raises on a many to one' do + condition_tree = ConditionTreeLeaf.new('author:id', Operators::EQUAL, '1') + + expect { described_class.validate(condition_tree, book) }.to raise_error( + Exceptions::ValidationError, "The relation 'book.author' is not filterable" + ) + end + + it 'raises on a one to one reached through a filterable relation' do + condition_tree = ConditionTreeLeaf.new('editor:passport:person_id', Operators::EQUAL, '1') + + expect { described_class.validate(condition_tree, book) }.to raise_error( + Exceptions::ValidationError, "The relation 'person.passport' is not filterable" + ) + end + + it 'raises when the leaf is nested in a branch' do + condition_tree = ConditionTreeBranch.new( + 'Or', + [ + ConditionTreeLeaf.new('editor:id', Operators::EQUAL, '1'), + ConditionTreeLeaf.new('author:id', Operators::EQUAL, '1') + ] + ) + + expect { described_class.validate(condition_tree, book) }.to raise_error( + Exceptions::ValidationError, "The relation 'book.author' is not filterable" + ) + end + + it 'still accepts the foreign key column of the relation' do + condition_tree = ConditionTreeLeaf.new('author_id', Operators::EQUAL, '1') + + expect(described_class.validate(condition_tree, book)).to be_nil + end + end end end end