diff --git a/.rubocop.yml b/.rubocop.yml index 4563d7cf8..e72cb2ff9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -262,6 +262,7 @@ Layout/LineLength: - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb' + - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' 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 d24cae173..5f4072c60 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 @@ -155,6 +155,31 @@ def add_field_validation(name, operator, value = nil) end end + # Enable sorting on a specific field using emulation. + # As for all the emulation method, the field sorting will be done in-memory. + # @param name the name of the field to enable emulation on + # @example + # .emulate_field_sorting('fullName') + def emulate_field_sorting(name) + push_customization { @stack.sort.get_collection(@name).emulate_field_sorting(name) } + end + + # Replace an implementation for the sorting. + # The field sorting will be done by the datasource. + # @param name the name of the field to enable sort + # @param equivalent_sort the sort equivalent + # @example + # .replace_field_sorting( + # 'fullName', + # [ + # { field: 'firstName', ascending: true }, + # { field: 'lastName', ascending: true }, + # ] + # ) + def replace_field_sorting(name, equivalent_sort) + push_customization { @stack.sort.get_collection(@name).replace_field_sorting(name, equivalent_sort) } + end + private def push_customization(&customization) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb index 41b6c989f..b1ef52be3 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb @@ -4,7 +4,7 @@ class DecoratorsStack include ForestAdminDatasourceToolkit::Decorators attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action, :relation, :late_op_emulate, - :early_op_emulate, :validation + :early_op_emulate, :validation, :sort def initialize(datasource) @customizations = [] @@ -22,6 +22,7 @@ def initialize(datasource) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @search = DatasourceDecorator.new(last, Search::SearchCollectionDecorator) + last = @sort = DatasourceDecorator.new(last, Sort::SortCollectionDecorator) last = @action = DatasourceDecorator.new(last, Action::ActionCollectionDecorator) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) last = @validation = DatasourceDecorator.new(last, Validation::ValidationCollectionDecorator) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb new file mode 100644 index 000000000..9f57afd53 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb @@ -0,0 +1,127 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Sort + class SortCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceToolkit::Validations + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Utils + + attr_reader :sorts + + def initialize(child_collection, datasource) + super + @sorts = {} + end + + def emulate_field_sorting(name) + replace_or_emulate_field_sorting(name, nil) + end + + def replace_field_sorting(name, equivalent_sort) + if equivalent_sort.nil? + raise ForestException, 'A new sorting method should be provided to replace field sorting' + end + + replace_or_emulate_field_sorting(name, equivalent_sort) + end + + def list(caller, filter = nil, projection = nil) + child_filter = filter.override(sort: filter.sort&.replace_clauses do |clause| + rewrite_plain_sort_clause(clause) + end) + + if child_filter.sort.nil? || child_filter.sort.none? { |clause| emulated?(clause[:field]) } + return child_collection.list(caller, child_filter, projection) + end + + # Fetch the whole collection, but only with the fields we need to sort + reference_records = child_collection.list(caller, child_filter.override(sort: nil, page: nil), + child_filter.sort.projection.with_pks(self)) + reference_records = child_filter.sort.apply(reference_records) + reference_records = child_filter.page.apply(reference_records) if child_filter.page + + # We now have the information we need to sort by the field + new_filter = Filter.new(condition_tree: ConditionTree::ConditionTreeFactory.match_records(schema, + reference_records)) + + records = child_collection.list(caller, new_filter, projection.with_pks(self)) + records = sort_records(reference_records, records) + + projection.apply(records) + end + + def refine_schema(child_schema) + child_schema[:fields].each do |name, schema| + if schema.type == 'Column' + schema.is_sortable = true if @sorts[name].nil? + child_schema[:fields][name] = schema + end + end + + child_schema + end + + def rewrite_plain_sort_clause(clause) + # Order by is targeting a field on another collection => recurse. + if clause[:field].include?(':') + prefix = clause[:field].split(':')[0] + schema = self.schema[:fields][prefix] + association = datasource.get_collection(schema.foreign_collection) + + return ForestAdminDatasourceToolkit::Components::Query::Sort.new([clause]) + .unnest + .replace_clauses { |sub_clause| association.rewrite_plain_sort_clause(sub_clause) } + .nest(prefix) + end + + # Field that we own: recursively replace using equivalent sort + equivalent_sort = @sorts[clause[:field]] + + if equivalent_sort + equivalent_sort = equivalent_sort.inverse unless clause[:ascending] + + return equivalent_sort.replace_clauses { |sub_clause| rewrite_plain_sort_clause(sub_clause) } + end + + ForestAdminDatasourceToolkit::Components::Query::Sort.new([clause]) + end + + def emulated?(path) + index = path.index(':') + return @sorts[path] if index.nil? + + foreign_collection = schema[:fields][path[0, index]].foreign_collection + association = datasource.get_collection(foreign_collection) + + association.emulated?(path[index + 1, path.length - index - 1]) + end + + private + + def replace_or_emulate_field_sorting(name, equivalent_sort) + FieldValidator.validate(self, name) + @sorts[name] = + equivalent_sort ? ForestAdminDatasourceToolkit::Components::Query::Sort.new(equivalent_sort) : nil + mark_schema_as_dirty + end + + def sort_records(reference_records, records) + position_by_id = {} + sorted = Array.new(records.length) + + reference_records.each_with_index do |record, index| + position_by_id[Record.primary_keys(schema, record).join('|')] = index + end + + records.each do |record| + id = Record.primary_keys(schema, record).join('|') + sorted[position_by_id[id]] = record + end + + sorted + end + end + 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 4ea0412ac..7c41a1e7d 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 @@ -67,7 +67,7 @@ module ForestAdminDatasourceCustomizer schema: { fields: { 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), - 'name' => ColumnSchema.new(column_type: 'String'), + 'name' => ColumnSchema.new(column_type: 'String', is_sortable: true), 'name_in_read_only' => ColumnSchema.new(column_type: 'String', is_read_only: true), 'book' => Relations::OneToOneSchema.new( origin_key: 'author_id', @@ -266,6 +266,14 @@ module ForestAdminDatasourceCustomizer expect(relation_collection.relations['myBooks'].origin_key).to eq('person_id') expect(relation_collection.relations['myBooks'].origin_key_target).to eq('id') end + + it 'does not allow replaceFieldSorting' do + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') + customizer.add_one_to_one_relation('myBookAuthor', 'book_person', { origin_key: 'person_id', origin_key_target: 'id' }) + customizer.replace_field_sorting('myBookAuthor', []) + + expect { @datasource_customizer.datasource({}) }.to raise_error(Exceptions::ValidationError, "🌳🌳🌳 Unexpected field type: 'person.myBookAuthor' (found 'OneToOne' expected 'Column')") + end end context 'when adding external relation' do @@ -353,5 +361,38 @@ module ForestAdminDatasourceCustomizer expect(op_emulate_collection.fields['title']).to eq({ Operators::PRESENT => replacer }) end end + + context 'when using emulate_field_sorting' do + it 'emulate sort on field' do + stack = @datasource_customizer.stack + allow(stack.sort).to receive(:get_collection).with('person').and_return(@datasource_customizer.stack.sort.get_collection('person')) + + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') + customizer.emulate_field_sorting('name') + @datasource_customizer.datasource({}) + + sort_collection = @datasource_customizer.stack.sort.get_collection('person') + + expect(sort_collection.sorts).to have_key('name') + expect(sort_collection.emulated?('name')).to be_nil + end + end + + context 'when using replace_field_sorting' do + it 'replace sort on field' do + stack = @datasource_customizer.stack + allow(stack.sort).to receive(:get_collection).with('person').and_return(@datasource_customizer.stack.sort.get_collection('person')) + + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') + sort_clauses = [{ field: 'name', ascending: true }] + customizer.replace_field_sorting('name', sort_clauses) + @datasource_customizer.datasource({}) + + sort_collection = @datasource_customizer.stack.sort.get_collection('person') + + expect(sort_collection.sorts).to have_key('name') + expect(sort_collection.sorts['name']).to eq(ForestAdminDatasourceToolkit::Components::Query::Sort.new(sort_clauses)) + end + end end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb index 25fb73b35..652b0e7c9 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb @@ -561,13 +561,13 @@ module Relation it 'replaces sorts in emulated many to one into sort by fk' do ascending = @datasource_decorator.get_collection('passport').list( caller, - Filter.new(sort: Sort.new([{ field: 'owner:name', ascending: true }])), + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'owner:name', ascending: true }])), Projection.new(%w[id owner_id owner:name]) ) descending = @datasource_decorator.get_collection('passport').list( caller, - Filter.new(sort: Sort.new([{ field: 'owner:name', ascending: false }])), + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'owner:name', ascending: false }])), Projection.new(%w[id owner_id owner:name]) ) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb new file mode 100644 index 000000000..63ba6a3b7 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator_spec.rb @@ -0,0 +1,232 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminDatasourceCustomizer + module Decorators + module Sort + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Decorators + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Exceptions + + describe SortCollectionDecorator do + include_context 'with caller' + subject(:sort_collection_decorator) { described_class } + + let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } + let(:records) do + [ + { + 'id' => 1, + 'author_id' => 1, + 'author' => { 'id' => 1, 'first_name' => 'Isaac', 'last_name' => 'Asimov' }, + 'title' => 'Foundation' + }, + { + 'id' => 2, + 'author_id' => 2, + 'author' => { 'id' => 2, 'first_name' => 'Edward O.', 'last_name' => 'Thorp' }, + 'title' => 'Beat the dealer' + }, + { + 'id' => 3, + 'author_id' => 3, + 'author' => { 'id' => 3, 'first_name' => 'Roberto', 'last_name' => 'Saviano' }, + 'title' => 'Gomorrah' + } + ] + end + + before do + @collection_book = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'book', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'author_id' => ColumnSchema.new(column_type: 'String'), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_collection: 'person', + foreign_key_target: 'id' + ), + 'title' => ColumnSchema.new(column_type: 'String', is_sortable: false) + } + }, + datasource: datasource + ) + + @collection_person = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'person', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'first_name' => ColumnSchema.new(column_type: 'String'), + 'last_name' => ColumnSchema.new(column_type: 'String', is_sortable: false), + 'book' => Relations::OneToOneSchema.new( + origin_key: 'author_id', + origin_key_target: 'id', + foreign_collection: 'book' + ) + } + }, + datasource: datasource + ) + + allow(@collection_book).to receive(:list) do |_caller, filter, projection| + rows = records + rows = filter.condition_tree.apply(rows, @collection_book, 'Europe/Paris') if filter.condition_tree + rows = filter.sort.apply(rows) if filter.sort + rows = filter.page.apply(rows) if filter.page + + projection.apply(rows) + end + + datasource.add_collection(@collection_book) + datasource.add_collection(@collection_person) + + datasource_decorator = ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator.new(datasource, sort_collection_decorator) + + @decorated_book = datasource_decorator.get_collection('book') + @decorated_person = datasource_decorator.get_collection('person') + end + + it 'emulate_field_sorting() should throw if the field does not exists' do + expect { @decorated_book.emulate_field_sorting('__dontExist') }.to raise_error(ValidationError, "🌳🌳🌳 Column not found: 'book.__dontExist'") + end + + it 'emulate_field_sorting() should throw if the field is not sortable' do + expect { @decorated_book.emulate_field_sorting('author') }.to raise_error(ValidationError, "🌳🌳🌳 Unexpected field type: 'book.author' (found 'ManyToOne' expected 'Column')") + end + + it 'replace_field_sorting() should throw if no equivalent_sort is provided' do + expect { @decorated_book.replace_field_sorting('author_id', nil) }.to raise_error(ForestException, '🌳🌳🌳 A new sorting method should be provided to replace field sorting') + end + + context 'when emulating sort on book.title (no relations)' do + before do + @decorated_book.emulate_field_sorting('title') + end + + it 'schema should be updated' do + schema = @decorated_book.schema[:fields]['title'] + expect(schema.is_sortable).to be_truthy + end + + it 'works in ascending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'title', ascending: true }])), + Projection.new(%w[id title]) + ) + expect(records).to eq([ + { 'id' => 2, 'title' => 'Beat the dealer' }, + { 'id' => 1, 'title' => 'Foundation' }, + { 'id' => 3, 'title' => 'Gomorrah' } + ]) + end + + it 'works in descending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'title', ascending: false }])), + Projection.new(%w[id title]) + ) + expect(records).to eq([ + { 'id' => 3, 'title' => 'Gomorrah' }, + { 'id' => 1, 'title' => 'Foundation' }, + { 'id' => 2, 'title' => 'Beat the dealer' } + ]) + end + + it 'works with pagination' do + records = @decorated_book.list( + caller, + Filter.new(page: Page.new(offset: 2, limit: 1), sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'title', ascending: false }])), + Projection.new(%w[id title]) + ) + expect(records).to eq([{ 'id' => 2, 'title' => 'Beat the dealer' }]) + end + end + + context 'when emulating sort on book.author.last_name (relation)' do + before do + @decorated_person.emulate_field_sorting('last_name') + end + + it 'schema should be updated' do + schema = @decorated_person.schema[:fields]['last_name'] + expect(schema.is_sortable).to be_truthy + end + + it 'works in ascending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'author:last_name', ascending: true }])), + Projection.new(%w[id title author:last_name]) + ) + expect(records).to eq([ + { 'id' => 1, 'title' => 'Foundation', 'author' => { 'last_name' => 'Asimov' } }, + { 'id' => 3, 'title' => 'Gomorrah', 'author' => { 'last_name' => 'Saviano' } }, + { 'id' => 2, 'title' => 'Beat the dealer', 'author' => { 'last_name' => 'Thorp' } } + ]) + end + + it 'works in descending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'author:last_name', ascending: false }])), + Projection.new(%w[id title author:last_name]) + ) + + expect(records).to eq([ + { 'id' => 2, 'title' => 'Beat the dealer', 'author' => { 'last_name' => 'Thorp' } }, + { 'id' => 3, 'title' => 'Gomorrah', 'author' => { 'last_name' => 'Saviano' } }, + { 'id' => 1, 'title' => 'Foundation', 'author' => { 'last_name' => 'Asimov' } } + ]) + end + end + + context 'when telling that sort(book.title) = sort(book.author.last_name)' do + before do + @decorated_book.replace_field_sorting('title', ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'author:last_name', ascending: true }])) + end + + it 'schema should be updated' do + schema = @decorated_book.schema[:fields]['title'] + expect(schema.is_sortable).to be_truthy + end + + it 'works in ascending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'title', ascending: true }])), + Projection.new(%w[id title author:last_name]) + ) + expect(records).to eq([ + { 'id' => 1, 'title' => 'Foundation', 'author' => { 'last_name' => 'Asimov' } }, + { 'id' => 3, 'title' => 'Gomorrah', 'author' => { 'last_name' => 'Saviano' } }, + { 'id' => 2, 'title' => 'Beat the dealer', 'author' => { 'last_name' => 'Thorp' } } + ]) + end + + it 'works in descending order' do + records = @decorated_book.list( + caller, + Filter.new(sort: ForestAdminDatasourceToolkit::Components::Query::Sort.new([{ field: 'title', ascending: false }])), + Projection.new(%w[id title author:last_name]) + ) + expect(records).to eq([ + { 'id' => 2, 'title' => 'Beat the dealer', 'author' => { 'last_name' => 'Thorp' } }, + { 'id' => 3, 'title' => 'Gomorrah', 'author' => { 'last_name' => 'Saviano' } }, + { 'id' => 1, 'title' => 'Foundation', 'author' => { 'last_name' => 'Asimov' } } + ]) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb index 24051f3de..85ef60865 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb @@ -8,6 +8,11 @@ def initialize(offset:, limit:) @offset = offset @limit = limit end + + def apply(records) + end_index = @limit ? @offset + @limit : nil + records[@offset...end_index] + end end end end