diff --git a/.rubocop.yml b/.rubocop.yml index b611d6690..db99a7b38 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -210,6 +210,7 @@ Metrics/MethodLength: - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' + - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb' - 'packages/forest_admin_datasource_active_record/spec/dummy/**/*' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/flattener.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb' @@ -238,6 +239,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_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/binary/binary_collection_decorator.rb' diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb index bf9633e74..8981cfbf7 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb @@ -53,8 +53,11 @@ class FrontendValidationUtils def self.convert_validation_list(column) return [] if column.validations.empty? - rules = column.validations.dup.map { |rule| simplify_rule(column.column_type, rule) } - remove_duplicates_in_place(rules).map { |rule| SUPPORTED[rule[:operator]].call(rule) } + rules = column.validations.map { |rule| simplify_rule(column.column_type, rule) } + remove_duplicates_in_place(rules) + + rules.filter { |rule| rule.is_a?(Hash) && rule.key?(:operator) } + .map { |rule| SUPPORTED[rule[:operator]].call(rule) } end def self.simplify_rule(column_type, rule) @@ -74,11 +77,16 @@ def self.simplify_rule(column_type, rule) timezone = 'Europe/Paris' # we're sending the schema => use random tz tree = ConditionTreeEquivalent.get_equivalent_tree(leaf, operators, column_type, timezone) - if tree.is_a? Nodes::ConditionTreeLeaf - [tree] - else - tree.conditions - end + conditions = if tree.is_a? Nodes::ConditionTreeLeaf + [tree] + else + tree.conditions + end + + return conditions.filter { |c| c.is_a?(Nodes::ConditionTreeLeaf) } + .filter { |c| c.operator != Operators::EQUAL && c.operator != Operators::NOT_EQUAL } + .map { |c| simplify_rule(column_type, operator: c.operator, value: c.value) } + .first rescue StandardError # Just ignore errors, they mean that the operator is not supported by the frontend # and that we don't have an automatic conversion for it. @@ -91,36 +99,44 @@ def self.simplify_rule(column_type, rule) [] end - # The frontend crashes when it receives multiple rules of the same type. - # This method merges the rules which can be merged and drops the others. def self.remove_duplicates_in_place(rules) used = {} - rules.each_with_index do |rule, key| - if used.key?(rule[:operator]) - rule = rules[rule[:operator]] - new_rule = rule - rules.delete(key) - rules[used[rule[:operator]]] = merge_into(rule, new_rule) - else - used[rule[:operator]] = key + + i = 0 + while i < rules.length + rule = rules[i] + if rule.is_a?(Hash) && rule.key?(:operator) + if used.key?(rule[:operator]) + existing_rule = rules[used[rule[:operator]]] + new_rule = rules.delete_at(i) + + merge_into(existing_rule, new_rule) + # Adjust the index to account for the removed element + i -= 1 + else + used[rule[:operator]] = i + end end + i += 1 end - - rules end - def merge_into(rule, new_rule) - if [Operators::GREATER_THAN, Operators::AFTER, Operators::LONGER_THAN].include? rule[:operator] + # rubocop:disable Style/EmptyElse + def self.merge_into(rule, new_rule) + case rule[:operator] + when Operators::GREATER_THAN, Operators::AFTER, Operators::LONGER_THAN rule[:value] = [rule[:value], new_rule[:value]].max - elsif [Operators::LESS_THAN, Operators::BEFORE, Operators::SHORTER_THAN].include? rule[:operator] + when Operators::LESS_THAN, Operators::BEFORE, Operators::SHORTER_THAN rule[:value] = [rule[:value], new_rule[:value]].min - elsif rule[:operator] == Operators::MATCH - # TODO + when Operators::MATCH + regex = rule[:value].gsub(/\W/, '') + new_regex = new_rule[:value].gsub(/\W/, '') + rule[:value] = "/^(?=#{regex})(?=#{new_regex}).*$/i" + else + # Ignore the rules that we can't deduplicate (we could log a warning here). end - # else Ignore the rules that we can't deduplicate (we could log a warning here). - - rule end + # rubocop:enable Style/EmptyElse end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/frontend_validation_utils_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/frontend_validation_utils_spec.rb new file mode 100644 index 000000000..06080db14 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/frontend_validation_utils_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +module ForestAdminAgent + module Utils + module Schema + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + describe FrontendValidationUtils do + subject(:frontend_validation_utils) { described_class } + + context 'when using convert_validation_list' do + it 'works with null validation' do + column = column_build + expect(frontend_validation_utils.convert_validation_list(column)).to be_empty + end + + it 'works with empty validation' do + column = column_build(validations: []) + expect(frontend_validation_utils.convert_validation_list(column)).to be_empty + end + + it 'works with supported handlers (strings)' do + column = numeric_primary_key_build(validations: [ + { operator: Operators::PRESENT }, + { operator: Operators::LESS_THAN, value: 34 }, + { operator: Operators::GREATER_THAN, value: 60 } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Field is required', type: 'is present' }, + { message: 'Value must be lower than 34', type: 'is less than', value: 34 }, + { message: 'Value must be greater than 60', type: 'is greater than', value: 60 } + ]) + end + + it 'works with supported handlers (date)' do + column = column_build(column_type: 'Date', validations: [ + { operator: Operators::BEFORE, value: '2010-01-01T00:00:00Z' }, + { operator: Operators::AFTER, value: '2010-01-01T00:00:00Z' } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Value must be before 2010-01-01T00:00:00Z', type: 'is before', value: '2010-01-01T00:00:00Z' }, + { message: 'Value must be after 2010-01-01T00:00:00Z', type: 'is after', value: '2010-01-01T00:00:00Z' } + ]) + end + + it 'works with supported handlers (string)' do + column = column_build(column_type: 'Number', validations: [ + { operator: Operators::LONGER_THAN, value: 34 }, + { operator: Operators::SHORTER_THAN, value: 60 }, + { operator: Operators::CONTAINS, value: 'abc' }, + { operator: Operators::MATCH, value: '/abc/' } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Value must be longer than 34 characters', type: 'is longer than', value: 34 }, + { message: 'Value must be shorter than 60 characters', type: 'is shorter than', value: 60 }, + { message: 'Value must contain abc', type: 'is contains', value: 'abc' }, + { message: 'Value must match /abc/', type: 'is like', value: '/abc/' } + ]) + end + + it 'works with supported handlers (fake enum)' do + column = column_build(column_type: 'String', validations: [ + { operator: Operators::IN, value: %w[a b c] } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Value must match /(a|b|c)/g', type: 'is like', value: '/(a|b|c)/g' } + ]) + end + + it 'works with duplication' do + column = numeric_primary_key_build(validations: [ + { operator: Operators::PRESENT }, + { operator: Operators::PRESENT }, + { operator: Operators::LESS_THAN, value: 34 }, + { operator: Operators::LESS_THAN, value: 40 }, + { operator: Operators::GREATER_THAN, value: 60 }, + { operator: Operators::GREATER_THAN, value: 80 }, + { operator: Operators::GREATER_THAN, value: 70 }, + { operator: Operators::MATCH, value: '/a/' }, + { operator: Operators::MATCH, value: '/b/' } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Field is required', type: 'is present' }, + { message: 'Value must be lower than 34', type: 'is less than', value: 34 }, + { message: 'Value must be greater than 80', type: 'is greater than', value: 80 }, + { message: 'Value must match /^(?=a)(?=b).*$/i', type: 'is like', value: '/^(?=a)(?=b).*$/i' } + ]) + end + + it 'works with rule expansion (not in with null)' do + column = column_build(column_type: 'String', validations: [ + { operator: Operators::NOT_IN, value: ['a', 'b', nil] } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Value must match /(?!(a|b))/g', type: 'is like', value: '/(?!(a|b))/g' } + ]) + end + + it 'skips validation which cannot be translated (depends on current time)' do + column = column_build(column_type: 'Date', validations: [ + { operator: Operators::PREVIOUS_QUARTER } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to be_empty + end + + it 'skips validation which cannot be translated (fake enum with null)' do + column = column_build(column_type: 'String', validations: [ + { operator: Operators::IN, value: ['a', 'b', nil] } + ]) + expect(frontend_validation_utils.convert_validation_list(column)).to eq([ + { message: 'Value must match /(a|b)/g', type: 'is like', value: '/(a|b)/g' } + ]) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb index 2c1231054..2b5b8e852 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb @@ -15,6 +15,10 @@ def initialize(datasource, model) enable_count end + def native_driver + ActiveRecord::Base.connection + end + def list(_caller, filter, projection) query = Utils::Query.new(self, projection, filter) diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb index a3407e4ed..3ef71e225 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb @@ -82,7 +82,7 @@ def compute_main_operator(condition_tree, aggregator) when Operators::LIKE @query = @query.send(aggregator, @query.where(@arel_table[field.to_sym].matches(value))) when Operators::INCLUDES_ALL - # TODO: to implement + @query = @query.send(aggregator, @query.where(@arel_table[field.to_sym].matches_all(value))) end @query diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection_spec.rb new file mode 100644 index 000000000..7167ecf03 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminDatasourceCustomizer + module Context + module RelaxedWrappers + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Decorators + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + describe RelaxedCollection do + include_context 'with caller' + subject(:relaxed_collection) { described_class } + let(:compute_collection_decorator) { ForestAdminDatasourceCustomizer::Decorators::Computed::ComputeCollectionDecorator } + + before do + datasource = Datasource.new + @collection_book = collection_build( + name: 'book', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::LONGER_THAN, Operators::PRESENT]) + } + } + ) + + datasource.add_collection(@collection_book) + datasource_decorator = DatasourceDecorator.new(datasource, compute_collection_decorator) + + @new_books = datasource_decorator.get_collection('book') + end + + context 'when native_driver is called' do + it 'returns the native driver' do + allow(@new_books).to receive(:native_driver).and_return('a native driver') + relaxed_collection = described_class.new(@new_books, caller) + + expect(relaxed_collection.native_driver).to eq('a native driver') + end + end + + context 'when schema is called' do + it 'returns the schema' do + allow(@new_books).to receive(:schema).and_return({ fields: { 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true) } }) + relaxed_collection = described_class.new(@new_books, caller) + + expect(relaxed_collection.schema).to be_instance_of(Hash) + expect(relaxed_collection.schema).to include(:fields) + expect(relaxed_collection.schema[:fields]).to have_key('id') + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.rb index d4c9ab1bb..350be0378 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.rb @@ -6,7 +6,7 @@ class Collection < Components::Contracts::CollectionContract :schema, :native_driver - def initialize(datasource, name, native_driver: nil) + def initialize(datasource, name, native_driver = nil) super() @datasource = datasource @name = name diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/collection_decorator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/collection_decorator.rb index 9e8fc5a69..d58e6f029 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/collection_decorator.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/collection_decorator.rb @@ -13,7 +13,7 @@ def initialize(child_collection, datasource) end def native_driver - # TODO + child_collection.native_driver end def schema diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/decorators/collection_decorator_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/decorators/collection_decorator_spec.rb new file mode 100644 index 000000000..e5860f822 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/decorators/collection_decorator_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Decorators + describe CollectionDecorator do + before do + datasource = Datasource.new + @collection_book = collection_build( + name: 'book', + schema: { + fields: { + 'id' => ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(column_type: 'String') + } + } + ) + + datasource.add_collection(@collection_book) + end + + context 'when native_driver is called' do + it 'returns the native driver' do + allow(@collection_book).to receive(:native_driver).and_return('a native driver') + + expect(@collection_book.native_driver).to eq('a native driver') + end + end + end + end +end