From 4f2c79254fbdede444fffe94a667e4f3027752c4 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 Mar 2024 10:09:05 +0100 Subject: [PATCH 1/6] feat(validation): add new decorator --- .../collection_customizer.rb | 19 ++++- .../validation_collection_decorator.rb | 79 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.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 450723f34..b8b26f63e 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 @@ -15,11 +15,11 @@ def add_action(name, definition) end def schema - @stack.datasource.get_collection(@name).schema + @stack.validation.get_collection(@name).schema end def collection - @stack.datasource.get_collection(@name) + @stack.validation.get_collection(@name) end def disable_count @@ -129,6 +129,21 @@ def add_external_relation(name, definition) use(ForestAdminDatasourceCustomizer::Plugins::AddExternalRelation, { name: name }.merge(definition)) end + # public function addFieldValidation(string $name, string $operator, $value = null): self + # { + # return $this->pushCustomization( + # fn () => $this->stack + # ->validation + # ->getCollection($this->name) + # ->addValidation($name, ['operator' => $operator, 'value' => $value]) + # ); + # } + def add_field_validation(name, operator, value = nil) + push_customization( + proc { @stack.validation.get_collection(@name).add_validation(name, { operator: operator, value: value }) } + ) + end + private def push_customization(customization) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb new file mode 100644 index 000000000..b95130a67 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb @@ -0,0 +1,79 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Validation + class ValidationCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Validations + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Exceptions + attr_reader :validation + + def initialize(child_collection, datasource) + super + @validation = {} + end + + def add_validation(name, validation) + FieldValidator.validate(child_collection, name) + + field = @child_collection.schema[:fields][name] + if field.nil? || field.type != 'Column' + raise ForestException, + 'Cannot add validators on a relation, use the foreign key instead' + end + raise ForestException, 'Cannot add validators on a readonly field' if field.is_read_only + + @validation[name] ||= [] + @validation[name].push(validation) + mark_schema_as_dirty + end + + def create(caller, data) + data.each { |record| validate(record, caller.timezone, true) } + super(caller, data) + end + + def update(caller, filter, patch) + validate(patch, caller.timezone, false) + super(caller, filter, patch) + end + + def refine_schema(child_schema) + @validation.each do |name, rules| + validation = child_schema[name].validation + rules + child_schema[name].validation = validation + end + end + + private + + def validate(record, timezone, all_fields) + @validation.each do |name, rules| + next unless all_fields || record.key?(name) + + # When setting a field to nil, only the "Present" validator is relevant + applicable_rules = record[name].nil? ? rules.select { |r| r[:operator] == Operators::PRESENT } : rules + + applicable_rules.each do |validator| + raw_leaf = { field: name }.merge(validator) + tree = ConditionTreeFactory.from_array(raw_leaf) + next if tree.match(record, self, timezone) + + message = "#{name} failed validation rule :" + rule = if validator.key?('value') + "#{validator["operator"]}(#{if validator["value"].is_a?(Array) + validator["value"].join(",") + else + validator["value"] + end})" + else + validator['operator'] + end + + raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, "#{message} #{rule}" + end + end + end + end + end + end +end From 3187b447b100812d936b484e1c63e754f69b5631 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 Mar 2024 17:17:20 +0100 Subject: [PATCH 2/6] fix: validation decorator --- .../validation_collection_decorator.rb | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb index b95130a67..e091f36f1 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator.rb @@ -13,7 +13,7 @@ def initialize(child_collection, datasource) end def add_validation(name, validation) - FieldValidator.validate(child_collection, name) + FieldValidator.validate(self, name) field = @child_collection.schema[:fields][name] if field.nil? || field.type != 'Column' @@ -29,19 +29,22 @@ def add_validation(name, validation) def create(caller, data) data.each { |record| validate(record, caller.timezone, true) } - super(caller, data) + child_collection.create(caller, data) end def update(caller, filter, patch) validate(patch, caller.timezone, false) - super(caller, filter, patch) + child_collection.update(caller, filter, patch) end def refine_schema(child_schema) @validation.each do |name, rules| - validation = child_schema[name].validation + rules - child_schema[name].validation = validation + field = child_schema[:fields][name] + field.validations = (field.validations || []).concat(rules) + child_schema[:fields][name] = field end + + child_schema end private @@ -55,18 +58,18 @@ def validate(record, timezone, all_fields) applicable_rules.each do |validator| raw_leaf = { field: name }.merge(validator) - tree = ConditionTreeFactory.from_array(raw_leaf) + tree = ConditionTreeFactory.from_plain_object(raw_leaf) next if tree.match(record, self, timezone) message = "#{name} failed validation rule :" - rule = if validator.key?('value') - "#{validator["operator"]}(#{if validator["value"].is_a?(Array) - validator["value"].join(",") - else - validator["value"] - end})" + rule = if validator.key?(:value) + "#{validator[:operator]}(#{if validator[:value].is_a?(Array) + validator[:value].join(",") + else + validator[:value] + end})" else - validator['operator'] + validator[:operator] end raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, "#{message} #{rule}" From 244d987370add9068207353c148fd36fed6de65c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 Mar 2024 17:17:47 +0100 Subject: [PATCH 3/6] feat: add validation to stack --- .../decorators/decorators_stack.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 09c9f7308..0fe85510e 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, :relation + attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action, :relation, :validation def initialize(datasource) @customizations = [] @@ -19,6 +19,7 @@ def initialize(datasource) last = @search = DatasourceDecorator.new(last, Search::SearchCollectionDecorator) last = @action = DatasourceDecorator.new(last, Action::ActionCollectionDecorator) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) + last = @validation = DatasourceDecorator.new(last, Validation::ValidationCollectionDecorator) @datasource = last end From fd80e2a1821295595a0f41b56c61dcb3a815a0c2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 5 Mar 2024 17:52:42 +0100 Subject: [PATCH 4/6] chore: add tests on validation --- .../collection_customizer_spec.rb | 16 ++ .../validation_collection_decorator_spec.rb | 145 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator_spec.rb 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 c4786952b..e1bb2e16e 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 @@ -306,5 +306,21 @@ module ForestAdminDatasourceCustomizer expect { @datasource_customizer.datasource({}) }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, '🌳🌳🌳 The options parameter must contains the following keys: `name, schema, listRecords`') end end + + context 'when adding a field validation' do + it 'adds a validation rule' do + stack = @datasource_customizer.stack + allow(stack.validation).to receive(:get_collection).with('book').and_return(@datasource_customizer.stack.validation.get_collection('book')) + + customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book') + customizer.add_field_validation('title', Operators::LONGER_THAN, 5) + @datasource_customizer.datasource({}) + + validation_collection = @datasource_customizer.stack.validation.get_collection('book') + + expect(validation_collection.validation).to have_key('title') + expect(validation_collection.validation['title']).to eq([{ operator: Operators::LONGER_THAN, value: 5 }]) + end + end end end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator_spec.rb new file mode 100644 index 000000000..97a163f7c --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/validation/validation_collection_decorator_spec.rb @@ -0,0 +1,145 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminDatasourceCustomizer + module Decorators + module Validation + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Exceptions + + describe ValidationCollectionDecorator do + include_context 'with caller' + let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } + + before do + @collection_book = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'book', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, is_read_only: true), + 'author_id' => ColumnSchema.new(column_type: 'String'), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_collection: 'person', + foreign_key_target: 'id' + ), + 'title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::LONGER_THAN, Operators::PRESENT]), + 'sub_title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::LONGER_THAN]) + } + }, + datasource: datasource + ) + + @collection_person = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'person', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'first_name' => ColumnSchema.new(column_type: 'String'), + 'last_name' => ColumnSchema.new(column_type: 'String'), + 'book' => Relations::OneToOneSchema.new( + origin_key: 'author_id', + origin_key_target: 'id', + foreign_collection: 'book' + ) + } + }, + datasource: datasource + ) + + datasource.add_collection(@collection_book) + datasource.add_collection(@collection_person) + + datasource_decorator = ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator.new(datasource, described_class) + + @decorated_book = datasource_decorator.get_collection('book') + @decorated_person = datasource_decorator.get_collection('person') + end + + it 'addValidation() should throw if the field does not exists' do + expect { @decorated_book.add_validation('__dontExist', { operator: Operators::PRESENT }) }.to raise_error(ValidationError, "🌳🌳🌳 Column not found: 'book.__dontExist'") + end + + it 'addValidation() should throw if the field is readonly' do + expect { @decorated_book.add_validation('id', { operator: Operators::PRESENT }) }.to raise_error(ForestException, '🌳🌳🌳 Cannot add validators on a readonly field') + end + + it 'addValidation() should throw if the field is a relation' do + expect { @decorated_book.add_validation('author', { operator: Operators::PRESENT }) }.to raise_error(ValidationError, "🌳🌳🌳 Unexpected field type: 'book.author' (found 'ManyToOne' expected 'Column')") + end + + it 'addValidation() should throw if the field is in a relation' do + expect { @decorated_book.add_validation('author:first_name', { operator: Operators::PRESENT }) }.to raise_error(ForestException, '🌳🌳🌳 Cannot add validators on a relation, use the foreign key instead') + end + + context 'with field selection when validating' do + before do + @decorated_book.add_validation('title', { operator: Operators::LONGER_THAN, value: 5 }) + @decorated_book.add_validation('sub_title', { operator: Operators::LONGER_THAN, value: 5 }) + end + + it 'validates all fields when creating a record' do + allow(@collection_book).to receive(:create).and_return(nil) + + expect { @decorated_book.create(caller, [{ 'title' => 'longtitle', 'sub_title' => '' }]) }.to raise_error(ValidationError, '🌳🌳🌳 sub_title failed validation rule : longer_than(5)') + end + + it 'validates only changed fields when updating' do + allow(@decorated_book).to receive(:update).and_return(nil) + @decorated_book.update(caller, Filter.new, { 'title' => 'longtitle' }) + + expect(@decorated_book).to have_received(:update) + end + end + + context 'with validation when setting to null (null allowed)' do + before do + @decorated_book.add_validation('title', { operator: Operators::LONGER_THAN, value: 5 }) + end + + it 'forwards create that respect the rule' do + allow(@decorated_book).to receive(:create).and_return(nil) + + expect(@decorated_book.create(caller, [{ title: nil }])).to be_nil + end + end + + context 'with validation on a defined value' do + before do + @decorated_book.add_validation('title', { operator: Operators::LONGER_THAN, value: 5 }) + end + + it 'forwards create that respect the rule' do + allow(@collection_book).to receive(:create).and_return(nil) + @decorated_book.create(caller, [{ title: '123456' }]) + + expect(@collection_book).to have_received(:create) + end + + it 'forwards updates that respect the rule' do + allow(@collection_book).to receive(:update).and_return(nil) + @decorated_book.update(caller, Filter.new, { title: '123456' }) + + expect(@collection_book).to have_received(:update) + end + + it 'rejects create that do not respect the rule' do + allow(@collection_book).to receive(:create).and_return(nil) + + expect { @decorated_book.create(caller, [{ 'title' => '1234' }]) }.to raise_error(ValidationError, '🌳🌳🌳 title failed validation rule : longer_than(5)') + end + + it 'rejects updates that do not respect the rule' do + allow(@collection_book).to receive(:update).and_return(true) + + expect { @decorated_book.update(caller, Filter.new, { 'title' => '1234' }) }.to raise_error(ValidationError, '🌳🌳🌳 title failed validation rule : longer_than(5)') + end + end + end + end + end +end From c5a0d8cc275e7f5aacc0c41e354ffe204dcdb171 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 6 Mar 2024 11:54:31 +0100 Subject: [PATCH 5/6] chore: remove useless comment --- .../collection_customizer.rb | 9 --------- 1 file changed, 9 deletions(-) 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 b8b26f63e..ed892eac4 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 @@ -129,15 +129,6 @@ def add_external_relation(name, definition) use(ForestAdminDatasourceCustomizer::Plugins::AddExternalRelation, { name: name }.merge(definition)) end - # public function addFieldValidation(string $name, string $operator, $value = null): self - # { - # return $this->pushCustomization( - # fn () => $this->stack - # ->validation - # ->getCollection($this->name) - # ->addValidation($name, ['operator' => $operator, 'value' => $value]) - # ); - # } def add_field_validation(name, operator, value = nil) push_customization( proc { @stack.validation.get_collection(@name).add_validation(name, { operator: operator, value: value }) } From 6e9df2df5ebb953dec7ae25a3010cb81aee4527f Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 6 Mar 2024 14:34:55 +0100 Subject: [PATCH 6/6] chore: add description on field validation --- .../collection_customizer.rb | 6 ++++++ 1 file changed, 6 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 ed892eac4..5e8df1f6d 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 @@ -129,6 +129,12 @@ def add_external_relation(name, definition) use(ForestAdminDatasourceCustomizer::Plugins::AddExternalRelation, { name: name }.merge(definition)) end + # Add a new validator to the edition form of a given field + # @param name The name of the field + # @param operator The validator that you wish to add + # @param value A configuration value that the validator may need + # @example + # .add_field_validation('first_name', Operators::LONGER_THAN, 2) def add_field_validation(name, operator, value = nil) push_customization( proc { @stack.validation.get_collection(@name).add_validation(name, { operator: operator, value: value }) }