From 71a5bd09eadeef513b9b38f66df98a881ae3c3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Rancan=20de=20Souza?= Date: Fri, 2 Mar 2018 18:13:24 -0300 Subject: [PATCH] Add ability to search through association fields (#1005) --- docs/customizing_dashboards.md | 36 +++++++++++++ lib/administrate/field/deferred.rb | 4 ++ lib/administrate/order.rb | 4 +- lib/administrate/search.rb | 42 +++++++++++++-- .../app/dashboards/customer_dashboard.rb | 8 ++- spec/factories.rb | 6 +++ spec/features/search_spec.rb | 15 ++++++ spec/lib/administrate/order_spec.rb | 5 +- spec/lib/administrate/search_spec.rb | 54 ++++++++++++++++++- 9 files changed, 164 insertions(+), 10 deletions(-) diff --git a/docs/customizing_dashboards.md b/docs/customizing_dashboards.md index ab9e4accd3..639fca09a6 100644 --- a/docs/customizing_dashboards.md +++ b/docs/customizing_dashboards.md @@ -87,6 +87,24 @@ Example: `.with_options(scope: -> { MyModel.includes(:rel).limit(5) })` `:class_name` - Specifies the name of the associated class. Defaults to `:#{attribute}.to_s.singularize.camelcase`. +`:searchable` - Specify if the attribute should be considered when searching. +Default is `false`. + +`searchable_field` - Specify which column to use on the search, only applies +if `searchable` is `true` + +For example: + +```ruby + country: Field::BelongsTo( + searchable: true, + seachable_field: 'name', + ) +``` + +with this, you will be able to search through the column `name` from the +association `belongs_to :country`, from your model. + **Field::HasMany** `:limit` - Set the number of resources to display in the show view. Default is @@ -108,6 +126,24 @@ Defaults to `:#{attribute}.to_s.singularize.camelcase`. `:class_name` - Specifies the name of the associated class. Defaults to `:#{attribute}.to_s.singularize.camelcase`. +`:searchable` - Specify if the attribute should be considered when searching. +Default is `false`. + +`searchable_field` - Specify which column to use on the search, only applies if +`searchable` is `true` + +For example: + +```ruby + cities: Field::HasMany( + searchable: true, + seachable_field: 'name', + ) +``` + +with this, you will be able to search through the column `name` from the +association `has_many :cities`, from your model. + **Field::Number** `:decimals` - Set the number of decimals to display. Defaults to `0`. diff --git a/lib/administrate/field/deferred.rb b/lib/administrate/field/deferred.rb index b6e7a08cd9..af3ab13c1b 100644 --- a/lib/administrate/field/deferred.rb +++ b/lib/administrate/field/deferred.rb @@ -25,6 +25,10 @@ def searchable? options.fetch(:searchable, deferred_class.searchable?) end + def searchable_field + options.fetch(:searchable_field) + end + def permitted_attribute(attr, _options = nil) options.fetch(:foreign_key, deferred_class.permitted_attribute(attr, options)) diff --git a/lib/administrate/order.rb b/lib/administrate/order.rb index 66600be8b0..5989902c8f 100644 --- a/lib/administrate/order.rb +++ b/lib/administrate/order.rb @@ -9,7 +9,9 @@ def apply(relation) return order_by_association(relation) unless reflect_association(relation).nil? - return relation.reorder("#{attribute} #{direction}") if + order = "#{relation.table_name}.#{attribute} #{direction}" + + return relation.reorder(order) if relation.columns_hash.keys.include?(attribute.to_s) relation diff --git a/lib/administrate/search.rb b/lib/administrate/search.rb index d1bf3a7435..77fbef2b50 100644 --- a/lib/administrate/search.rb +++ b/lib/administrate/search.rb @@ -13,7 +13,7 @@ def run if @term.blank? @scoped_resource.all else - @scoped_resource.where(query, *search_terms) + @scoped_resource.joins(tables_to_join).where(query, *search_terms) end end @@ -21,9 +21,9 @@ def run def query search_attributes.map do |attr| - table_name = ActiveRecord::Base.connection. - quote_table_name(@scoped_resource.table_name) - attr_name = ActiveRecord::Base.connection.quote_column_name(attr) + table_name = query_table_name(attr) + attr_name = column_to_query(attr) + "LOWER(CAST(#{table_name}.#{attr_name} AS CHAR(256))) LIKE ?" end.join(" OR ") end @@ -42,6 +42,40 @@ def attribute_types @dashboard_class::ATTRIBUTE_TYPES end + def query_table_name(attr) + if association_search?(attr) + ActiveRecord::Base.connection.quote_table_name(attr.to_s.pluralize) + else + ActiveRecord::Base.connection. + quote_table_name(@scoped_resource.table_name) + end + end + + def column_to_query(attr) + if association_search?(attr) + ActiveRecord::Base.connection. + quote_column_name(attribute_types[attr].searchable_field) + else + ActiveRecord::Base.connection.quote_column_name(attr) + end + end + + def tables_to_join + attribute_types.keys.select do |attribute| + attribute_types[attribute].searchable? && association_search?(attribute) + end + end + + def association_search?(attribute) + return unless attribute_types[attribute].respond_to?(:deferred_class) + + [ + Administrate::Field::BelongsTo, + Administrate::Field::HasMany, + Administrate::Field::HasOne, + ].include?(attribute_types[attribute].deferred_class) + end + attr_reader :resolver, :term end end diff --git a/spec/example_app/app/dashboards/customer_dashboard.rb b/spec/example_app/app/dashboards/customer_dashboard.rb index 22e932f483..56068df39e 100644 --- a/spec/example_app/app/dashboards/customer_dashboard.rb +++ b/spec/example_app/app/dashboards/customer_dashboard.rb @@ -10,8 +10,12 @@ class CustomerDashboard < Administrate::BaseDashboard orders: Field::HasMany.with_options(limit: 2), updated_at: Field::DateTime, kind: Field::Select.with_options(collection: Customer::KINDS), - country: Field::BelongsTo. - with_options(primary_key: :code, foreign_key: :country_code), + country: Field::BelongsTo.with_options( + primary_key: :code, + foreign_key: :country_code, + searchable: true, + searchable_field: "name", + ), password: Field::Password, } diff --git a/spec/factories.rb b/spec/factories.rb index d2f0adfcf2..8dd4176df4 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :customer do + country sequence(:name) { |n| "Customer #{n}" } email { name.downcase.gsub(" ", "_") + "@example.com" } @@ -61,4 +62,9 @@ factory :series do sequence(:name) { |n| "Series #{n}" } end + + factory :country do + sequence(:name) { |n| "Country #{n}" } + sequence(:code) { |n| "C#{n}" } + end end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index ec3c0b2e1b..5eea06d1c8 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -42,6 +42,21 @@ expect(page).to have_content(mismatch.email) end + scenario "admin searches across associations fields", :js do + country = create(:country, name: "Brazil", code: "BR") + country_match = create(:customer, country: country) + mismatch = create(:customer) + + visit admin_customers_path + + fill_in :search, with: "Brazil" + submit_search + + expect(page).to have_content(country_match.email) + expect(page).to have_content(country.name) + expect(page).not_to have_content(mismatch.email) + end + def clear_search find(".search__clear-link").click end diff --git a/spec/lib/administrate/order_spec.rb b/spec/lib/administrate/order_spec.rb index 2ae5eee76b..afdabc7638 100644 --- a/spec/lib/administrate/order_spec.rb +++ b/spec/lib/administrate/order_spec.rb @@ -36,7 +36,7 @@ ordered = order.apply(relation) - expect(relation).to have_received(:reorder).with("name asc") + expect(relation).to have_received(:reorder).with("table_name.name asc") expect(ordered).to eq(relation) end @@ -47,7 +47,7 @@ ordered = order.apply(relation) - expect(relation).to have_received(:reorder).with("name desc") + expect(relation).to have_received(:reorder).with("table_name.name desc") expect(ordered).to eq(relation) end end @@ -171,6 +171,7 @@ def relation_with_column(column) double( klass: double(reflect_on_association: nil), columns_hash: { column.to_s => :column_info }, + table_name: "table_name", ) end diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb index 4777e98b02..c68e391c77 100644 --- a/spec/lib/administrate/search_spec.rb +++ b/spec/lib/administrate/search_spec.rb @@ -9,7 +9,20 @@ class MockDashboard name: Administrate::Field::String, email: Administrate::Field::Email, phone: Administrate::Field::Number, - } + }.freeze +end + +class MockDashboardWithAssociation + ATTRIBUTE_TYPES = { + role: Administrate::Field::BelongsTo.with_options( + searchable: true, + searchable_field: "name", + ), + address: Administrate::Field::HasOne.with_options( + searchable: true, + searchable_field: "street", + ), + }.freeze end describe Administrate::Search do @@ -85,5 +98,44 @@ class User < ActiveRecord::Base; end remove_constants :User end end + + context "when searching through associations" do + let(:scoped_object) { double(:scoped_object) } + + let(:search) do + Administrate::Search.new( + scoped_object, + MockDashboardWithAssociation, + "Тест Test", + ) + end + + let(:expected_query) do + [ + 'LOWER(CAST("roles"."name" AS CHAR(256))) LIKE ?'\ + ' OR LOWER(CAST("addresses"."street" AS CHAR(256))) LIKE ?', + "%тест test%", + "%тест test%", + ] + end + + it "joins with the correct association table to query" do + allow(scoped_object).to receive(:where) + + expect(scoped_object).to receive(:joins).with(%i(role address)). + and_return(scoped_object) + + search.run + end + + it "builds the 'where' clause using the joined tables" do + allow(scoped_object).to receive(:joins).with(%i(role address)). + and_return(scoped_object) + + expect(scoped_object).to receive(:where).with(*expected_query) + + search.run + end + end end end