From 802a524934b6a0d5dee2cf35622031b77b35e53a Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jan 2024 15:11:00 +0100 Subject: [PATCH 01/33] feat(relation): add new relation decorator --- .../decorators/decorators_stack.rb | 3 +++ .../decorators/relation/relation_collection.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb 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 a72970811..b36ee734a 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 @@ -13,6 +13,9 @@ def initialize(datasource) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @early_computed = DatasourceDecorator.new(last, Computed::ComputeCollectionDecorator) last = @late_computed = DatasourceDecorator.new(last, Computed::ComputeCollectionDecorator) + last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) + last = @relation = DatasourceDecorator.new(last, Relation::RelationCollectionDecorator) + last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @search = DatasourceDecorator.new(last, Search::SearchCollectionDecorator) last = @action = DatasourceDecorator.new(last, Action::ActionCollectionDecorator) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb new file mode 100644 index 000000000..90cd0d24f --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Relation + class RelationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Utils + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + + def initialize(child_collection, datasource) + super + @relations = {} + end + + def add_relation(name, partial_joint); end + end + end + end +end From dc91d7ba91c754cf7fb80a612db5322fac58b777 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jan 2024 15:12:26 +0100 Subject: [PATCH 02/33] feat(customizer): add new methods to handle relation --- .../collection_customizer.rb | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) 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 1e6a4e794..616c6ca7d 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 @@ -58,10 +58,83 @@ def add_field(name, definition) ) end + # Add a many to one relation to the collection + # @param name name of the new relation + # @param foreign_collection name of the targeted collection + # @param options extra information about the relation + # @example + # books.add_many_to_one_relation('my_author', 'persons', { foreign_key: 'author_id' }) + def add_many_to_one_relation(name, foreign_collection, options = {}) + push_relation(name, { + type: 'ManyToOne', + foreign_collection: foreign_collection, + foreign_key: options[:foreign_key], + foreign_key_target: options[:foreign_key_target] + }) + end + + # Add a one to many relation to the collection + # @param name name of the new relation + # @param foreign_collection name of the targeted collection + # @param options extra information about the relation + # @example + # persons.add_one_to_many_relation('written_books', 'books', { origin_key: 'author_id' }) + def add_one_to_many_relation(name, foreign_collection, options = {}) + push_relation(name, { + type: 'OneToMany', + foreign_collection: foreign_collection, + origin_key: options[:origin_key], + origin_key_target: options[:origin_key_target] + }) + end + + # Add a one to one relation to the collection + # @param name name of the new relation + # @param foreign_collection name of the targeted collection + # @param options extra information about the relation + # @example + # persons.add_one_to_one_relation('best_friend', 'persons', { origin_key: 'best_friend_id' }) + def add_one_to_one_relation(name, foreign_collection, options = {}) + push_relation(name, { + type: 'OneToOne', + foreign_collection: foreign_collection, + origin_key: options[:origin_key], + origin_key_target: options[:origin_key_target] + }) + end + + # Add a many to many relation to the collection + # @param name name of the new relation + # @param foreign_collection name of the targeted collection + # @param through_collection name of the intermediary collection + # @param options extra information about the relation + # @example + # dvds.add_many_to_many_relation('rentals_of_this_dvd', 'rentals', 'dvd_rentals', { + # origin_key: 'dvd_id', + # foreign_key: 'rental_id' + # }) + def add_many_to_many_relation(name, foreign_collection, through_collection, options = {}) + push_relation(name, { + type: 'ManyToMany', + foreign_collection: foreign_collection, + through_collection: through_collection, + origin_key: options[:origin_key], + origin_key_target: options[:origin_key_target], + foreign_key: options[:foreign_key], + foreign_key_target: options[:foreign_key_target] + }) + end + private def push_customization(customization) @stack.queue_customization(customization) end + + def push_relation(name, definition) + push_customization( + proc { @stack.relation.get_collection(@name).add_relation(name, definition) } + ) + end end end From 58fbd51073b94d8c088649eae2c353db56763ac6 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 26 Jan 2024 14:54:22 +0100 Subject: [PATCH 03/33] feat: add sort utils --- .../components/query/sort.rb | 62 +++++++++++++++++++ .../components/query/sort/sort_factory.rb | 16 +++++ 2 files changed, 78 insertions(+) create mode 100644 packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb create mode 100644 packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb new file mode 100644 index 000000000..974c2893d --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -0,0 +1,62 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Sort < Array + def projection + Projection.new(map(&:field)) + end + + def replace_clauses(...) + self.class.new( + map(...) + .reduce(self.class.new) do |memo, cb_result| + return memo.union(cb_result) if cb_result.is_a?(self.class) + + memo.union([cb_result]) + end + ) + end + + def nest(prefix) + if prefix&.length + self.class.new(map do |ob| + { field: "#{prefix}:#{ob[:field]}", ascending: ob[:ascending] } + end) + else + self + end + end + + def inverse + self.class.new(map { |ob| { field: ob[:field], ascending: !ob[:ascending] } }) + end + + def unnest + prefix = first[:field].split(':')[0] + raise 'Cannot unnest sort.' unless all? { |ob| ob[:field].start_with?(prefix) } + + self.class.new(map do |ob| + { field: ob[:field][prefix.length + 1, ob[:field].length - prefix.length - 1], + ascending: ob[:ascending] } + end) + end + + def apply(records) + records.sort do |a, b| + (0..length).each do |i| + field = self[i][:field] + ascending = self[i][:ascending] + value_on_a = ForestAdminDatasourceToolkit::Utils::Record.field_value(a, field) + value_on_b = ForestAdminDatasourceToolkit::Utils::Record.field_value(b, field) + + return ascending ? -1 : 1 if value_on_a < value_on_b + return ascending ? 1 : -1 if value_on_a > value_on_b + end + + 0 + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb new file mode 100644 index 000000000..a1cb2e816 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb @@ -0,0 +1,16 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + module Sort + class SortFactory + def self.by_primary_keys(collection) + Sort.new( + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) + .map { |pk| { field: pk, ascending: true } } + ) + end + end + end + end + end +end From 367be5c0674852b4a02d4143f7a051959d31d201 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 26 Jan 2024 14:56:38 +0100 Subject: [PATCH 04/33] chore: add new sort validator --- .../validations/sort_validator.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb new file mode 100644 index 000000000..0a4839d36 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb @@ -0,0 +1,15 @@ +module ForestAdminDatasourceToolkit + module Validations + class SortValidator + def self.validate(collection, sort) + sort&.each do |s| + FieldValidator.validate(collection, s[:field]) + unless s[:ascending].is_a?(TrueClass) || s[:ascending].is_a?(FalseClass) + raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, + "Invalid sort.ascending value: #{s[:ascending]}" + end + end + end + end + end +end From a6d9ee05a0ba8a5d7c81f4f73fcdff919d65443f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 26 Jan 2024 14:57:47 +0100 Subject: [PATCH 05/33] chore: add parse_sort method on query_string_parser --- .../forest_admin_agent/utils/query_string_parser.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb index 9f42b9535..04850e79e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb @@ -104,6 +104,19 @@ def self.parse_search_extended(args) extended != '0' end + + def self.parse_sort(collection, context) + sort_string = context.request.params[:sort] + + return Sort::SortFactory.by_primary_keys(collection) unless sort_string + + sort = Sort.new( + field: sort_string.gsub(/^-/, '').tr('.', ':'), + ascending: !sort_string.start_with?('-') + ) + + SortValidator.validate(collection, sort) + end end end end From 200bd16ad5c63d134d26909534c684778ebdebda Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 29 Jan 2024 17:01:51 +0100 Subject: [PATCH 06/33] fix: parse_sort on query_string_parser --- .../utils/query_string_parser.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb index 04850e79e..2abfb06ff 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb @@ -105,17 +105,16 @@ def self.parse_search_extended(args) extended != '0' end - def self.parse_sort(collection, context) - sort_string = context.request.params[:sort] + def self.parse_sort(collection, args) + sort_string = args.dig(:params, :sort) - return Sort::SortFactory.by_primary_keys(collection) unless sort_string + return SortUtils::SortFactory.by_primary_keys(collection) unless sort_string - sort = Sort.new( - field: sort_string.gsub(/^-/, '').tr('.', ':'), - ascending: !sort_string.start_with?('-') - ) + sort = Sort.new([ + { field: sort_string.gsub(/^-/, '').tr('.', ':'), ascending: !sort_string.start_with?('-') } + ]) - SortValidator.validate(collection, sort) + ForestAdminDatasourceToolkit::Validations::SortValidator.validate(collection, sort) end end end From 71b5f1fcf6f3718c099a0aab334646a9afd46453 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 29 Jan 2024 17:02:54 +0100 Subject: [PATCH 07/33] chore: add sort test on query_string_parser --- .../utils/query_string_parser_spec.rb | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) 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 22178e47f..2f30a4c3c 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 @@ -439,6 +439,57 @@ module Utils expect(described_class.parse_search_extended(args)).to be(false) end end + + describe 'parse_sort' do + let(:collection_user) do + datasource = Datasource.new + collection_user = Collection.new(datasource, 'User') + collection_user.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, + filter_operators: [Operators::EQUAL]), + 'name' => ColumnSchema.new(column_type: 'String') + } + ) + + datasource.add_collection(collection_user) + + return collection_user + end + + it 'sorts by pk ascending when not sort is given' do + args = { + params: {} + } + + expect(described_class.parse_sort(collection_user, args)).to eq([{ field: 'id', ascending: true }]) + end + + it 'sorts by the request field and order when given' do + args = { + params: { + sort: '-name' + } + } + + expect(described_class.parse_sort(collection_user, args)).to eq([{ field: 'name', ascending: false }]) + end + + it 'throws a ValidationError when the requested sort is invalid' do + args = { + params: { + sort: '-fieldThatDoNotExist' + } + } + + expect do + described_class.parse_sort(collection_user, args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + "🌳🌳🌳 Column not found: 'User.fieldThatDoNotExist'" + ) + end + end end end end From 752be9c81c59e25975d3bb346adfa03abfe6d19e Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 29 Jan 2024 17:10:14 +0100 Subject: [PATCH 08/33] fix: sort and add some tests --- .../components/query/sort.rb | 7 +- .../components/query/sort_spec.rb | 84 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index 974c2893d..c05ba35be 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -3,12 +3,12 @@ module Components module Query class Sort < Array def projection - Projection.new(map(&:field)) + Projection.new(map { |clause| clause[:field] }) end def replace_clauses(...) self.class.new( - map(...) + map(&block) .reduce(self.class.new) do |memo, cb_result| return memo.union(cb_result) if cb_result.is_a?(self.class) @@ -33,7 +33,7 @@ def inverse def unnest prefix = first[:field].split(':')[0] - raise 'Cannot unnest sort.' unless all? { |ob| ob[:field].start_with?(prefix) } + raise 'Cannot unnest sort_utils.' unless all? { |ob| ob[:field].start_with?(prefix) } self.class.new(map do |ob| { field: ob[:field][prefix.length + 1, ob[:field].length - prefix.length - 1], @@ -46,6 +46,7 @@ def apply(records) (0..length).each do |i| field = self[i][:field] ascending = self[i][:ascending] + value_on_a = ForestAdminDatasourceToolkit::Utils::Record.field_value(a, field) value_on_b = ForestAdminDatasourceToolkit::Utils::Record.field_value(b, field) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb new file mode 100644 index 000000000..e8d11cd5b --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Query + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query::SortUtils + describe Sort do + let(:sort) { described_class.new([{ field: 'column1', ascending: true }, { field: 'column2', ascending: false }]) } + + it 'projection should work' do + expect(sort.projection).to eq(['column1', 'column2']) + end + + # test('apply should sort records', () => { + # const records = [ + # { column1: 2, column2: 2 }, + # { column1: 1, column2: 1 }, + # { column1: 1, column2: 1 }, + # { column1: 1, column2: 2 }, + # { column1: 2, column2: 1 }, + # ]; + # + # expect(sort.apply(records)).toStrictEqual([ + # { column1: 1, column2: 2 }, + # { column1: 1, column2: 1 }, + # { column1: 1, column2: 1 }, + # { column1: 2, column2: 2 }, + # { column1: 2, column2: 1 }, + # ]); + # }); + it('apply should sort records') do + records = [ + { column1: 2, column2: 2 }, + { column1: 1, column2: 1 }, + { column1: 1, column2: 1 }, + { column1: 1, column2: 2 }, + { column1: 2, column2: 1 } + ] + expect(sort.apply(records)).to eq([ + { column1: 1, column2: 2 }, + { column1: 1, column2: 1 }, + { column1: 1, column2: 1 }, + { column1: 2, column2: 2 }, + { column1: 2, column2: 1 } + ]) + end + + context 'when replace_clauses is called' do + it 'works when returning a single clause' do + expect(sort.replace_clauses { |clause| { field: clause[:field], ascending: !clause[:ascending] } }).to eq([ + { field: 'column1', ascending: false }, + { field: 'column2', ascending: true } + ]) + end + end + + context 'when nest is called' do + it 'does nothing with nil' do + expect(sort.nest(nil)).to eq(sort) + end + + it 'works with a prefix' do + expect(sort.nest('prefix')).to eq([ + { field: 'prefix:column1', ascending: true }, + { field: 'prefix:column2', ascending: false } + ]) + end + end + + context 'when unnest is called' do + it 'sorts' do + expect(sort.nest('prefix').unnest).to eq(sort) + end + + it 'fails when no common prefix exists' do + expect { sort.unnest }.to raise_error('Cannot unnest sort.') + end + end + end + end + end +end From beb4b48471435d47891afdd4cd56a36e7d92050f Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 29 Jan 2024 17:14:40 +0100 Subject: [PATCH 09/33] chore: add tests on sort_validator --- .../validations/sort_validator.rb | 2 +- .../validations/sort_validator_spec.rb | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/sort_validator_spec.rb diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb index 0a4839d36..b2d2fb965 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/sort_validator.rb @@ -6,7 +6,7 @@ def self.validate(collection, sort) FieldValidator.validate(collection, s[:field]) unless s[:ascending].is_a?(TrueClass) || s[:ascending].is_a?(FalseClass) raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, - "Invalid sort.ascending value: #{s[:ascending]}" + "Invalid sort_utils.ascending value: #{s[:ascending]}" end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/sort_validator_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/sort_validator_spec.rb new file mode 100644 index 000000000..b13be275e --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/validations/sort_validator_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Validations + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query + describe SortValidator do + let(:collection_user) do + datasource = Datasource.new + collection = Collection.new(datasource, 'User') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, + filter_operators: [ConditionTree::Operators::EQUAL]) + } + ) + + return collection + end + + it('does not throw if the field exist on the collection') do + expect { described_class.validate(collection_user, Sort.new([{ field: 'id', ascending: true }])) }.not_to raise_error + end + + it('throws if the field does not exist on the collection') do + expect { described_class.validate(collection_user, Sort.new([{ field: '__no__such__field', ascending: true }])) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ValidationError, "🌳🌳🌳 Column not found: 'User.__no__such__field'" + ) + end + + context 'when parameter is a boolean' do + it('does not throw if the ascending parameter is boolean') do + expect { described_class.validate(collection_user, Sort.new([{ field: 'id', ascending: true }])) }.not_to raise_error + end + + it('throws if the ascending parameter is not boolean') do + expect { described_class.validate(collection_user, Sort.new([{ field: 'id', ascending: 42 }])) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ValidationError, '🌳🌳🌳 Invalid sort_utils.ascending value: 42' + ) + end + end + end + end +end From 4d849a5344bf5daaf29d87d1a55cdbb41552dedd Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 29 Jan 2024 17:14:58 +0100 Subject: [PATCH 10/33] fix: sort_factory module --- .../components/query/{sort => sort_utils}/sort_factory.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/{sort => sort_utils}/sort_factory.rb (80%) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb similarity index 80% rename from packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb rename to packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb index a1cb2e816..78f5cce7e 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort/sort_factory.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb @@ -1,10 +1,10 @@ module ForestAdminDatasourceToolkit module Components module Query - module Sort + module SortUtils class SortFactory def self.by_primary_keys(collection) - Sort.new( + ForestAdminDatasourceToolkit::Components::Query::Sort.new( ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) .map { |pk| { field: pk, ascending: true } } ) From c84befeff17c2899dedb4ebc2d9cba6d09ec2db0 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 30 Jan 2024 11:42:58 +0100 Subject: [PATCH 11/33] fix(sort): apply function --- .../components/query/sort.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index c05ba35be..eb0f2a10b 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -43,18 +43,20 @@ def unnest def apply(records) records.sort do |a, b| - (0..length).each do |i| + comparison = 0 + (0..length - 1).each do |i| field = self[i][:field] ascending = self[i][:ascending] + break unless comp.zero? value_on_a = ForestAdminDatasourceToolkit::Utils::Record.field_value(a, field) value_on_b = ForestAdminDatasourceToolkit::Utils::Record.field_value(b, field) - return ascending ? -1 : 1 if value_on_a < value_on_b - return ascending ? 1 : -1 if value_on_a > value_on_b + comparison = value_on_a <=> value_on_b + comparison *= -1 unless ascending end - 0 + comparison end end end From 4f7210e6089623056c2b7b9aa1a4fc684615f433 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Jan 2024 11:56:40 +0100 Subject: [PATCH 12/33] fix: record utils --- .../forest_admin_datasource_toolkit/components/query/sort.rb | 2 +- .../lib/forest_admin_datasource_toolkit/utils/record.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index eb0f2a10b..9bf844c3f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -47,7 +47,7 @@ def apply(records) (0..length - 1).each do |i| field = self[i][:field] ascending = self[i][:ascending] - break unless comp.zero? + break unless comparison.zero? value_on_a = ForestAdminDatasourceToolkit::Utils::Record.field_value(a, field) value_on_b = ForestAdminDatasourceToolkit::Utils::Record.field_value(b, field) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb index 13975c913..1a44d0a27 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb @@ -11,7 +11,7 @@ def self.field_value(record, field) path = field.split(':') current = record - current = current[path.shift] while path.length.positive? && current + current = current[path.shift.to_sym] while path.length.positive? && current path.empty? ? current : nil end From 167b656d4e0d91bebe27191730c9d61ed7c2ebce Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Jan 2024 11:58:52 +0100 Subject: [PATCH 13/33] chore: add test on sort_factory --- .../query/sort_utils/sort_factory_spec.rb | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory_spec.rb diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory_spec.rb new file mode 100644 index 000000000..43378aba1 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Query + module Utils + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query::SortUtils + describe SortFactory do + it 'returns a sort instance sorted by primary keys' do + collection_with_composite_id = Collection.new(Datasource.new, 'Book') + collection_with_composite_id.add_fields( + { + 'id1' => ColumnSchema.new(column_type: PrimitiveType::UUID, is_primary_key: true), + 'id2' => ColumnSchema.new(column_type: PrimitiveType::UUID, is_primary_key: true) + } + ) + + expect(described_class.by_primary_keys(collection_with_composite_id)).to eq([ + { field: 'id1', ascending: true }, + { field: 'id2', ascending: true } + ]) + end + end + end + end + end +end From d33b2363137b017b32684f1ff59a3ed4b3496e0f Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Jan 2024 12:02:21 +0100 Subject: [PATCH 14/33] fix: sort --- .../forest_admin_datasource_toolkit/components/query/sort.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index 9bf844c3f..30f1d840e 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -8,7 +8,7 @@ def projection def replace_clauses(...) self.class.new( - map(&block) + map(...) .reduce(self.class.new) do |memo, cb_result| return memo.union(cb_result) if cb_result.is_a?(self.class) From a7894ec302dfdf775a0c7e529e09f80392fca4a5 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Jan 2024 12:08:01 +0100 Subject: [PATCH 15/33] chore: remove useless comment --- .../components/query/sort_spec.rb | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb index e8d11cd5b..dd54c41d0 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb @@ -13,23 +13,6 @@ module Query expect(sort.projection).to eq(['column1', 'column2']) end - # test('apply should sort records', () => { - # const records = [ - # { column1: 2, column2: 2 }, - # { column1: 1, column2: 1 }, - # { column1: 1, column2: 1 }, - # { column1: 1, column2: 2 }, - # { column1: 2, column2: 1 }, - # ]; - # - # expect(sort.apply(records)).toStrictEqual([ - # { column1: 1, column2: 2 }, - # { column1: 1, column2: 1 }, - # { column1: 1, column2: 1 }, - # { column1: 2, column2: 2 }, - # { column1: 2, column2: 1 }, - # ]); - # }); it('apply should sort records') do records = [ { column1: 2, column2: 2 }, From deea264a5a1ef0a6396d7f0ef8912ba2ecbd4ba4 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Jan 2024 17:09:48 +0100 Subject: [PATCH 16/33] feat(decorator): add new relation decorator --- .rubocop.yml | 1 + .../relation/relation_collection.rb | 18 -- .../relation/relation_collection_decorator.rb | 239 ++++++++++++++++++ 3 files changed, 240 insertions(+), 18 deletions(-) delete mode 100644 packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb create mode 100644 packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb diff --git a/.rubocop.yml b/.rubocop.yml index e232d78e1..7e938ff89 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -229,6 +229,7 @@ Metrics/ClassLength: - 'packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/action/action.rb' + - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.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/aggregation.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/decorators/relation/relation_collection.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb deleted file mode 100644 index 90cd0d24f..000000000 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection.rb +++ /dev/null @@ -1,18 +0,0 @@ -module ForestAdminDatasourceCustomizer - module Decorators - module Relation - class RelationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator - include ForestAdminDatasourceToolkit::Utils - include ForestAdminDatasourceToolkit::Components::Query - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes - - def initialize(child_collection, datasource) - super - @relations = {} - end - - def add_relation(name, partial_joint); end - end - end - end -end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb new file mode 100644 index 000000000..04aa41c02 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb @@ -0,0 +1,239 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Relation + class RelationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Utils + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + + def initialize(child_collection, datasource) + super + @relations = {} + end + + def add_relation(name, partial_joint) + relation = relation_with_optional_fields(partial_joint) + check_foreign_keys(relation) + check_origin_keys(relation) + + @relations[name] = relation + mark_schema_as_dirty + end + + def list(caller, filter, projection) + new_filter = refine_filter(caller, filter) + new_projection = projection.replace(->(field) { rewrite_field(field) }, self).with_pks(self) + records = child_collection.list(caller, new_filter, new_projection) + return records if new_projection.equals(projection) + + re_project_in_place(caller, records, projection) + + projection.apply(records) + end + + def aggregate(caller, filter, aggregation, limit) + new_filter = refine_filter(caller, filter) + + # No emulated relations are used in the aggregation + if aggregation.projection.relations.keys.all? { |prefix| !@relations.key?(prefix) } + return child_collection.aggregate(caller, new_filter, aggregation, limit) + end + + # Fallback to full emulation. + aggregation.apply(list(caller, filter, aggregation.projection), caller.timezone, limit) + end + + protected + + def refine_schema(sub_schema) + sub_schema[:fields].merge!(@relations) + end + + # protected override async refineFilter( + # caller: Caller, + # filter: PaginatedFilter, + # ): Promise { + # return filter?.override({ + # conditionTree: await filter.conditionTree?.replaceLeafsAsync( + # leaf => this.rewriteLeaf(caller, leaf), + # this, + # ), + # + # // Replace sort in emulated relations to + # // - sorting by the fk of the relation for many to one + # // - removing the sort altogether for one to one + # // + # // This is far from ideal, but the best that can be done without taking a major + # // performance hit. + # // Customers which want proper sorting should enable emulation in the associated + # // middleware + # sort: filter.sort?.replaceClauses(clause => + # this.rewriteField(clause.field).map(field => ({ ...clause, field })), + # ), + # }); + # } + def refine_filter(caller, filter) + filter.override({ + condition_tree: filter.condition_tree.replace_leafs do |leaf| + rewrite_leaf(caller, leaf) + end, + sort: filter.sort&.replace_clauses do |clause| + rewrite_field(clause.field).map do |field| + { **clause, field: field } + end + end + }) + end + + private + + def relation_with_optional_fields(partial_joint) + relation = partial_joint.dup + target = datasource.get_collection(relation[:foreign_collection]) + + case relation[:type] + when 'ManyToOne' + relation[:foreign_key_target] ||= Schema.primary_keys(target.schema).first + when 'OneToOne', 'OneToMany' + relation[:origin_key_target] ||= Schema.primary_keys(schema).first + when 'ManyToMany' + relation[:origin_key_target] ||= Schema.primary_keys(schema).first + relation[:foreign_key_target] ||= Schema.primary_keys(target.schema).first + end + + relation + end + + def check_foreign_keys(relation) + return unless relation[:type] == 'ManyToOne' || relation[:type] == 'ManyToMany' + + check_keys( + relation[:type] == 'ManyToMany' ? datasource.get_collection(relation[:through_collection]) : self, + datasource.get_collection(relation[:foreign_collection]), + relation[:foreign_key], + relation[:foreign_key_target] + ) + end + + def check_origin_keys(relation) + if relation[:type] == 'OneToMany' || relation[:type] == 'OneToOne' || relation[:type] == 'ManyToMany' + check_keys( + relation[:type] == 'ManyToMany' ? datasource.get_collection(relation[:through_collection]) : self, + datasource.get_collection(relation[:foreign_collection]), + relation[:origin_key], + relation[:origin_key_target] + ) + end + end + + def check_keys(owner, target_owner, key_name, target_name) + check_column(owner, key_name) + check_column(target_owner, target_name) + + key = owner.schema[:fields][key_name] + target = target_owner.schema[:fields][target_name] + + return unless key.column_type != target.column_type + + raise ForestException, + "Types from '#{owner.name}.#{key_name}' and '#{target_owner.name}.#{target_name}' do not match." + end + + def check_column(owner, name) + column = owner.schema[:fields][name] + + raise ForestException, "Column not found: '#{owner.name}.#{name}'" if !column || column.type != 'Column' + + return if column.filter_operators.include?(Operators::IN) + + raise ForestException, "Column does not support the In operator: '#{owner.name}.#{name}'" + end + + def rewrite_field(field) + prefix = field.split(':').first + schema = schema[:fields][prefix] + return [field] if schema.type == 'Column' + + relation = datasource.get_collection(schema.foreign_collection) + result = [] + + if !@relations.key?(prefix) + result = relation.rewrite_field(field[prefix.length + 1..]).map { |sub_field| "#{prefix}:#{sub_field}" } + elsif schema.type == 'ManyToOne' + result = [schema.foreign_key] + elsif schema.type == 'OneToOne' || schema.type == 'OneToMany' || schema.type == 'ManyToMany' + result = [schema.origin_key_target] + end + + result + end + + def rewrite_leaf(caller, leaf) + prefix = leaf.field.split(':').first + schema = schema[:fields][prefix] + return leaf if schema.type == 'Column' + + relation = datasource.get_collection(schema.foreign_collection) + result = leaf + + if !@relations.key?(prefix) + result = relation.rewrite_leaf(caller, leaf.unnest).nest(prefix) + elsif schema.type == 'ManyToOne' + records = relation.list( + caller, + Filter.new(condition_tree: leaf.unnest), + Projection.new(schema.foreign_key_target) + ) + + result = ConditionTreeLeaf.new(schema.foreign_key, 'In', records.map do |record| + record[schema.foreign_key_target] + end.uniq) + elsif schema.type == 'OneToOne' + records = relation.list( + caller, + Filter.new(condition_tree: leaf.unnest), + Projection.new(schema.origin_key) + ) + + result = ConditionTreeLeaf.new(schema.origin_key_target, 'In', records.map do |record| + record[schema.origin_key] + end.uniq) + end + + result + end + + def re_project_in_place(caller, records, projection) + projection.relations.each do |prefix, sub_projection| + re_project_relation_in_place(caller, records, prefix, sub_projection) + end + end + + def re_project_relation_in_place(caller, records, name, projection) + schema = schema[:fields][name] + association = datasource.get_collection(schema.foreign_collection) + + if !@relations[name] + association.re_project_in_place(caller, records.map { |r| r[name] }.filter { |fk| !fk.nil? }, projection) + elsif schema.type == 'ManyToOne' + ids = records.map { |record| record[schema.foreign_key] }.filter { |fk| !fk.nil? }.uniq + sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(schema.foreign_key_target, 'In', ids)) + sub_records = association.list(caller, sub_filter, projection.union([schema.foreign_key_target])) + + records.each do |record| + record[name] = sub_records.find { |sr| sr[schema.foreign_key_target] == record[schema.foreign_key] } + end + elsif schema.type == 'OneToOne' || schema.type == 'OneToMany' + ids = records.map { |record| record[schema.origin_key_target] }.filter { |okt| !okt.nil? }.uniq + sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(schema.origin_key, 'In', ids)) + sub_records = association.list(caller, sub_filter, projection.union([schema.origin_key])) + + records.each do |record| + record[name] = sub_records.find { |sr| sr[schema.origin_key] == record[schema.origin_key_target] } + end + end + end + end + end + end +end From e1c9791e408cc8b7896c6f32898898665f6b207c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 1 Feb 2024 18:34:11 +0100 Subject: [PATCH 17/33] fix: relationdecorator --- .rubocop.yml | 1 + .../relation/relation_collection_decorator.rb | 163 ++++++++++++------ 2 files changed, 111 insertions(+), 53 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 7e938ff89..0f7ae5f25 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -260,6 +260,7 @@ Layout/LineLength: - 'packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb' - '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_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/decorators/relation/relation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb index 04aa41c02..ea32b0a89 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb @@ -2,9 +2,12 @@ module ForestAdminDatasourceCustomizer module Decorators module Relation class RelationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Exceptions include ForestAdminDatasourceToolkit::Utils include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + include ForestAdminDatasourceToolkit::Schema def initialize(child_collection, datasource) super @@ -13,6 +16,7 @@ def initialize(child_collection, datasource) def add_relation(name, partial_joint) relation = relation_with_optional_fields(partial_joint) + puts relation.inspect check_foreign_keys(relation) check_origin_keys(relation) @@ -22,7 +26,8 @@ def add_relation(name, partial_joint) def list(caller, filter, projection) new_filter = refine_filter(caller, filter) - new_projection = projection.replace(->(field) { rewrite_field(field) }, self).with_pks(self) + new_projection = projection.replace { |field| rewrite_field(field) }.with_pks(self) + # new_projection = projection.replace(->(field) { rewrite_field(field) }, self).with_pks(self) records = child_collection.list(caller, new_filter, new_projection) return records if new_projection.equals(projection) @@ -45,36 +50,17 @@ def aggregate(caller, filter, aggregation, limit) protected - def refine_schema(sub_schema) - sub_schema[:fields].merge!(@relations) + def refine_schema(child_schema) + @relations.each do |name, relation| + child_schema[:fields][name] = relation + end + + child_schema end - # protected override async refineFilter( - # caller: Caller, - # filter: PaginatedFilter, - # ): Promise { - # return filter?.override({ - # conditionTree: await filter.conditionTree?.replaceLeafsAsync( - # leaf => this.rewriteLeaf(caller, leaf), - # this, - # ), - # - # // Replace sort in emulated relations to - # // - sorting by the fk of the relation for many to one - # // - removing the sort altogether for one to one - # // - # // This is far from ideal, but the best that can be done without taking a major - # // performance hit. - # // Customers which want proper sorting should enable emulation in the associated - # // middleware - # sort: filter.sort?.replaceClauses(clause => - # this.rewriteField(clause.field).map(field => ({ ...clause, field })), - # ), - # }); - # } def refine_filter(caller, filter) filter.override({ - condition_tree: filter.condition_tree.replace_leafs do |leaf| + condition_tree: filter.condition_tree&.replace_leafs do |leaf| rewrite_leaf(caller, leaf) end, sort: filter.sort&.replace_clauses do |clause| @@ -89,41 +75,82 @@ def refine_filter(caller, filter) def relation_with_optional_fields(partial_joint) relation = partial_joint.dup - target = datasource.get_collection(relation[:foreign_collection]) + target = datasource.get_collection(partial_joint[:foreign_collection]) + puts "partial_joint #{relation}" case relation[:type] when 'ManyToOne' - relation[:foreign_key_target] ||= Schema.primary_keys(target.schema).first - when 'OneToOne', 'OneToMany' - relation[:origin_key_target] ||= Schema.primary_keys(schema).first + relation = Relations::ManyToOneSchema.new( + foreign_key: relation[:foreign_key], + foreign_key_target: if relation[:foreign_key_target].nil? + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(target).first + else + relation[:foreign_key_target] + end, + foreign_collection: relation[:foreign_collection] + ) + when 'OneToOne' + relation = Relations::OneToOneSchema.new( + origin_key: relation[:origin_key], + origin_key_target: if relation[:origin_key_target].nil? + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(self).first + else + relation[:origin_key_target] + end, + foreign_collection: relation[:foreign_collection] + ) + when 'OneToMany' + relation = Relations::OneToManySchema.new( + origin_key: relation[:origin_key], + origin_key_target: if relation[:origin_key_target].nil? + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(self).first + else + relation[:origin_key_target] + end, + foreign_collection: relation[:foreign_collection] + ) when 'ManyToMany' - relation[:origin_key_target] ||= Schema.primary_keys(schema).first - relation[:foreign_key_target] ||= Schema.primary_keys(target.schema).first + relation = Relations::ManyToManySchema.new( + origin_key: relation[:origin_key], + origin_key_target: if relation[:origin_key_target].nil? + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(self).first + else + relation[:origin_key_target] + end, + foreign_key: relation[:foreign_key], + foreign_key_target: if relation[:foreign_key_target].nil? + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(target).first + else + relation[:foreign_key_target] + end, + foreign_collection: relation[:foreign_collection], + through_collection: relation[:through_collection] + ) end relation end def check_foreign_keys(relation) - return unless relation[:type] == 'ManyToOne' || relation[:type] == 'ManyToMany' + return unless relation.type == 'ManyToOne' || relation.type == 'ManyToMany' check_keys( - relation[:type] == 'ManyToMany' ? datasource.get_collection(relation[:through_collection]) : self, - datasource.get_collection(relation[:foreign_collection]), - relation[:foreign_key], - relation[:foreign_key_target] + relation.type == 'ManyToMany' ? datasource.get_collection(relation.through_collection) : self, + datasource.get_collection(relation.foreign_collection), + relation.foreign_key, + relation.foreign_key_target ) end def check_origin_keys(relation) - if relation[:type] == 'OneToMany' || relation[:type] == 'OneToOne' || relation[:type] == 'ManyToMany' - check_keys( - relation[:type] == 'ManyToMany' ? datasource.get_collection(relation[:through_collection]) : self, - datasource.get_collection(relation[:foreign_collection]), - relation[:origin_key], - relation[:origin_key_target] - ) - end + return unless relation.type == 'OneToMany' || relation.type == 'OneToOne' || relation.type == 'ManyToMany' + + check_keys( + relation.type == 'ManyToMany' ? datasource.get_collection(relation.through_collection) : datasource.get_collection(relation.foreign_collection), + self, + relation.origin_key, + relation.origin_key_target + ) end def check_keys(owner, target_owner, key_name, target_name) @@ -149,22 +176,52 @@ def check_column(owner, name) raise ForestException, "Column does not support the In operator: '#{owner.name}.#{name}'" end + # private rewriteField(field: string): string[] { + # const prefix = field.split(':').shift(); + # const schema = this.schema.fields[prefix]; + # if (schema.type === 'Column') return [field]; + # + # const relation = this.dataSource.getCollection(schema.foreignCollection); + # let result = [] as string[]; + # + # if (!this.relations[prefix]) { + # result = relation + # .rewriteField(field.substring(prefix.length + 1)) + # .map(subField => `${prefix}:${subField}`); + # } else if (schema.type === 'ManyToOne') { + # result = [schema.foreignKey]; + # } else if ( + # schema.type === 'OneToOne' || + # schema.type === 'OneToMany' || + # schema.type === 'ManyToMany' + # ) { + # result = [schema.originKeyTarget]; + # } + # + # return result; + # } def rewrite_field(field) prefix = field.split(':').first - schema = schema[:fields][prefix] - return [field] if schema.type == 'Column' + field_schema = schema[:fields][prefix] - relation = datasource.get_collection(schema.foreign_collection) + puts "prefix #{prefix}" + + return [field] if field_schema.type == 'Column' + + relation = datasource.get_collection(field_schema.foreign_collection) result = [] if !@relations.key?(prefix) result = relation.rewrite_field(field[prefix.length + 1..]).map { |sub_field| "#{prefix}:#{sub_field}" } - elsif schema.type == 'ManyToOne' - result = [schema.foreign_key] - elsif schema.type == 'OneToOne' || schema.type == 'OneToMany' || schema.type == 'ManyToMany' - result = [schema.origin_key_target] + elsif field_schema.is_a? Relations::ManyToOneSchema + result = [field_schema.foreign_key] + elsif field_schema.is_a?(Relations::OneToOneSchema) || + field_schema.is_a?(Relations::OneToManySchema) || + field_schema.is_a?(Relations::ManyToManySchema) + result = [field_schema.origin_key_target] end + puts "result #{result}" result end From db83911449a56e8f80a6b9631b54b59799cfea06 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 13 Feb 2024 10:20:39 +0100 Subject: [PATCH 18/33] fix: permission & context_variables --- .../lib/forest_admin_agent/services/permissions.rb | 3 +++ .../lib/forest_admin_agent/utils/context_variables.rb | 5 ++++- .../lib/forest_admin_agent/utils/context_variables_spec.rb | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb b/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb index 5f59c6a84..636d53876 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb @@ -103,6 +103,9 @@ def get_scope(collection) return nil if scope.nil? + team = get_team(caller.rendering_id) + user = get_user_data(caller.id) + context_variables = ContextVariables.new(team, user) ContextVariablesInjector.inject_context_in_filter(scope, context_variables) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables.rb index a02c9337f..d1e731b5e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/context_variables.rb @@ -29,7 +29,10 @@ def get_current_user_data(context_variable_key) end if context_variable_key.start_with?(USER_VALUE_TAG_PREFIX) - return user[:tags][context_variable_key[USER_VALUE_TAG_PREFIX.length..]] + user[:tags].each do |tag| + match_key = context_variable_key[USER_VALUE_TAG_PREFIX.length..] + return tag[match_key] if tag.key?(match_key) + end end user[context_variable_key[USER_VALUE_PREFIX.length..].to_sym] diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb index 54f807366..598ef6c99 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_spec.rb @@ -10,7 +10,7 @@ module Utils lastName: 'Doe', fullName: 'John Doe', email: 'johndoe@forestadmin.com', - tags: { 'foo' => 'bar' }, + tags: [{ 'foo' => 'bar' }], roleId: 1, permissionLevel: 'admin' } From bb960bae6f94ce968bf7e60e5c7eecb6efa49a5e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 13 Feb 2024 11:06:22 +0100 Subject: [PATCH 19/33] fix: context_variable_injector test --- .../utils/context_variables_injector_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb index b0f074562..300566b99 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/context_variables_injector_spec.rb @@ -12,7 +12,7 @@ module Utils 'lastName' => 'Doe', 'fullName' => 'John Doe', 'email' => 'john.doe@domain.com', - 'tags' => { 'planet' => 'Death Star' }, + 'tags' => [{ 'planet' => 'Death Star' }], 'roleId' => 1, 'permissionLevel' => 'admin' } @@ -89,7 +89,7 @@ module Utils { key: 'id', expected_value: user['id'] }, { key: 'permissionLevel', expected_value: user['permissionLevel'] }, { key: 'roleId', expected_value: user['roleId'] }, - { key: 'tags.planet', expected_value: user['tags']['planet'] }, + { key: 'tags.planet', expected_value: user['tags'][0]['planet'] }, { key: 'team.id', expected_value: team['id'] }, { key: 'team.name', expected_value: team['name'] } ].each do |value| From 6ea9418b8fc78a4db7ce7d1fa64db67519510020 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 14 Feb 2024 16:59:11 +0100 Subject: [PATCH 20/33] chore: projection add apply method and utils for Hash --- .../components/query/projection.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index bd620c768..fcec8297b 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -58,6 +58,23 @@ def replace(...) def equals(other) length == other.length && all? { |field| other.include?(field) } end + + def apply(records) + records.map { |record| re_project(record) } + end + + def re_project(record) + result = nil + + if record + record = HashHelper.convert_keys(record, :to_s) + result = {} + columns.each { |column| result[column.to_s] = record[column.to_s] } + relations.each { |relation, projection| result[relation] = projection.re_project(record[relation]) } + end + + result + end end end end From cd9c07d2ece23ceb5a199ed8d39ee98114167f0e Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 14 Feb 2024 17:00:51 +0100 Subject: [PATCH 21/33] chore: add missing test on Projection --- .../components/query/projection_spec.rb | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb index c8c42f015..30fc105da 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb @@ -71,6 +71,33 @@ module Query expect(projection.relations).to eq({ 'category' => ['label'] }) end end + + describe 'apply' do + it 're_projects a list of records' do + projection = described_class.new(%w[id name author:name other:id]) + + expect( + projection.apply([ + { + id: 1, + name: 'romain', + age: 12, + author: { name: 'ana', lastname: 'something' }, + other: nil + } + ]) + ).to eq( + [ + { + 'id' => 1, + 'name' => 'romain', + 'author' => { 'name' => 'ana' }, + 'other' => nil + } + ] + ) + end + end end end end From 7309024f20fea5e0facb168439634f6124d9a5bc Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 15 Feb 2024 17:33:21 +0100 Subject: [PATCH 22/33] fix: condition tree leaf --- .../query/condition_tree/nodes/condition_tree_leaf.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb index 59b347d33..e7bee310c 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb @@ -81,7 +81,7 @@ def match(record, collection, timezone) case @operator when Operators::IN - Array(@value).include?(field_value) + @value.include?(field_value) when Operators::EQUAL field_value == @value when Operators::LESS_THAN From 1d15abbaba69dbacbcbe1d3512ef262cc1819fcb Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 15 Feb 2024 17:33:41 +0100 Subject: [PATCH 23/33] fix: record utils --- .../lib/forest_admin_datasource_toolkit/utils/record.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb index 1a44d0a27..13975c913 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/record.rb @@ -11,7 +11,7 @@ def self.field_value(record, field) path = field.split(':') current = record - current = current[path.shift.to_sym] while path.length.positive? && current + current = current[path.shift] while path.length.positive? && current path.empty? ? current : nil end From 9c578d2db54dfc35c021a8afc89670ad05de5600 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 15 Feb 2024 17:33:59 +0100 Subject: [PATCH 24/33] chore: projection add union method --- .../components/query/projection.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index fcec8297b..db55522bd 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -75,6 +75,10 @@ def re_project(record) result end + + def union(other_arrays) + Projection.new(other_arrays.union(self)) + end end end end From a2efeeefede47ab30cbc121d3d750c9e40042526 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 15 Feb 2024 17:38:34 +0100 Subject: [PATCH 25/33] chore: add test on relation decorator --- .../relation_collection_decorator_spec.rb | 505 ++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb 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 new file mode 100644 index 000000000..cf79d4323 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb @@ -0,0 +1,505 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminDatasourceCustomizer + module Decorators + module Relation + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Decorators + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Exceptions + + describe RelationCollectionDecorator do + include_context 'with caller' + subject(:relation_collection_decorator) { described_class } + let(:passport_records) do + [ + { + id: 101, + issue_date: '2010-01-01', + owner_id: 202, + picture_id: 301, + picture: { picture_id: 301, filename: 'pic1.jpg' } + }, + { + id: 102, + issue_date: '2017-01-01', + owner_id: 201, + picture_id: 302, + picture: { picture_id: 302, filename: 'pic2.jpg' } + }, + { + id: 103, + issue_date: '2017-02-05', + owner_id: nil, + picture_id: 303, + picture: { picture_id: 303, filename: 'pic3.jpg' } + } + ] + end + let(:person_records) do + [ + { id: 201, other_id: 201, name: 'Sharon J. Whalen' }, + { id: 202, other_id: 202, name: 'Mae S. Waldron' }, + { id: 203, other_id: 203, name: 'Joseph P. Rodriguez' } + ] + end + + before do + datasource = Datasource.new + collection_picture = instance_double( + Collection, + name: 'picture', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), + 'filename' => ColumnSchema.new(column_type: PrimitiveType::STRING), + 'other_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER) + } + } + ) + + collection_passport = instance_double( + Collection, + name: 'passport', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), + 'issue_date' => ColumnSchema.new(column_type: PrimitiveType::DATEONLY), + 'owner_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, filter_operators: [Operators::IN]), + 'picture_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER), + 'picture' => Relations::ManyToOneSchema.new(foreign_key: 'picture_id', foreign_key_target: 'id', foreign_collection: 'picture') + } + } + ) + + allow(collection_passport).to receive(:list) do |_caller, filter, projection| + result = ForestAdminDatasourceToolkit::Utils::HashHelper.convert_keys(passport_records, :to_s) + result = filter.condition_tree.apply(result, collection_passport, 'Europe/Paris') if filter&.condition_tree + result = filter.sort.apply(result) if filter&.sort + + projection.apply(result) + end + + collection_person = instance_double( + Collection, + name: 'person', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), + 'other_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, filter_operators: [Operators::IN]), + 'name' => ColumnSchema.new(column_type: PrimitiveType::STRING, filter_operators: [Operators::IN]) + } + } + ) + + allow(collection_person).to receive(:list) do |_caller, filter, projection| + result = ForestAdminDatasourceToolkit::Utils::HashHelper.convert_keys(person_records, :to_s) + result = filter.condition_tree.apply(result, collection_person, 'Europe/Paris') if filter&.condition_tree + result = filter.sort.apply(result) if filter&.sort + + projection.apply(result) + end + + datasource.add_collection(collection_picture) + datasource.add_collection(collection_passport) + datasource.add_collection(collection_person) + + @datasource_decorator = DatasourceDecorator.new(datasource, relation_collection_decorator) + end + + context 'when a one to one is declared' do + context 'when missing dependencies' do + it 'throws with a non existent fk' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: '__nonExisting__' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column not found: 'passport.__nonExisting__'") + end + end + + context 'when missing operators' do + it 'throws when In is not supported by the fk in the target' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'picture_id' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column does not support the In operator: 'passport.picture_id'") + end + end + + it 'throws when there is a given originKeyTarget that does not match the target type' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'name' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Types from 'passport.owner_id' and 'person.name' do not match.") + end + + context 'when there is a given originKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'id' + }) + end.not_to raise_error + end + end + + context 'when there is not a given originKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id' + }) + end.not_to raise_error + end + end + end + + context 'when a one to many is declared' do + it 'when there is a given originKeyTarget that does not match the target type' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToMany', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'name' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Types from 'passport.owner_id' and 'person.name' do not match.") + end + + context 'when there is a given originKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToMany', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'id' + }) + end.not_to raise_error + end + end + + context 'when there is not a given originKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToMany', + foreign_collection: 'passport', + origin_key: 'owner_id' + }) + end.not_to raise_error + end + end + end + + context 'when a many to one is declared' do + context 'when missing dependencies' do + it 'throws with a non existent collection' do + expect do + @datasource_decorator.get_collection('person').add_relation('someName', { + type: 'ManyToOne', + foreign_collection: '__nonExisting__', + foreign_key: 'owner_id' + }) + end.to raise_error(ForestException, '🌳🌳🌳 Collection __nonExisting__ not found.') + end + + it 'throws with a non existent fk' do + expect do + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: '__nonExisting__' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column not found: 'passport.__nonExisting__'") + end + end + + context 'when missing operators' do + it 'throws when In is not supported by the pk in the target' do + expect do + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'picture_id' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column does not support the In operator: 'passport.picture_id'") + end + end + + context 'when there is a given foreignKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id', + foreign_key_target: 'id' + }) + end.not_to raise_error + end + end + + context 'when there is not a given foreignKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id' + }) + end.not_to raise_error + end + end + end + + context 'when a many to many is declared' do + context 'when missing dependencies' do + it 'throws with a non existent though collection' do + expect do + @datasource_decorator.get_collection('person').add_relation('passports', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: 'owner_id', + origin_key: 'owner_id', + through_collection: '__nonExisting__' + }) + end.to raise_error(ForestException, '🌳🌳🌳 Collection __nonExisting__ not found.') + end + + it 'throws with a non existent originKey' do + expect do + @datasource_decorator.get_collection('person').add_relation('person', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: 'owner_id', + origin_key: '__nonExisting__', + through_collection: 'passport' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column not found: 'passport.__nonExisting__'") + end + + it 'throws with a non existent fk' do + expect do + @datasource_decorator.get_collection('person').add_relation('person', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: '__nonExisting__', + origin_key: 'owner_id', + through_collection: 'passport' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Column not found: 'passport.__nonExisting__'") + end + end + + context 'when there is a given originKeyTarget that does not match the target type' do + it 'throws' do + expect do + @datasource_decorator.get_collection('person').add_relation('person', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: 'owner_id', + origin_key: 'owner_id', + through_collection: 'passport', + origin_key_target: 'name' + }) + end.to raise_error(ForestException, "🌳🌳🌳 Types from 'passport.owner_id' and 'person.name' do not match.") + end + end + + context 'when there are a given originKeyTarget and foreignKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('person', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: 'owner_id', + origin_key: 'owner_id', + through_collection: 'passport', + origin_key_target: 'id', + foreign_key_target: 'id' + }) + end.not_to raise_error + end + end + + context 'when there are not a given originKeyTarget and foreignKeyTarget' do + it 'registers the relation' do + expect do + @datasource_decorator.get_collection('person').add_relation('person', { + type: 'ManyToMany', + foreign_collection: 'passport', + foreign_key: 'owner_id', + origin_key: 'owner_id', + through_collection: 'passport' + }) + end.not_to raise_error + end + end + end + + context 'when emulated projection' do + it 'fetches fields from a many to one relation' do + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id' + }) + + records = @datasource_decorator.get_collection('passport').list( + caller, + Filter.new, + Projection.new(%w[id owner:name]) + ) + + expect(records).to eq([ + { 'id' => 101, 'owner' => { 'name' => 'Mae S. Waldron' } }, + { 'id' => 102, 'owner' => { 'name' => 'Sharon J. Whalen' } }, + { 'id' => 103, 'owner' => nil } + ]) + end + + it 'fetches fields from a one to one relation' do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'other_id' + }) + + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new, + Projection.new(%w[id name passport:issue_date]) + ) + + expect(records).to eq([ + { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'issue_date' => '2017-01-01' } }, + { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'issue_date' => '2010-01-01' } }, + { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } + ]) + end + + it 'fetches fields from a one to many relation' do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToMany', + foreign_collection: 'passport', + origin_key: 'owner_id', + origin_key_target: 'other_id' + }) + + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new, + Projection.new(%w[id name passport:issue_date]) + ) + + expect(records).to eq([ + { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'issue_date' => '2017-01-01' } }, + { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'issue_date' => '2010-01-01' } }, + { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } + ]) + end + + it 'fetches fields from a many to many relation' do + @datasource_decorator.get_collection('person').add_relation('persons', { + type: 'ManyToMany', + foreign_collection: 'person', + foreign_key: 'owner_id', + origin_key: 'owner_id', + through_collection: 'passport', + origin_key_target: 'other_id', + foreign_key_target: 'id' + }) + + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new, + Projection.new(%w[id name persons:name]) + ) + + expect(records).to eq([ + { 'id' => 201, 'name' => 'Sharon J. Whalen', 'persons' => nil }, + { 'id' => 202, 'name' => 'Mae S. Waldron', 'persons' => nil }, + { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'persons' => nil } + ]) + end + + # test('should fetch fields from a native behind an emulated one', async () => { + # newPersons.addRelation('passport', { + # type: 'OneToOne', + # foreignCollection: 'passports', + # originKey: 'ownerId', + # }); + # newPassports.addRelation('owner', { + # type: 'ManyToOne', + # foreignCollection: 'persons', + # foreignKey: 'ownerId', + # }); + # const records = await newPersons.list( + # factories.caller.build(), + # new Filter({}), + # new Projection('personId', 'name', 'passport:picture:filename'), + # ); + # + # expect(records).toStrictEqual([ + # { + # personId: 201, + # name: 'Sharon J. Whalen', + # passport: { picture: { filename: 'pic2.jpg' } }, + # }, + # { personId: 202, name: 'Mae S. Waldron', passport: { picture: { filename: 'pic1.jpg' } } }, + # { personId: 203, name: 'Joseph P. Rodriguez', passport: null }, + # ]); + # + # // make sure that the emulator did not trigger on native relation + # expect(pictures.list).not.toHaveBeenCalled(); + # }); + + # it 'fetches fields from a native behind an emulated one' do + # @datasource_decorator.get_collection('person').add_relation('passport', { + # type: 'OneToOne', + # foreign_collection: 'passport', + # origin_key: 'owner_id' + # }) + # @datasource_decorator.get_collection('passport').add_relation('owner', { + # type: 'ManyToOne', + # foreign_collection: 'person', + # foreign_key: 'owner_id' + # }) + # + # records = @datasource_decorator.get_collection('person').list( + # caller, + # Filter.new, + # Projection.new(%w[passport:picture:filename]) # passport:picture:filename + # ) + # + # expect(records).to eq([ + # { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'picture' => { 'filename' => 'pic2.jpg' } } }, + # { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'picture' => { 'filename' => 'pic1.jpg' } } }, + # { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } + # ]) + # end + end + end + end + end +end From dbe8de7b94aeab8e70ea7f08d0312f12783c6127 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 27 Feb 2024 17:52:55 +0100 Subject: [PATCH 26/33] fix: utils and relation decorator --- .../relation/relation_collection_decorator.rb | 98 +++++++------------ .../components/query/aggregation.rb | 6 +- .../components/query/projection.rb | 9 +- .../components/query/sort.rb | 3 +- 4 files changed, 44 insertions(+), 72 deletions(-) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb index ea32b0a89..9e27b2acc 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb @@ -16,7 +16,6 @@ def initialize(child_collection, datasource) def add_relation(name, partial_joint) relation = relation_with_optional_fields(partial_joint) - puts relation.inspect check_foreign_keys(relation) check_origin_keys(relation) @@ -26,17 +25,16 @@ def add_relation(name, partial_joint) def list(caller, filter, projection) new_filter = refine_filter(caller, filter) - new_projection = projection.replace { |field| rewrite_field(field) }.with_pks(self) - # new_projection = projection.replace(->(field) { rewrite_field(field) }, self).with_pks(self) + new_projection = projection.replace { |field| rewrite_field(field) } records = child_collection.list(caller, new_filter, new_projection) return records if new_projection.equals(projection) - re_project_in_place(caller, records, projection) + records = re_project_in_place(caller, records, projection) projection.apply(records) end - def aggregate(caller, filter, aggregation, limit) + def aggregate(caller, filter, aggregation, limit = nil) new_filter = refine_filter(caller, filter) # No emulated relations are used in the aggregation @@ -64,20 +62,17 @@ def refine_filter(caller, filter) rewrite_leaf(caller, leaf) end, sort: filter.sort&.replace_clauses do |clause| - rewrite_field(clause.field).map do |field| - { **clause, field: field } - end - end + rewrite_field(clause[:field]).map do |field| + clause.merge(field: field) + end + end }) end - private - def relation_with_optional_fields(partial_joint) relation = partial_joint.dup target = datasource.get_collection(partial_joint[:foreign_collection]) - puts "partial_joint #{relation}" case relation[:type] when 'ManyToOne' relation = Relations::ManyToOneSchema.new( @@ -176,36 +171,10 @@ def check_column(owner, name) raise ForestException, "Column does not support the In operator: '#{owner.name}.#{name}'" end - # private rewriteField(field: string): string[] { - # const prefix = field.split(':').shift(); - # const schema = this.schema.fields[prefix]; - # if (schema.type === 'Column') return [field]; - # - # const relation = this.dataSource.getCollection(schema.foreignCollection); - # let result = [] as string[]; - # - # if (!this.relations[prefix]) { - # result = relation - # .rewriteField(field.substring(prefix.length + 1)) - # .map(subField => `${prefix}:${subField}`); - # } else if (schema.type === 'ManyToOne') { - # result = [schema.foreignKey]; - # } else if ( - # schema.type === 'OneToOne' || - # schema.type === 'OneToMany' || - # schema.type === 'ManyToMany' - # ) { - # result = [schema.originKeyTarget]; - # } - # - # return result; - # } def rewrite_field(field) prefix = field.split(':').first field_schema = schema[:fields][prefix] - puts "prefix #{prefix}" - return [field] if field_schema.type == 'Column' relation = datasource.get_collection(field_schema.foreign_collection) @@ -221,40 +190,39 @@ def rewrite_field(field) result = [field_schema.origin_key_target] end - puts "result #{result}" result end def rewrite_leaf(caller, leaf) prefix = leaf.field.split(':').first - schema = schema[:fields][prefix] - return leaf if schema.type == 'Column' + field_schema = schema[:fields][prefix] + return leaf if field_schema.type == 'Column' - relation = datasource.get_collection(schema.foreign_collection) + relation = datasource.get_collection(field_schema.foreign_collection) result = leaf if !@relations.key?(prefix) result = relation.rewrite_leaf(caller, leaf.unnest).nest(prefix) - elsif schema.type == 'ManyToOne' + elsif field_schema.type == 'ManyToOne' records = relation.list( caller, Filter.new(condition_tree: leaf.unnest), - Projection.new(schema.foreign_key_target) + Projection.new([field_schema.foreign_key_target]) ) - result = ConditionTreeLeaf.new(schema.foreign_key, 'In', records.map do |record| - record[schema.foreign_key_target] - end.uniq) - elsif schema.type == 'OneToOne' + result = ConditionTreeLeaf.new(field_schema.foreign_key, 'In', records.map do |record| + record[field_schema.foreign_key_target] + end.uniq) + elsif field_schema.type == 'OneToOne' records = relation.list( caller, Filter.new(condition_tree: leaf.unnest), - Projection.new(schema.origin_key) + Projection.new([field_schema.origin_key]) ) - result = ConditionTreeLeaf.new(schema.origin_key_target, 'In', records.map do |record| - record[schema.origin_key] - end.uniq) + result = ConditionTreeLeaf.new(field_schema.origin_key_target, 'In', records.map do |record| + record[field_schema.origin_key] + end.uniq) end result @@ -264,29 +232,31 @@ def re_project_in_place(caller, records, projection) projection.relations.each do |prefix, sub_projection| re_project_relation_in_place(caller, records, prefix, sub_projection) end + + records end def re_project_relation_in_place(caller, records, name, projection) - schema = schema[:fields][name] - association = datasource.get_collection(schema.foreign_collection) + field_schema = schema[:fields][name] + association = datasource.get_collection(field_schema.foreign_collection) if !@relations[name] association.re_project_in_place(caller, records.map { |r| r[name] }.filter { |fk| !fk.nil? }, projection) - elsif schema.type == 'ManyToOne' - ids = records.map { |record| record[schema.foreign_key] }.filter { |fk| !fk.nil? }.uniq - sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(schema.foreign_key_target, 'In', ids)) - sub_records = association.list(caller, sub_filter, projection.union([schema.foreign_key_target])) + elsif field_schema.type == 'ManyToOne' + ids = records.map { |record| record[field_schema.foreign_key] }.filter { |fk| !fk.nil? }.uniq + sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(field_schema.foreign_key_target, 'In', ids)) + sub_records = association.list(caller, sub_filter, projection.union([field_schema.foreign_key_target])) records.each do |record| - record[name] = sub_records.find { |sr| sr[schema.foreign_key_target] == record[schema.foreign_key] } + record[name] = sub_records.find { |sr| sr[field_schema.foreign_key_target] == record[field_schema.foreign_key] } end - elsif schema.type == 'OneToOne' || schema.type == 'OneToMany' - ids = records.map { |record| record[schema.origin_key_target] }.filter { |okt| !okt.nil? }.uniq - sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(schema.origin_key, 'In', ids)) - sub_records = association.list(caller, sub_filter, projection.union([schema.origin_key])) + elsif field_schema.type == 'OneToOne' || field_schema.type == 'OneToMany' + ids = records.map { |record| record[field_schema.origin_key_target] }.filter { |okt| !okt.nil? }.uniq + sub_filter = Filter.new(condition_tree: ConditionTreeLeaf.new(field_schema.origin_key, 'In', ids)) + sub_records = association.list(caller, sub_filter, projection.union([field_schema.origin_key])) records.each do |record| - record[name] = sub_records.find { |sr| sr[schema.origin_key] == record[schema.origin_key_target] } + record[name] = sub_records.find { |sr| sr[field_schema.origin_key] == record[field_schema.origin_key_target] } end end end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb index 97c35a749..5f44c9f99 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb @@ -23,10 +23,10 @@ def validate(operation) def projection aggregate_fields = [] - aggregate_fields << field if field + aggregate_fields << field.to_s if field groups.each do |group| - aggregate_fields << group[:field] + aggregate_fields << group[:field].to_s end Projection.new(aggregate_fields) @@ -132,7 +132,7 @@ def create_group(record, timezone) group = {} groups.each do |value| - group_value = record[value[:field]] + group_value = ForestAdminDatasourceToolkit::Utils::Record.field_value(record, value[:field]) group[value[:field]] = apply_date_operation(group_value, value[:operation], timezone) end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index db55522bd..9b33925db 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -27,9 +27,10 @@ def relations each_with_object({}) do |path, memo| next unless path.include?(':') - split_path = path.split(':') - relation = split_path[0] - memo[relation] = Projection.new([split_path[1]].union(memo[relation] || [])) + original_path = path.split(':') + relation = original_path.shift + + memo[relation] = Projection.new([original_path.join(':')].union(memo[relation] || [])) end end @@ -47,7 +48,7 @@ def unnest def replace(...) Projection.new( map(...) - .reduce(Projection.new) do |memo, path| + .reduce(Projection.new) do |memo, path| return memo.union([path]) if path.is_a?(String) memo.union(path) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index 30f1d840e..ae7719e37 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -12,7 +12,7 @@ def replace_clauses(...) .reduce(self.class.new) do |memo, cb_result| return memo.union(cb_result) if cb_result.is_a?(self.class) - memo.union([cb_result]) + memo.union(cb_result) end ) end @@ -53,6 +53,7 @@ def apply(records) value_on_b = ForestAdminDatasourceToolkit::Utils::Record.field_value(b, field) comparison = value_on_a <=> value_on_b + comparison = 1 if comparison.nil? comparison *= -1 unless ascending end From a2acdfffa90068b08a7c7027b8482325f53d0405 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 28 Feb 2024 10:52:24 +0100 Subject: [PATCH 27/33] fix: test on relation decorator --- .../relation_collection_decorator_spec.rb | 262 +++++++++++++----- 1 file changed, 187 insertions(+), 75 deletions(-) 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 cf79d4323..25fb73b35 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 @@ -17,33 +17,33 @@ module Relation let(:passport_records) do [ { - id: 101, - issue_date: '2010-01-01', - owner_id: 202, - picture_id: 301, - picture: { picture_id: 301, filename: 'pic1.jpg' } + 'id' => 101, + 'issue_date' => '2010-01-01', + 'owner_id' => 202, + 'picture_id' => 301, + 'picture' => { 'picture_id' => 301, 'filename' => 'pic1.jpg' } }, { - id: 102, - issue_date: '2017-01-01', - owner_id: 201, - picture_id: 302, - picture: { picture_id: 302, filename: 'pic2.jpg' } + 'id' => 102, + 'issue_date' => '2017-01-01', + 'owner_id' => 201, + 'picture_id' => 302, + 'picture' => { 'picture_id' => 302, 'filename' => 'pic2.jpg' } }, { - id: 103, - issue_date: '2017-02-05', - owner_id: nil, - picture_id: 303, - picture: { picture_id: 303, filename: 'pic3.jpg' } + 'id' => 103, + 'issue_date' => '2017-02-05', + 'owner_id' => nil, + 'picture_id' => 303, + 'picture' => { 'picture_id' => 303, 'filename' => 'pic3.jpg' } } ] end let(:person_records) do [ - { id: 201, other_id: 201, name: 'Sharon J. Whalen' }, - { id: 202, other_id: 202, name: 'Mae S. Waldron' }, - { id: 203, other_id: 203, name: 'Joseph P. Rodriguez' } + { 'id' => 201, 'other_id' => 201, 'name' => 'Sharon J. Whalen' }, + { 'id' => 202, 'other_id' => 202, 'name' => 'Mae S. Waldron' }, + { 'id' => 203, 'other_id' => 203, 'name' => 'Joseph P. Rodriguez' } ] end @@ -72,7 +72,8 @@ module Relation 'picture_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER), 'picture' => Relations::ManyToOneSchema.new(foreign_key: 'picture_id', foreign_key_target: 'id', foreign_collection: 'picture') } - } + }, + datasource: datasource ) allow(collection_passport).to receive(:list) do |_caller, filter, projection| @@ -82,6 +83,9 @@ module Relation projection.apply(result) end + allow(collection_passport).to receive(:aggregate) do |caller, _filter, aggregation, limit| + aggregation.apply(passport_records, caller.timezone, limit) + end collection_person = instance_double( Collection, @@ -92,7 +96,8 @@ module Relation 'other_id' => ColumnSchema.new(column_type: PrimitiveType::NUMBER, filter_operators: [Operators::IN]), 'name' => ColumnSchema.new(column_type: PrimitiveType::STRING, filter_operators: [Operators::IN]) } - } + }, + datasource: datasource ) allow(collection_person).to receive(:list) do |_caller, filter, projection| @@ -102,6 +107,9 @@ module Relation projection.apply(result) end + allow(collection_person).to receive(:aggregate) do |caller, _filter, aggregation, limit| + aggregation.apply(person_records, caller.timezone, limit) + end datasource.add_collection(collection_picture) datasource.add_collection(collection_passport) @@ -443,61 +451,165 @@ module Relation ]) end - # test('should fetch fields from a native behind an emulated one', async () => { - # newPersons.addRelation('passport', { - # type: 'OneToOne', - # foreignCollection: 'passports', - # originKey: 'ownerId', - # }); - # newPassports.addRelation('owner', { - # type: 'ManyToOne', - # foreignCollection: 'persons', - # foreignKey: 'ownerId', - # }); - # const records = await newPersons.list( - # factories.caller.build(), - # new Filter({}), - # new Projection('personId', 'name', 'passport:picture:filename'), - # ); - # - # expect(records).toStrictEqual([ - # { - # personId: 201, - # name: 'Sharon J. Whalen', - # passport: { picture: { filename: 'pic2.jpg' } }, - # }, - # { personId: 202, name: 'Mae S. Waldron', passport: { picture: { filename: 'pic1.jpg' } } }, - # { personId: 203, name: 'Joseph P. Rodriguez', passport: null }, - # ]); - # - # // make sure that the emulator did not trigger on native relation - # expect(pictures.list).not.toHaveBeenCalled(); - # }); - - # it 'fetches fields from a native behind an emulated one' do - # @datasource_decorator.get_collection('person').add_relation('passport', { - # type: 'OneToOne', - # foreign_collection: 'passport', - # origin_key: 'owner_id' - # }) - # @datasource_decorator.get_collection('passport').add_relation('owner', { - # type: 'ManyToOne', - # foreign_collection: 'person', - # foreign_key: 'owner_id' - # }) - # - # records = @datasource_decorator.get_collection('person').list( - # caller, - # Filter.new, - # Projection.new(%w[passport:picture:filename]) # passport:picture:filename - # ) - # - # expect(records).to eq([ - # { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'picture' => { 'filename' => 'pic2.jpg' } } }, - # { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'picture' => { 'filename' => 'pic1.jpg' } } }, - # { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } - # ]) - # end + it 'fetches fields from a native behind an emulated one' do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id' + }) + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id' + }) + + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new, + Projection.new(%w[id name passport:picture:filename]) + ) + + expect(records).to eq([ + { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'picture' => { 'filename' => 'pic2.jpg' } } }, + { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'picture' => { 'filename' => 'pic1.jpg' } } }, + { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } + ]) + end + + it 'does not break with deep reprojection' do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id' + }) + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id' + }) + + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new, + Projection.new(%w[id name passport:owner:passport:issue_date]) + ) + + expect(records).to eq([ + { 'id' => 201, 'name' => 'Sharon J. Whalen', 'passport' => { 'owner' => { 'passport' => { 'issue_date' => '2017-01-01' } } } }, + { 'id' => 202, 'name' => 'Mae S. Waldron', 'passport' => { 'owner' => { 'passport' => { 'issue_date' => '2010-01-01' } } } }, + { 'id' => 203, 'name' => 'Joseph P. Rodriguez', 'passport' => nil } + ]) + end + + context 'with two emulated relations' do + before do + @datasource_decorator.get_collection('person').add_relation('passport', { + type: 'OneToOne', + foreign_collection: 'passport', + origin_key: 'owner_id' + }) + @datasource_decorator.get_collection('passport').add_relation('owner', { + type: 'ManyToOne', + foreign_collection: 'person', + foreign_key: 'owner_id' + }) + end + + context 'when emulated filtering' do + it 'filters by a many to one relation' do + records = @datasource_decorator.get_collection('passport').list( + caller, + Filter.new(condition_tree: Nodes::ConditionTreeLeaf.new('owner:name', 'Equal', 'Mae S. Waldron')), + Projection.new(%w[id issue_date]) + ) + + expect(records).to eq([{ 'id' => 101, 'issue_date' => '2010-01-01' }]) + end + + it 'filters by a one to one relation' do + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new(condition_tree: Nodes::ConditionTreeLeaf.new('passport:issue_date', 'Equal', '2017-01-01')), + Projection.new(%w[id name]) + ) + + expect(records).to eq([{ 'id' => 201, 'name' => 'Sharon J. Whalen' }]) + end + + it 'filters by native relation behind an emulated one' do + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new(condition_tree: Nodes::ConditionTreeLeaf.new('passport:picture:filename', 'Equal', 'pic1.jpg')), + Projection.new(%w[id name]) + ) + + expect(records).to eq([{ 'id' => 202, 'name' => 'Mae S. Waldron' }]) + end + + it 'does not break with deep filters' do + records = @datasource_decorator.get_collection('person').list( + caller, + Filter.new(condition_tree: Nodes::ConditionTreeLeaf.new('passport:owner:passport:issue_date', 'Equal', '2017-01-01')), + Projection.new(%w[id name]) + ) + + expect(records).to eq([{ 'id' => 201, 'name' => 'Sharon J. Whalen' }]) + end + end + + context 'when emulated sorting' do + 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 }])), + 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 }])), + Projection.new(%w[id owner_id owner:name]) + ) + + expect(ascending).to eq([ + { 'id' => 103, 'owner_id' => nil, 'owner' => nil }, + { 'id' => 102, 'owner_id' => 201, 'owner' => { 'name' => 'Sharon J. Whalen' } }, + { 'id' => 101, 'owner_id' => 202, 'owner' => { 'name' => 'Mae S. Waldron' } } + ]) + + expect(descending).to eq([ + { 'id' => 101, 'owner_id' => 202, 'owner' => { 'name' => 'Mae S. Waldron' } }, + { 'id' => 102, 'owner_id' => 201, 'owner' => { 'name' => 'Sharon J. Whalen' } }, + { 'id' => 103, 'owner_id' => nil, 'owner' => nil } + ]) + end + end + + context 'when emulated aggregation' do + it "does not emulate aggregation which don't need it" do + filter = Filter.new + aggregation = Aggregation.new(operation: 'Count', groups: [{ field: 'name' }]) + groups = @datasource_decorator.get_collection('person').aggregate(caller, filter, aggregation) + + expect(groups).to eq([ + { value: 1, group: { 'name' => 'Sharon J. Whalen' } }, + { value: 1, group: { 'name' => 'Mae S. Waldron' } }, + { value: 1, group: { 'name' => 'Joseph P. Rodriguez' } } + ]) + end + + it 'gives valid results otherwise' do + filter = Filter.new + aggregation = Aggregation.new(operation: 'Count', groups: [{ field: 'passport:picture:filename' }]) + groups = @datasource_decorator.get_collection('person').aggregate(caller, filter, aggregation, 2) + + expect(groups).to eq([ + { value: 1, group: { 'passport:picture:filename' => 'pic2.jpg' } }, + { value: 1, group: { 'passport:picture:filename' => 'pic1.jpg' } } + ]) + end + end + end end end end From 9316ae9b5fde8331d08b9785b6b01ce7cd78bfff Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 29 Feb 2024 17:37:29 +0100 Subject: [PATCH 28/33] feat(relation): add external relation plugin --- .../collection_customizer.rb | 16 +++++++---- .../decorators/decorators_stack.rb | 4 +-- .../relation/relation_collection_decorator.rb | 2 ++ .../plugins/add_external_relation.rb | 28 +++++++++++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.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 616c6ca7d..450723f34 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 @@ -22,12 +22,6 @@ def collection @stack.datasource.get_collection(@name) end - def use(plugin, options = []) - push_customization( - proc { plugin.run(@datasource_customizer, self, options) } - ) - end - def disable_count push_customization( -> { @stack.schema.get_collection(@name).override_schema(countable: false) } @@ -125,6 +119,16 @@ def add_many_to_many_relation(name, foreign_collection, through_collection, opti }) end + def use(plugin, options = []) + push_customization( + proc { plugin.new.run(@datasource_customizer, self, options) } + ) + end + + def add_external_relation(name, definition) + use(ForestAdminDatasourceCustomizer::Plugins::AddExternalRelation, { name: name }.merge(definition)) + 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 b36ee734a..09c9f7308 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 @@ -3,7 +3,7 @@ module Decorators class DecoratorsStack include ForestAdminDatasourceToolkit::Decorators - attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action + attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action, :relation def initialize(datasource) @customizations = [] @@ -12,9 +12,9 @@ def initialize(datasource) last = DatasourceDecorator.new(last, Empty::EmptyCollectionDecorator) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @early_computed = DatasourceDecorator.new(last, Computed::ComputeCollectionDecorator) - last = @late_computed = DatasourceDecorator.new(last, Computed::ComputeCollectionDecorator) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @relation = DatasourceDecorator.new(last, Relation::RelationCollectionDecorator) + last = @late_computed = DatasourceDecorator.new(last, Computed::ComputeCollectionDecorator) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @search = DatasourceDecorator.new(last, Search::SearchCollectionDecorator) last = @action = DatasourceDecorator.new(last, Action::ActionCollectionDecorator) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb index 9e27b2acc..c41f9d606 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb @@ -9,6 +9,8 @@ class RelationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::Co include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes include ForestAdminDatasourceToolkit::Schema + attr_reader :relations + def initialize(child_collection, datasource) super @relations = {} diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb new file mode 100644 index 000000000..0bf2de74a --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb @@ -0,0 +1,28 @@ +module ForestAdminDatasourceCustomizer + module Plugins + class AddExternalRelation < Plugin + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceToolkit::Utils + include ForestAdminDatasourceCustomizer::Decorators::Computed + + def run(_datasource_customizer, collection_customizer = nil, options = []) + primary_keys = Schema.primary_keys(collection_customizer.collection) + + unless options.key?(:name) && options.key?(:schema) && options.key?(:listRecords) + raise ForestException, 'The options parameter must contains the following keys: `name, schema, listRecords`' + end + + collection_customizer.add_field( + options[:name], + ComputedDefinition.new( + column_type: [options[:schema]], + dependencies: options[:dependencies] || primary_keys, + values: proc { |records, context| + records.map { |record| options[:listRecords].call(record, context) } + } + ) + ) + end + end + end +end From 208934a8dccf5f373de859003b7840f7f0890fa6 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 29 Feb 2024 17:38:47 +0100 Subject: [PATCH 29/33] chore: add tests on collection_customizer --- .../collection_customizer_spec.rb | 164 ++++++++++++++---- 1 file changed, 127 insertions(+), 37 deletions(-) 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 5e59a3986..c4786952b 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 @@ -1,4 +1,5 @@ require 'spec_helper' +require 'shared/caller' module ForestAdminDatasourceCustomizer include ForestAdminDatasourceToolkit @@ -6,7 +7,9 @@ module ForestAdminDatasourceCustomizer include ForestAdminDatasourceToolkit::Components::Query::ConditionTree include ForestAdminDatasourceCustomizer::Decorators::Computed include ForestAdminDatasourceCustomizer::Decorators::Action + include ForestAdminDatasourceCustomizer::Context describe CollectionCustomizer do + include_context 'with caller' before do datasource = Datasource.new collection_book = instance_double( @@ -14,11 +17,11 @@ module ForestAdminDatasourceCustomizer name: 'book', schema: { fields: { - 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), 'title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL]), 'reference' => ColumnSchema.new(column_type: 'String'), 'child_id' => ColumnSchema.new(column_type: 'Number', filter_operators: [Operators::EQUAL, Operators::IN]), - 'author_id' => ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true), + 'author_id' => ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: true, filter_operators: [Operators::EQUAL, Operators::IN]), 'author' => Relations::ManyToOneSchema.new( foreign_key: 'author_id', foreign_collection: 'person', @@ -42,8 +45,8 @@ module ForestAdminDatasourceCustomizer schema: { fields: { 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), - 'person_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), - 'book_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'person_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), + 'book_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]), 'category' => Relations::ManyToOneSchema.new( foreign_key: 'category_id', foreign_key_target: 'id', @@ -83,18 +86,6 @@ module ForestAdminDatasourceCustomizer } ) - # $collectionCategory = new Collection($datasource, 'Category'); - # $collectionCategory->addFields( - # [ - # 'id' => new ColumnSchema(columnType: PrimitiveType::NUMBER, filterOperators: [Operators::EQUAL, Operators::IN], isPrimaryKey: true), - # 'label' => new ColumnSchema(columnType: PrimitiveType::STRING), - # 'books' => new OneToManySchema( - # originKey: 'categoryId', - # originKeyTarget: 'id', - # foreignCollection: 'Book', - # ), - # ] - # ); collection_category = instance_double( Collection, name: 'category', @@ -167,27 +158,26 @@ module ForestAdminDatasourceCustomizer expect(computed_collection.get_computed('test').get_values(data)).to eq(['Foundation-2022', 'Harry Potter-2022']) end - # TODO: uncomment this test when the relation decorator will be implemented - # it 'should add a field to late collection' do - # stack = @datasource_customizer.stack - # allow(stack.late_computed).to receive(:get_collection).with('book').and_return(@datasource_customizer.stack.late_computed.get_collection('book')) - # - # field_definition = ComputedDefinition.new( - # column_type: PrimitiveType::STRING, - # dependencies: ['id'], - # values: proc { |records| records.map { |record| record['id'].to_s + '-Foo' } }, - # ) - # - # customizer = CollectionCustomizer.new(@datasource_customizer, @datasource_customizer.stack, 'book') - # # $customizer->addManyToOneRelation('mySelf', 'Book', 'id', 'childId'); - # customizer.add_field('mySelf', field_definition) - # @datasource_customizer.datasource({}) - # - # computed_collection = @datasource_customizer.stack.late_computed.get_collection('book') - # - # expect(computed_collection.fields).to have_key('mySelf') - # expect(computed_collection.get_computed('mySelf').get_values(data)).to eq(['1-Foo', '2-Foo']) - # end + it 'adds a field to late collection' do + stack = @datasource_customizer.stack + allow(stack.late_computed).to receive(:get_collection).with('book').and_return(@datasource_customizer.stack.early_computed.get_collection('book')) + + field_definition = ComputedDefinition.new( + column_type: PrimitiveType::STRING, + dependencies: ['id'], + values: proc { |records| records.map { |record| "#{record["id"]}-Foo" } } + ) + + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') + customizer.add_many_to_one_relation('mySelf', 'book', { foreign_key: 'id', foreign_key_target: 'child_id' }) + customizer.add_field('mySelf', field_definition) + @datasource_customizer.datasource({}) + + computed_collection = @datasource_customizer.stack.late_computed.get_collection('book') + + expect(computed_collection.fields).to have_key('mySelf') + expect(computed_collection.get_computed('mySelf').get_values(data)).to eq(['1-Foo', '2-Foo']) + end end context 'when using replace_search' do @@ -216,5 +206,105 @@ module ForestAdminDatasourceCustomizer expect(@datasource_customizer.stack.schema.get_collection('book').schema[:countable]).to be false 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') + customizer.add_many_to_one_relation('myAuthor', 'person', { foreign_key: 'author_id' }) + @datasource_customizer.datasource({}) + + relation_collection = @datasource_customizer.stack.relation.get_collection('book') + + expect(relation_collection.relations).to have_key('myAuthor') + expect(relation_collection.relations['myAuthor']).to be_a(Relations::ManyToOneSchema) + expect(relation_collection.relations['myAuthor'].foreign_collection).to eq('person') + expect(relation_collection.relations['myAuthor'].foreign_key).to eq('author_id') + expect(relation_collection.relations['myAuthor'].foreign_key_target).to eq('id') + end + + it 'adds a one to one' 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' }) + @datasource_customizer.datasource({}) + + relation_collection = @datasource_customizer.stack.relation.get_collection('person') + + expect(relation_collection.relations).to have_key('myBookAuthor') + expect(relation_collection.relations['myBookAuthor']).to be_a(Relations::OneToOneSchema) + expect(relation_collection.relations['myBookAuthor'].foreign_collection).to eq('book_person') + expect(relation_collection.relations['myBookAuthor'].origin_key).to eq('person_id') + expect(relation_collection.relations['myBookAuthor'].origin_key_target).to eq('id') + end + + it 'adds a one to many' do + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') + customizer.add_one_to_many_relation('myBookAuthors', 'book_person', { origin_key: 'person_id', origin_key_target: 'id' }) + @datasource_customizer.datasource({}) + + relation_collection = @datasource_customizer.stack.relation.get_collection('person') + + expect(relation_collection.relations).to have_key('myBookAuthors') + expect(relation_collection.relations['myBookAuthors']).to be_a(Relations::OneToManySchema) + expect(relation_collection.relations['myBookAuthors'].foreign_collection).to eq('book_person') + expect(relation_collection.relations['myBookAuthors'].origin_key).to eq('person_id') + expect(relation_collection.relations['myBookAuthors'].origin_key_target).to eq('id') + end + + it 'adds a many to many' do + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'person') + customizer.add_many_to_many_relation('myBooks', 'book', 'book_person', { foreign_key: 'book_id', foreign_key_target: 'id', origin_key: 'person_id', origin_key_target: 'id' }) + @datasource_customizer.datasource({}) + + relation_collection = @datasource_customizer.stack.relation.get_collection('person') + + expect(relation_collection.relations).to have_key('myBooks') + expect(relation_collection.relations['myBooks']).to be_a(Relations::ManyToManySchema) + expect(relation_collection.relations['myBooks'].foreign_collection).to eq('book') + expect(relation_collection.relations['myBooks'].through_collection).to eq('book_person') + expect(relation_collection.relations['myBooks'].foreign_key).to eq('book_id') + expect(relation_collection.relations['myBooks'].foreign_key_target).to eq('id') + expect(relation_collection.relations['myBooks'].origin_key).to eq('person_id') + expect(relation_collection.relations['myBooks'].origin_key_target).to eq('id') + end + end + + context 'when adding external relation' do + it 'calls addField' do + data = [{ 'id' => 1, 'title' => 'Dune' }] + stack = @datasource_customizer.stack + allow(stack.late_computed).to receive(:get_collection).with('book').and_return(@datasource_customizer.stack.early_computed.get_collection('book')) + + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') + customizer.add_external_relation( + 'tags', + { + schema: ['etag' => 'String', 'selfLink' => 'String'], + listRecords: proc { + [ + { 'etag' => 'OTD2tB19qn4', 'selfLink' => 'https://www.googleapis.com/books/v1/volumes/_ojXNuzgHRcC' }, + { 'etag' => 'NsxMT6kCCVs', 'selfLink' => 'https://www.googleapis.com/books/v1/volumes/RJxWIQOvoZUC' } + ] + } + } + ) + @datasource_customizer.datasource({}) + + computed_collection = @datasource_customizer.stack.late_computed.get_collection('book') + + expect(computed_collection.fields).to have_key('tags') + expect(computed_collection.get_computed('tags').get_values(data, CollectionCustomizationContext.new(computed_collection, caller))).to eq([ + [ + { 'etag' => 'OTD2tB19qn4', 'selfLink' => 'https://www.googleapis.com/books/v1/volumes/_ojXNuzgHRcC' }, + { 'etag' => 'NsxMT6kCCVs', 'selfLink' => 'https://www.googleapis.com/books/v1/volumes/RJxWIQOvoZUC' } + ] + ]) + end + + it 'throwns an exception when the plugin have options keys missing' do + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') + customizer.add_external_relation('tags', {}) + expect { @datasource_customizer.datasource({}) }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, '🌳🌳🌳 The options parameter must contains the following keys: `name, schema, listRecords`') + end + end end end From ed995211602924f2809c8a32e2cca61f0c7aef66 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 29 Feb 2024 17:59:40 +0100 Subject: [PATCH 30/33] fix: test --- .../components/query/projection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index 9b33925db..c1075e409 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -78,7 +78,7 @@ def re_project(record) end def union(other_arrays) - Projection.new(other_arrays.union(self)) + Projection.new(other_arrays.to_a.union(self)) end end end From f2e789c184e5de23a47b459dbc5bbcb3e99b70c6 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 29 Feb 2024 18:05:33 +0100 Subject: [PATCH 31/33] fix: import schema utils --- .../plugins/add_external_relation.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb index 0bf2de74a..5c2895fe7 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb @@ -2,11 +2,10 @@ module ForestAdminDatasourceCustomizer module Plugins class AddExternalRelation < Plugin include ForestAdminDatasourceToolkit::Exceptions - include ForestAdminDatasourceToolkit::Utils include ForestAdminDatasourceCustomizer::Decorators::Computed def run(_datasource_customizer, collection_customizer = nil, options = []) - primary_keys = Schema.primary_keys(collection_customizer.collection) + primary_keys = ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection_customizer.collection) unless options.key?(:name) && options.key?(:schema) && options.key?(:listRecords) raise ForestException, 'The options parameter must contains the following keys: `name, schema, listRecords`' From e0f05b46cee5a8a8fc05a5c729c12b44518e3d48 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 Mar 2024 10:26:19 +0100 Subject: [PATCH 32/33] fix: tests --- .../nodes/condition_tree_leaf.rb | 3 ++- .../components/query/filter_factory.rb | 4 ++-- .../components/query/sort.rb | 6 +++--- .../nodes/condition_tree_spec.rb | 2 +- .../components/query/sort_spec.rb | 20 +++++++++---------- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb index e7bee310c..493821169 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb @@ -61,7 +61,8 @@ def replace_leafs def match(record, collection, timezone) field_value = Record.field_value(record, @field) - column_type = Utils::Collection.get_field_schema(collection, @field).column_type + column_type = ForestAdminDatasourceToolkit::Utils::Collection.get_field_schema(collection, + @field).column_type supported = [ Operators::IN, diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb index 726df735f..520b39374 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb @@ -105,8 +105,8 @@ def self.get_previous_x_days_period(leaf, timezone, operator) def self.make_through_filter(collection, id, relation_name, caller, base_foreign_filter) relation = collection.schema[:fields][relation_name] - origin_value = Utils::Collection.get_value(collection, caller, id, relation.origin_key_target) - foreign_relation = Utils::Collection.get_through_target(collection, relation_name) + origin_value = ForestAdminDatasourceToolkit::Utils::Collection.get_value(collection, caller, id, relation.origin_key_target) + foreign_relation = ForestAdminDatasourceToolkit::Utils::Collection.get_through_target(collection, relation_name) # Optimization for many to many when there is not search/segment (saves one query) if foreign_relation && base_foreign_filter.nestable? diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index ae7719e37..86c30102e 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -10,9 +10,9 @@ def replace_clauses(...) self.class.new( map(...) .reduce(self.class.new) do |memo, cb_result| - return memo.union(cb_result) if cb_result.is_a?(self.class) + return memo.union(cb_result) if cb_result.is_a?(self.class) || cb_result.is_a?(Array) - memo.union(cb_result) + memo.union([cb_result]) end ) end @@ -33,7 +33,7 @@ def inverse def unnest prefix = first[:field].split(':')[0] - raise 'Cannot unnest sort_utils.' unless all? { |ob| ob[:field].start_with?(prefix) } + raise 'Cannot unnest sort.' unless all? { |ob| ob[:field].start_with?(prefix) } self.class.new(map do |ob| { field: ob[:field][prefix.length + 1, ob[:field].length - prefix.length - 1], diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb index 9324e858a..3bf9c25f3 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_spec.rb @@ -151,7 +151,7 @@ module Nodes end it 'when calling projection should work' do - expect(@condition_tree_branch.projection).to eq(Projection.new(['column1', 'column2'])) + expect(@condition_tree_branch.projection).to include('column1', 'column2') end it 'when calling apply should work' do diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb index dd54c41d0..733058217 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb @@ -15,18 +15,18 @@ module Query it('apply should sort records') do records = [ - { column1: 2, column2: 2 }, - { column1: 1, column2: 1 }, - { column1: 1, column2: 1 }, - { column1: 1, column2: 2 }, - { column1: 2, column2: 1 } + { 'column1' => 2, 'column2' => 2 }, + { 'column1' => 1, 'column2' => 1 }, + { 'column1' => 1, 'column2' => 1 }, + { 'column1' => 1, 'column2' => 2 }, + { 'column1' => 2, 'column2' => 1 } ] expect(sort.apply(records)).to eq([ - { column1: 1, column2: 2 }, - { column1: 1, column2: 1 }, - { column1: 1, column2: 1 }, - { column1: 2, column2: 2 }, - { column1: 2, column2: 1 } + { 'column1' => 1, 'column2' => 2 }, + { 'column1' => 1, 'column2' => 1 }, + { 'column1' => 1, 'column2' => 1 }, + { 'column1' => 2, 'column2' => 2 }, + { 'column1' => 2, 'column2' => 1 } ]) end From c541ae0bad4c878fd27fc7f0328b90b708fbc2c1 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 Mar 2024 11:34:13 +0100 Subject: [PATCH 33/33] fix: sort & projection replace methods --- .../components/query/projection.rb | 8 +++++--- .../components/query/sort.rb | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index c1075e409..c15a84d4d 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -49,9 +49,11 @@ def replace(...) Projection.new( map(...) .reduce(Projection.new) do |memo, path| - return memo.union([path]) if path.is_a?(String) - - memo.union(path) + if path.is_a?(String) + memo.union([path]) + else + memo.union(path) + end end ) end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb index 86c30102e..c33e6186c 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -7,12 +7,14 @@ def projection end def replace_clauses(...) - self.class.new( + Sort.new( map(...) - .reduce(self.class.new) do |memo, cb_result| - return memo.union(cb_result) if cb_result.is_a?(self.class) || cb_result.is_a?(Array) - - memo.union([cb_result]) + .reduce(Sort.new) do |memo, clause| + if clause.is_a?(Array) || clause.is_a?(self.class) + memo.union(clause) + else + memo.union([clause]) + end end ) end