diff --git a/.rubocop.yml b/.rubocop.yml index e232d78e1..0f7ae5f25 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' @@ -259,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_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/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..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 @@ -104,6 +104,18 @@ def self.parse_search_extended(args) extended != '0' end + + def self.parse_sort(collection, args) + sort_string = args.dig(:params, :sort) + + return SortUtils::SortFactory.by_primary_keys(collection) unless sort_string + + sort = Sort.new([ + { field: sort_string.gsub(/^-/, '').tr('.', ':'), ascending: !sort_string.start_with?('-') } + ]) + + ForestAdminDatasourceToolkit::Validations::SortValidator.validate(collection, sort) + end end end end 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| 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' } 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 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..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) } @@ -58,10 +52,93 @@ 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 + + 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) @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 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..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,7 +12,10 @@ def initialize(datasource) last = DatasourceDecorator.new(last, Empty::EmptyCollectionDecorator) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) last = @early_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) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) 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..c41f9d606 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb @@ -0,0 +1,268 @@ +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 + + attr_reader :relations + + 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) } + records = child_collection.list(caller, new_filter, new_projection) + return records if new_projection.equals(projection) + + records = re_project_in_place(caller, records, projection) + + projection.apply(records) + end + + def aggregate(caller, filter, aggregation, limit = nil) + 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(child_schema) + @relations.each do |name, relation| + child_schema[:fields][name] = relation + end + + child_schema + end + + 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.merge(field: field) + end + end + }) + end + + def relation_with_optional_fields(partial_joint) + relation = partial_joint.dup + target = datasource.get_collection(partial_joint[:foreign_collection]) + + case relation[:type] + when 'ManyToOne' + 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 = 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' + + 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) + 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) + 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 + field_schema = schema[:fields][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 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 + + result + end + + def rewrite_leaf(caller, leaf) + prefix = leaf.field.split(':').first + field_schema = schema[:fields][prefix] + return leaf if field_schema.type == 'Column' + + relation = datasource.get_collection(field_schema.foreign_collection) + result = leaf + + if !@relations.key?(prefix) + result = relation.rewrite_leaf(caller, leaf.unnest).nest(prefix) + elsif field_schema.type == 'ManyToOne' + records = relation.list( + caller, + Filter.new(condition_tree: leaf.unnest), + Projection.new([field_schema.foreign_key_target]) + ) + + 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([field_schema.origin_key]) + ) + + result = ConditionTreeLeaf.new(field_schema.origin_key_target, 'In', records.map do |record| + record[field_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 + + records + end + + def re_project_relation_in_place(caller, records, name, projection) + 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 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[field_schema.foreign_key_target] == record[field_schema.foreign_key] } + end + 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[field_schema.origin_key] == record[field_schema.origin_key_target] } + end + end + end + end + end + end +end 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..5c2895fe7 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb @@ -0,0 +1,27 @@ +module ForestAdminDatasourceCustomizer + module Plugins + class AddExternalRelation < Plugin + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceCustomizer::Decorators::Computed + + def run(_datasource_customizer, collection_customizer = nil, options = []) + 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`' + 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 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 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..25fb73b35 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator_spec.rb @@ -0,0 +1,617 @@ +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') + } + }, + datasource: datasource + ) + + 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 + allow(collection_passport).to receive(:aggregate) do |caller, _filter, aggregation, limit| + aggregation.apply(passport_records, caller.timezone, limit) + 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]) + } + }, + datasource: datasource + ) + + 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 + 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) + 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 + + 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 + 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/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..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, @@ -81,7 +82,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 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/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index bd620c768..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 @@ -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,10 +48,12 @@ def unnest def replace(...) Projection.new( map(...) - .reduce(Projection.new) do |memo, path| - return memo.union([path]) if path.is_a?(String) - - memo.union(path) + .reduce(Projection.new) do |memo, path| + if path.is_a?(String) + memo.union([path]) + else + memo.union(path) + end end ) end @@ -58,6 +61,27 @@ 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 + + def union(other_arrays) + Projection.new(other_arrays.to_a.union(self)) + end 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 new file mode 100644 index 000000000..c33e6186c --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort.rb @@ -0,0 +1,68 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Sort < Array + def projection + Projection.new(map { |clause| clause[:field] }) + end + + def replace_clauses(...) + Sort.new( + map(...) + .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 + + 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| + comparison = 0 + (0..length - 1).each do |i| + field = self[i][:field] + ascending = self[i][:ascending] + break unless comparison.zero? + + value_on_a = ForestAdminDatasourceToolkit::Utils::Record.field_value(a, field) + 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 + + comparison + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb new file mode 100644 index 000000000..78f5cce7e --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/sort_utils/sort_factory.rb @@ -0,0 +1,16 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + module SortUtils + class SortFactory + def self.by_primary_keys(collection) + ForestAdminDatasourceToolkit::Components::Query::Sort.new( + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) + .map { |pk| { field: pk, ascending: true } } + ) + end + end + end + end + end +end 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..b2d2fb965 --- /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_utils.ascending value: #{s[:ascending]}" + end + end + end + end + end +end 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/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 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..733058217 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/sort_spec.rb @@ -0,0 +1,67 @@ +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 + + 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 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 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