Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

Constructing SchemaCollectionDecorator raises NameError for Set when set has not already been loaded, preventing the customizer from booting. This file does not require the standard-library set, so require it before initializing @unfilterable_fields.

Suggested change
require 'set'
module ForestAdminDatasourceCustomizer
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb around line 1:

Constructing `SchemaCollectionDecorator` raises `NameError` for `Set` when `set` has not already been loaded, preventing the customizer from booting. This file does not require the standard-library `set`, so require it before initializing `@unfilterable_fields`.

Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,63 @@ 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)
@schema_override.merge!(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): initialize [qlty:function-parameters]

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): initialize [qlty:function-parameters]

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
Expand Down
Loading
Loading