Skip to content

Commit

Permalink
Add ability to search through association fields (#1005)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatasrancan authored and nickcharlton committed Mar 2, 2018
1 parent 996a66e commit 71a5bd0
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 10 deletions.
36 changes: 36 additions & 0 deletions docs/customizing_dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions lib/administrate/field/deferred.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 3 additions & 1 deletion lib/administrate/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions lib/administrate/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ 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

private

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
Expand All @@ -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
8 changes: 6 additions & 2 deletions spec/example_app/app/dashboards/customer_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
6 changes: 6 additions & 0 deletions spec/factories.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
FactoryBot.define do
factory :customer do
country
sequence(:name) { |n| "Customer #{n}" }
email { name.downcase.gsub(" ", "_") + "@example.com" }

Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions spec/features/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions spec/lib/administrate/order_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
54 changes: 53 additions & 1 deletion spec/lib/administrate/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

0 comments on commit 71a5bd0

Please sign in to comment.