Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Advanced" search #1218

Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions lib/administrate/search.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"
require "administrate/field/boolean"

module Administrate
class Search
ADVANCED_CLAUSE_REGEX = /(\w*)\:((?:["'].*?["'])|(?:\w+))/

def initialize(scoped_resource, dashboard_class, term)
@dashboard_class = dashboard_class
@scoped_resource = scoped_resource
Expand All @@ -13,28 +16,48 @@ def run
if @term.blank?
@scoped_resource.all
else
@scoped_resource.joins(tables_to_join).where(query, *search_terms)
add_advanced_clauses(basic_clause)
end
end

private

def query
search_attributes.map do |attr|
table_name = query_table_name(attr)
attr_name = column_to_query(attr)
def basic_clause
if basic_term.blank?
@scoped_resource.all
else
@scoped_resource.joins(tables_to_join).
where(basic_query, *basic_search_terms)
end
end

def basic_query
basic_search_attributes.map(&method(:build_query_string)).join(" OR ")
end

def build_query_string(attr)
table_name = query_table_name(attr)
attr_name = column_to_query(attr)

"LOWER(CAST(#{table_name}.#{attr_name} AS CHAR(256))) LIKE ?"
end

def basic_search_terms
[build_interpolatee(basic_term)] * basic_search_attributes.count
end

"LOWER(CAST(#{table_name}.#{attr_name} AS CHAR(256))) LIKE ?"
end.join(" OR ")
def build_interpolatee(value)
"%#{value.mb_chars.downcase}%"
end

def search_terms
["%#{term.mb_chars.downcase}%"] * search_attributes.count
def basic_term
term.gsub(ADVANCED_CLAUSE_REGEX, "").strip
end

def search_attributes
def basic_search_attributes
attribute_types.keys.select do |attribute|
attribute_types[attribute].searchable?
attribute_types[attribute].searchable? &&
!advanced_search_clauses.key?(attribute)
end
end

Expand Down Expand Up @@ -76,6 +99,33 @@ def association_search?(attribute)
].include?(attribute_types[attribute].deferred_class)
end

def advanced_search_clauses
@advanced_search_clauses ||= begin
# Match "attribute:value" OR "attribute:'value with spaces'"
raw_clauses = @term.scan ADVANCED_CLAUSE_REGEX
raw_clauses.map { |raw| [raw[0].to_sym, raw[1].tr('"\'', "")] }.to_h
end
end

def add_advanced_clauses(resource_collection)
advanced_search_clauses.each do |field, value|
attribute = field.to_sym
next unless attribute_types.key?(attribute) &&
!association_search?(attribute)
if attribute_types[attribute].searchable?
resource_collection = resource_collection.where(
build_query_string(attribute), build_interpolatee(value)
)
elsif attribute_types[attribute] == Administrate::Field::Boolean
truthy = %w[true yes 1].include?(value.downcase)
resource_collection = resource_collection.where(
attribute => truthy,
)
end
end
resource_collection
end

attr_reader :resolver, :term
end
end
207 changes: 139 additions & 68 deletions spec/lib/administrate/search_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "rails_helper"
require "administrate/field/belongs_to"
require "administrate/field/boolean"
require "administrate/field/email"
require "administrate/field/has_many"
require "administrate/field/has_one"
Expand All @@ -13,6 +14,7 @@ class MockDashboard
name: Administrate::Field::String,
email: Administrate::Field::Email,
phone: Administrate::Field::Number,
activated: Administrate::Field::Boolean,
}.freeze
end

Expand All @@ -32,83 +34,75 @@ class MockDashboardWithAssociation
describe Administrate::Search do
describe "#run" do
it "returns all records when no search term" do
begin
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
nil)
expect(scoped_object).to receive(:all)

search.run
ensure
remove_constants :User
end
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
nil)
expect(scoped_object).to receive(:all)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "returns all records when search is empty" do
begin
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
" ")
expect(scoped_object).to receive(:all)

search.run
ensure
remove_constants :User
end
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
" ")
expect(scoped_object).to receive(:all)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "searches using LOWER + LIKE for all searchable fields" do
begin
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"test")
expected_query = [
[
'LOWER(CAST("users"."id" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
].join(" OR "),
"%test%",
"%test%",
"%test%",
]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
remove_constants :User
end
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"test")
expected_query = [
[
'LOWER(CAST("users"."id" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
].join(" OR "),
"%test%",
"%test%",
"%test%",
]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "converts search term LOWER case for latin and cyrillic strings" do
begin
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"Тест Test")
expected_query = [
[
'LOWER(CAST("users"."id" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
].join(" OR "),
"%тест test%",
"%тест test%",
"%тест test%",
]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
remove_constants :User
end
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"Тест Test")
expected_query = [
[
'LOWER(CAST("users"."id" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
].join(" OR "),
"%тест test%",
"%тест test%",
"%тест test%",
]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

context "when searching through associations" do
Expand Down Expand Up @@ -149,5 +143,82 @@ class User < ActiveRecord::Base; end
search.run
end
end

context "when using advanced search" do
it "accepts single-word searches on specific fields" do
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"email:test")
expected_query = ['LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
"%test%"]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "accepts multi-word searches on specific fields" do
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"name:'multiple words'")
expected_query = ['LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
"%multiple words%"]
expect(scoped_object).to receive(:where).with(*expected_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "accepts searches on boolean fields" do
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"activated:true")
expected_query = { activated: true }
expect(scoped_object).to receive(:where).with(expected_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end

it "accepts mixed basic/advanced searches" do
class User < ActiveRecord::Base; end
scoped_object = User.default_scoped
search = Administrate::Search.new(scoped_object,
MockDashboard,
"hello activated:true name:test")
basic_query = [
[
'LOWER(CAST("users"."id" AS CHAR(256))) LIKE ?',
'LOWER(CAST("users"."email" AS CHAR(256))) LIKE ?',
].join(" OR "),
"%hello%",
"%hello%",
]
boolean_query = { activated: true }
string_query = ['LOWER(CAST("users"."name" AS CHAR(256))) LIKE ?',
"%test%"]
expect(scoped_object).to receive(:where).once.ordered.
with(*basic_query).
and_return(scoped_object)
expect(scoped_object).to receive(:where).once.ordered.
with(boolean_query).
and_return(scoped_object)
expect(scoped_object).to receive(:where).once.ordered.
with(*string_query)

search.run
ensure
patrick-gleeson marked this conversation as resolved.
Show resolved Hide resolved
remove_constants :User
end
end
end
end