diff --git a/.rubocop.yml b/.rubocop.yml index d9d5852a7..2ce07d9d6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -205,6 +205,7 @@ Metrics/MethodLength: - '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/spec/dummy/**/*' + - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/collection.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/components/query/filter_factory.rb' @@ -261,3 +262,4 @@ Layout/LineLength: RSpec/MultipleMemoizedHelpers: Max: 10 + diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb index 0204b4585..de1d50a2e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb @@ -15,7 +15,6 @@ def setup_routes def handle_request(args = {}) build(args) @permissions.can?(:browse, @collection) - filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( condition_tree: ConditionTreeFactory.intersect([ @permissions.get_scope(@collection), @@ -23,8 +22,11 @@ def handle_request(args = {}) @collection, args ) ]), - page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args), + search: ForestAdminAgent::Utils::QueryStringParser.parse_search(@collection, args), + search_extended: ForestAdminAgent::Utils::QueryStringParser.parse_search_extended(args) ) + projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(@collection, args) records = @collection.list(@caller, filter, projection) @@ -34,10 +36,30 @@ def handle_request(args = {}) records, is_collection: true, serializer: Serializer::ForestSerializer, - include: projection.relations.keys + include: projection.relations.keys, + meta: handle_search_decorator(args[:params]['search'], records) ) } end + + def handle_search_decorator(search_value, records) + decorator = { decorators: [] } + unless search_value.nil? + records.each_with_index do |entry, index| + decorator[:decorators][index] = { id: Utils::Id.pack_id(@collection, entry), search: [] } + # attributes method is defined on ActiveRecord::Base model + attributes = entry.respond_to?(:attributes) ? entry.attributes : entry + + attributes.each do |field_key, field_value| + if !field_value.is_a?(Array) && field_value.to_s.downcase.include?(search_value.downcase) + decorator[:decorators][index][:search] << field_key + end + end + end + end + + decorator + end end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb index 543cb0473..15b272e64 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/id.rb @@ -3,6 +3,19 @@ module Utils class Id include ForestAdminDatasourceToolkit::Utils include ForestAdminDatasourceToolkit + + def self.pack_ids(schema, records) + records.map { |packed_id| pack_id(schema, packed_id) } + end + + def self.pack_id(schema, record) + pk_names = ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(schema) + + raise Exceptions::ForestException, 'This collection has no primary key' if pk_names.empty? + + pk_names.map { |pk| record[pk].to_s }.join('|') + end + def self.unpack_id(collection, packed_id, with_key: false) primary_keys = ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection) primary_key_values = packed_id.to_s.split('|') 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 eb15f5aa2..95e5d14b5 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 @@ -85,6 +85,23 @@ def self.parse_pagination(args) Page.new(offset: offset, limit: items_per_pages.to_i) end + + def self.parse_search(collection, args) + search = args.dig(:params, :data, :attributes, :all_records_subset_query, :search) || args.dig(:params, :search) + + raise ForestException, 'Collection is not searchable' if search && !collection.is_searchable? + + search + end + + def self.parse_search_extended(args) + extended = args.dig(:params, :data, :attributes, :all_records_subset_query, + :searchExtended) || args.dig(:params, :searchExtended) + + return false if extended.nil? + + extended != '0' + end end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb index 3cde79035..10e5ba7e5 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb @@ -41,6 +41,7 @@ module Resources }, list: [User.new(1, 'foo', 'foo')] ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) datasource.add_collection(collection) ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) @@ -75,7 +76,8 @@ module Resources 'links' => { 'self' => 'forest/user/1' } } ], - 'included' => [] + 'included' => [], + 'meta' => { decorators: [] } ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb index 5e66aff0f..26c8ae4b2 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/id_spec.rb @@ -141,6 +141,38 @@ module Utils ) end end + + describe 'when pack_id is called' do + it 'return the id value' do + collection = datasource.get_collection('person') + expect(described_class.pack_id(collection, { 'id' => 1, 'foo' => 'bar' })).to eq('1') + end + + it 'return the ids value' do + collection = datasource.get_collection('pks') + expect(described_class.pack_id(collection, { 'key1' => 1, 'key2' => 2 })).to eq('1|2') + end + + it 'throws when collection does not have any primary keys' do + collection_foo = datasource.get_collection('foo') + expect do + described_class.pack_id(collection_foo, { 'id' => 1, 'foo' => 'bar' }) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 This collection has no primary key' + ) + end + end + + describe 'when pack_ids is called' do + it 'return an array of ids' do + collection = datasource.get_collection('person') + expect(described_class.pack_ids(collection, [ + { 'id' => 1, 'foo' => 'bar' }, + { 'id' => 2, 'foo' => 'foo' } + ])).to eq(['1', '2']) + end + end end end end 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 39528895f..e080dfee6 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 @@ -358,6 +358,100 @@ module Utils ) end end + + describe 'when parse_search' do + let(:collection_category) do + datasource = Datasource.new + collection_category = Collection.new(datasource, 'Category') + collection_category.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, + filter_operators: [Operators::EQUAL]), + 'label' => ColumnSchema.new(column_type: 'String') + } + ) + + datasource.add_collection(collection_category) + + return collection_category + end + + 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') + } + ) + collection_user.schema[:searchable] = true + + datasource.add_collection(collection_user) + + return collection_user + end + + it 'returns null when not provided' do + args = { params: {} } + + expect(described_class.parse_search(collection_category, args)).to be_nil + end + + it 'hrows an error when the collection is not searchable' do + args = { params: { search: 'searched argument' } } + + expect do + described_class.parse_search(collection_category, args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Collection is not searchable' + ) + end + + it 'returns the query search parameter' do + args = { params: { search: 'searched argument' } } + + expect(described_class.parse_search(collection_user, args)).to eq('searched argument') + end + + it 'converts the query search parameter as string' do + args = { params: { search: 1234 } } + + expect(described_class.parse_search(collection_user, args)).to eq(1234) + end + + it 'works when passed in the body (actions)' do + args = { + params: { + data: { + attributes: { + all_records_subset_query: { + search: 'searched argument' + } + } + } + } + } + + expect(described_class.parse_search(collection_user, args)).to eq('searched argument') + end + end + + describe 'when parse_search_extended' do + it 'returns the query searchExtended parameter' do + args = { params: { searchExtended: true } } + + expect(described_class.parse_search_extended(args)).to be(true) + end + + it 'returns false for falsy "0" string' do + args = { params: { searchExtended: '0' } } + + expect(described_class.parse_search_extended(args)).to be(false) + 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 04a07c32d..69744e1b8 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 @@ -27,5 +27,11 @@ def disable_count -> { @stack.schema.get_collection(@name).override_schema(countable: false) } ) end + + def replace_search(definition) + push_customization( + -> { @stack.search.get_collection(@name).replace_search(definition) } + ) + end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/agent_customization_context.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/agent_customization_context.rb new file mode 100644 index 000000000..01e4f674a --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/agent_customization_context.rb @@ -0,0 +1,16 @@ +module ForestAdminDatasourceCustomizer + module Context + class AgentCustomizationContext + attr_reader :caller + + def initialize(datasource, caller) + @real_datasource = datasource + @caller = caller + end + + def datasource + RelaxedDataSource.new(@real_datasource, @caller) + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/collection_customization_context.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/collection_customization_context.rb new file mode 100644 index 000000000..e095de5b1 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/collection_customization_context.rb @@ -0,0 +1,14 @@ +module ForestAdminDatasourceCustomizer + module Context + class CollectionCustomizationContext < AgentCustomizationContext + def initialize(collection, caller) + super(collection.datasource, caller) + @real_collection = collection + end + + def collection + RelaxedCollection.new(@real_collection, @caller) + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb new file mode 100644 index 000000000..e4fafaea7 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_collection.rb @@ -0,0 +1,12 @@ +module ForestAdminDatasourceCustomizer + module Context + module RelaxedWrappers + class RelaxedCollection + def initialize(collection, caller) + @collection = collection + @caller = caller + end + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_data_source.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_data_source.rb new file mode 100644 index 000000000..e231d90ea --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/context/relaxed_wrappers/relaxed_data_source.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceCustomizer + module Context + module RelaxedWrappers + class RelaxedDataSource + def initialize(datasource, caller) + @real_datasource = datasource + @caller = caller + end + + # Get a collection from a datasource + # @param name the name of the collection + def get_collection(name) + RelaxedCollection.new(@real_datasource.get_collection(name), @caller) + end + end + 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 7a602b457..8ee7c51fe 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 @@ -9,6 +9,7 @@ def initialize(datasource) last = datasource last = DatasourceDecorator.new(last, Empty::EmptyCollectionDecorator) last = DatasourceDecorator.new(last, OperatorsEquivalence::OperatorsEquivalenceCollectionDecorator) + last = DatasourceDecorator.new(last, Search::SearchCollectionDecorator) last = @schema = DatasourceDecorator.new(last, Schema::SchemaCollectionDecorator) @datasource = last end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/schema/schema_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb similarity index 73% rename from packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/schema/schema_collection_decorator.rb rename to packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb index 1eec89e43..52a7405c2 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/schema/schema_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator.rb @@ -1,7 +1,7 @@ -module ForestAdminDatasourceToolkit +module ForestAdminDatasourceCustomizer module Decorators module Schema - class SchemaCollectionDecorator < CollectionDecorator + class SchemaCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator def initialize(child_collection, datasource) super @schema_override = {} diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb new file mode 100644 index 000000000..11de4b9d0 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb @@ -0,0 +1,129 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Search + class SearchCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + def initialize(child_collection, datasource) + super + @replacer = nil + end + + def replace_search(replacer) + @replacer = replacer + end + + def refine_schema(sub_schema) + sub_schema.merge({ searchable: true }) + end + + def refine_filter(caller, filter) + # Search string is not significant + return filter.override({ search: nil }) if !filter || !filter.search || filter.search.strip&.length&.zero? + + # Implement search ourselves + if @replacer || !@child_collection.schema[:searchable] + ctx = ForestAdminDatasourceCustomizer::Context::CollectionCustomizationContext.new(self, caller) + tree = default_replacer(filter.search, filter.search_extended) + + if @replacer + plain_tree = @replacer.call(filter.search, filter.search_extended, ctx) + tree = ConditionTreeFactory.from_plain_object(plain_tree) + end + + # Note that if no fields are searchable with the provided searchString, the conditions + # array might be empty, which will create a condition returning zero records + # (this is the desired behavior). + return filter.override({ + condition_tree: ConditionTreeFactory.intersect([filter.condition_tree, tree]), + search: nil + }) + end + + # Let sub-collection deal with the search + filter + end + + private + + def default_replacer(search, extended) + searchable_fields = get_fields(@child_collection, extended) + + conditions = searchable_fields.map do |field, schema| + build_condition(field, schema, search) + end + + ConditionTreeFactory.union(conditions) + end + + def build_condition(field, schema, search_string) + column_type = schema.column_type + enum_values = schema.enum_values + filter_operators = schema.filter_operators + is_number = search_string.to_i.to_s == search_string + is_uuid = uuid?(search_string) + + if column_type == PrimitiveType::NUMBER && is_number && filter_operators&.include?(Operators::EQUAL) + return Nodes::ConditionTreeLeaf.new(field, Operators::EQUAL, search_string.to_i) + end + + if column_type == PrimitiveType::ENUM && filter_operators&.include?(Operators::EQUAL) + search_value = lenient_find(enum_values, search_string) + + return Nodes::ConditionTreeLeaf.new(field, Operators::EQUAL, search_value) if search_value + end + + if column_type == PrimitiveType::STRING + is_case_sensitive = !search_string.casecmp(search_string).zero? + supports_i_contains = filter_operators&.include?(Operators::I_CONTAINS) + supports_contains = filter_operators&.include?(Operators::CONTAINS) + supports_equal = filter_operators&.include?(Operators::EQUAL) + + operator = nil + if supports_i_contains && (is_case_sensitive || !supports_contains) + operator = Operators::I_CONTAINS + elsif supports_contains + operator = Operators::CONTAINS + elsif supports_equal + operator = Operators::EQUAL + end + + return Nodes::ConditionTreeLeaf.new(field, operator, search_string) if operator + end + + if column_type == PrimitiveType::UUID && is_uuid && filter_operators&.include?(Operators::EQUAL) + return Nodes::ConditionTreeLeaf.new(field, Operators::EQUAL, search_string) + end + + nil + end + + def get_fields(collection, extended) + fields = [] + collection.schema[:fields].each do |name, field| + fields.push([name, field]) if field.type == 'Column' + + next unless extended && (field.type == 'ManyToOne' || field.type == 'OneToOne') + + related = collection.datasource.get_collection(field.foreign_collection) + + related.schema[:fields].each do |sub_name, sub_field| + fields.push(["#{name}:#{sub_name}", sub_field]) if sub_field.type == 'Column' + end + end + + fields + end + + def lenient_find(haystack, needle) + haystack&.find { |v| v == needle.strip } || haystack&.find { |v| v.downcase == needle.downcase.strip } + end + + def uuid?(value) + value.to_s.downcase.match?(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb index edd518ac5..a4bcce012 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/schema/schema_collection_decorator_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -module ForestAdminDatasourceToolkit +module ForestAdminDatasourceCustomizer module Decorators module Schema describe SchemaCollectionDecorator do diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb new file mode 100644 index 000000000..96b7eb309 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator_spec.rb @@ -0,0 +1,609 @@ +require 'spec_helper' + +module ForestAdminDatasourceCustomizer + module Decorators + module Search + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Schema + + describe SearchCollectionDecorator do + let(:datasource) { ForestAdminDatasourceToolkit::Datasource.new } + + let(:caller) { instance_double(ForestAdminDatasourceToolkit::Components::Caller) } + + before do + @collection_user = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'user', + schema: { + fields: { + 'address_users' => Relations::OneToManySchema.new( + origin_key: 'user_id', + origin_key_target: 'id', + foreign_collection: 'address_user' + ) + } + } + ) + + collection_address_user = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'address_user', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'address' => Relations::ManyToOneSchema.new( + foreign_key: 'address_id', + foreign_collection: 'address', + foreign_key_target: 'id' + ), + 'user' => Relations::ManyToOneSchema.new( + foreign_key: 'user_id', + foreign_collection: 'user', + foreign_key_target: 'id' + ) + } + } + ) + + collection_address = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'address', + schema: { + fields: { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'location' => ColumnSchema.new(column_type: 'String') + } + } + ) + + datasource.add_collection(@collection_user) + datasource.add_collection(collection_address_user) + datasource.add_collection(collection_address) + end + + context 'when refine_schema' do + it 'sets the schema searchable' do + collection = instance_double(ForestAdminDatasourceToolkit::Collection) + search_collection_decorator = described_class.new(collection, datasource) + unsearchable_schema = { searchable: false } + expect(search_collection_decorator.refine_schema(unsearchable_schema)).to eq({ searchable: true }) + end + end + + context 'when refine_filter' do + context 'when the search value is null' do + it 'returns the given filter to return all records' do + collection = instance_double(ForestAdminDatasourceToolkit::Collection) + search_collection_decorator = described_class.new(collection, datasource) + filter = Filter.new(search: nil) + expect(search_collection_decorator.refine_filter(nil, filter)).eql?(filter) + end + end + + context 'when the given field is a column' do + it 'adds a condition to return records matching the search value' do + filter = Filter.new(search: 'a search value') + search_collection_decorator = described_class.new(@collection_user, datasource) + refined_filter = search_collection_decorator.refine_filter(caller, filter) + + expect(refined_filter).eql?(Filter.new) + end + end + + context 'when the collection schema is not searchable' do + it 'returns the given filter without adding condition' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: true + } + ) + datasource.add_collection(collection) + + search_collection_decorator = described_class.new(collection, datasource) + filter = Filter.new(search: 'a text') + refined_filter = search_collection_decorator.refine_filter(caller, filter) + + expect(refined_filter).eql?(filter) + end + end + + context 'when a replacer is provided' do + it 'is used instead of the default one' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + fields: { id: ColumnSchema.new(column_type: 'Number', is_primary_key: true) } + } + ) + filter = Filter.new(search: 'something') + decorator = described_class.new(collection, nil) + decorator.replace_search(proc { |value| + { field: 'id', operator: ConditionTree::Operators::EQUAL, value: value } + }) + + refined_filter = decorator.refine_filter(caller, filter) + + expect(refined_filter).to have_attributes( + condition_tree: have_attributes(field: 'id', operator: ConditionTree::Operators::EQUAL, + value: 'something'), + search: nil + ) + end + end + + context 'when the search is defined and the collection schema is not searchable' do + context 'when the search is empty' do + it 'returns the same filter and set search as null' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false + } + ) + filter = Filter.new(search: ' ') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes(search: nil) + end + end + + context 'when the filter contains already conditions' do + it 'adds its conditions to the filter' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'String', + filter_operators: [ConditionTree::Operators::I_CONTAINS] + ) + } + } + ) + + filter = Filter.new( + search: 'a text', + condition_tree: ConditionTree::Nodes::ConditionTreeBranch.new( + 'And', + [ + ConditionTree::Nodes::ConditionTreeLeaf.new('aFieldName', ConditionTree::Operators::EQUAL, + 'fieldValue') + ] + ) + ) + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes( + aggregator: 'And', + conditions: [ + have_attributes(field: 'aFieldName', operator: ConditionTree::Operators::EQUAL, + value: 'fieldValue'), + have_attributes(field: 'fieldName', operator: ConditionTree::Operators::I_CONTAINS, + value: 'a text') + ] + ) + ) + end + end + + context 'when the search is a string and the column type is a string' do + it 'returns filter with "contains" condition and "or" aggregator' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'String', + filter_operators: [ConditionTree::Operators::I_CONTAINS, ConditionTree::Operators::CONTAINS] + ) + } + } + ) + + filter = Filter.new(search: 'a text') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(field: 'fieldName', operator: ConditionTree::Operators::CONTAINS, + value: 'a text') + ) + end + end + + context 'when searching on a string that only supports Equal' do + it 'returns filter with "equal" condition' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'String', + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + } + ) + + filter = Filter.new(search: 'a text') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).eql?(Filter.new(search: nil, + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new( + 'fieldName', + ConditionTree::Operators::EQUAL, 'a text' + ))) + end + end + + context 'when search is a case insensitive string and both operators are supported' do + it 'returns filter with "contains" condition and "or" aggregator' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'String', + filter_operators: [ConditionTree::Operators::I_CONTAINS, ConditionTree::Operators::CONTAINS] + ) + } + } + ) + + filter = Filter.new(search: '@#*$(@#*$(23423423') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(field: 'fieldName', operator: ConditionTree::Operators::CONTAINS, + value: '@#*$(@#*$(23423423') + ) + end + end + + context 'when the search is an uuid and the column type is an uuid' do + it 'returns filter with "equal" condition and "or" aggregator' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Uuid', + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + } + ) + + filter = Filter.new(search: '2d162303-78bf-599e-b197-93590ac3d315') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(field: 'fieldName', operator: ConditionTree::Operators::EQUAL, + value: '2d162303-78bf-599e-b197-93590ac3d315') + ) + end + end + + context 'when the search is a number and the column type is a number' do + it 'returns "equal" condition, "or" aggregator and cast value to Number' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Number', + filter_operators: [ConditionTree::Operators::EQUAL] + ), + 'fieldName2' => ColumnSchema.new( + column_type: 'String', + filter_operators: [ConditionTree::Operators::I_CONTAINS] + ) + } + } + ) + + filter = Filter.new(search: '1584') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes( + aggregator: 'Or', + conditions: [ + have_attributes(field: 'fieldName', operator: ConditionTree::Operators::EQUAL, value: 1584), + have_attributes(field: 'fieldName2', operator: ConditionTree::Operators::I_CONTAINS, + value: '1584') + ] + ) + ) + end + end + + context 'when the search is an string and the column type is an enum' do + it 'returns filter with "equal" condition and "or" aggregator' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Enum', + enum_values: ['AnEnUmVaLue'], + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + } + ) + + filter = Filter.new(search: 'anenumvalue') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).eql?( + Filter.new( + search: nil, + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new( + 'fieldName', + ConditionTree::Operators::EQUAL, 'AnEnUmVaLue' + ) + ) + ) + end + + context 'when the search value does not match any enum' do + it 'adds a condition to not return record if it is the only one filter' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Enum', + enum_values: ['AEnumValue'], + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + } + ) + + filter = Filter.new(search: 'NotExistEnum') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(aggregator: 'Or', conditions: []) + ) + end + end + + context 'when the enum values are not defined' do + it 'adds a condition to not return record if it is the only one filter' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Enum' + # enum values is not defined + ) + } + } + ) + + filter = Filter.new(search: 'NotExistEnum') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(aggregator: 'Or', conditions: []) + ) + end + end + + context 'when the column type is not searchable' do + it 'adds a condition to not return record if it is the only one filter' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'fieldName' => ColumnSchema.new( + column_type: 'Boolean' + ) + } + } + ) + + filter = Filter.new(search: '1584') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes(aggregator: 'Or', conditions: []) + ) + end + end + end + + context 'when there are several fields' do + it 'returns all the number fields when a number is researched' do + collection = instance_double( + ForestAdminDatasourceToolkit::Collection, + name: 'foo', + schema: { + searchable: false, + fields: { + 'numberField1' => ColumnSchema.new( + column_type: 'Number', + filter_operators: [ConditionTree::Operators::EQUAL] + ), + 'numberField2' => ColumnSchema.new( + column_type: 'Number', + filter_operators: [ConditionTree::Operators::EQUAL] + ), + 'fieldNotReturned' => ColumnSchema.new(column_type: 'Uuid') + } + } + ) + + filter = Filter.new(search: '1584') + + search_collection_decorator = described_class.new(collection, nil) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search: nil, + condition_tree: have_attributes( + aggregator: 'Or', + conditions: [ + have_attributes(field: 'numberField1', operator: ConditionTree::Operators::EQUAL, value: 1584), + have_attributes(field: 'numberField2', operator: ConditionTree::Operators::EQUAL, value: 1584) + ] + ) + ) + end + + context 'when it is a deep search with relation fields' do + it 'returns all the uuid fields when uuid is researched' do + collection_book = ForestAdminDatasourceToolkit::Collection.new( + datasource, + 'book' + ) + collection_book.add_fields( + { + 'id' => ColumnSchema.new( + column_type: 'Uuid', + is_primary_key: true, + filter_operators: [ConditionTree::Operators::EQUAL] + ), + 'my_persons' => Relations::OneToOneSchema.new( + origin_key: 'person_id', + origin_key_target: 'id', + foreign_collection: 'person' + ), + 'my_book_persons' => Relations::ManyToOneSchema.new( + foreign_key: 'book_id', + foreign_key_target: 'id', + foreign_collection: 'book_person' + ) + } + ) + + collection_book_persons = ForestAdminDatasourceToolkit::Collection.new( + datasource, + 'book_person' + ) + collection_book_persons.add_fields( + { + 'book_id' => ColumnSchema.new( + column_type: 'Uuid', + is_primary_key: true, + filter_operators: [ConditionTree::Operators::EQUAL] + ), + 'person_id' => ColumnSchema.new( + column_type: 'Uuid', + is_primary_key: true, + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + ) + + collection_persons = ForestAdminDatasourceToolkit::Collection.new( + datasource, + 'person' + ) + collection_persons.add_fields( + { + 'id' => ColumnSchema.new( + column_type: 'Uuid', + is_primary_key: true, + filter_operators: [ConditionTree::Operators::EQUAL] + ) + } + ) + + datasource.add_collection(collection_book) + datasource.add_collection(collection_book_persons) + datasource.add_collection(collection_persons) + + filter = Filter.new( + search_extended: true, + search: '2d162303-78bf-599e-b197-93590ac3d315' + ) + + search_collection_decorator = described_class.new(collection_book, datasource) + + refined_filter = search_collection_decorator.refine_filter(caller, filter) + expect(refined_filter).to have_attributes( + search_extended: true, + search: nil, + condition_tree: have_attributes( + aggregator: 'Or', + conditions: [ + have_attributes(field: 'id', operator: ConditionTree::Operators::EQUAL, + value: '2d162303-78bf-599e-b197-93590ac3d315'), + have_attributes(field: 'my_persons:id', operator: ConditionTree::Operators::EQUAL, + value: '2d162303-78bf-599e-b197-93590ac3d315'), + have_attributes(field: 'my_book_persons:book_id', operator: ConditionTree::Operators::EQUAL, + value: '2d162303-78bf-599e-b197-93590ac3d315'), + have_attributes(field: 'my_book_persons:person_id', operator: ConditionTree::Operators::EQUAL, + value: '2d162303-78bf-599e-b197-93590ac3d315') + ] + ) + ) + end + end + end + 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 9a1730fb6..1c4e971d4 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 @@ -9,9 +9,6 @@ class Collection < Components::Contracts::CollectionContract :schema, :native_driver - attr_writer :searchable, - :countable - def initialize(datasource, name, native_driver: nil) super() @datasource = datasource @@ -19,35 +16,34 @@ def initialize(datasource, name, native_driver: nil) @native_driver = native_driver @schema = { fields: {}, - countable: false + countable: false, + searchable: false } @actions = {} @segments = {} @charts = {} - @searchable = false - @countable = false end def enable_count - @schema[:countable] = true + schema[:countable] = true end def is_countable? - @schema[:countable] + schema[:countable] end def is_searchable? - @searchable + schema[:searchable] end def fields - @schema[:fields] + schema[:fields] end def add_field(name, field) raise Exceptions::ForestException, "Field #{name} already defined in collection" if @schema[:fields].key?(name) - @schema[:fields][name] = field + schema[:fields][name] = field end def add_fields(fields) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/collection_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/collection_spec.rb index 0b4451b17..d4fb6d509 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/collection_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/collection_spec.rb @@ -25,7 +25,7 @@ module ForestAdminDatasourceToolkit end it 'return true when value is override' do - @collection.searchable = true + @collection.schema[:searchable] = true expect(@collection.is_searchable?).to be true end end