From 9badbbe2ab730cab630519a222487b3b4caf8b2e Mon Sep 17 00:00:00 2001 From: Patrick Gleeson Date: Wed, 19 Sep 2018 16:24:08 +0100 Subject: [PATCH 1/2] Enable advanced search --- lib/administrate/search.rb | 72 ++++++++-- spec/lib/administrate/search_spec.rb | 207 ++++++++++++++++++--------- 2 files changed, 200 insertions(+), 79 deletions(-) diff --git a/lib/administrate/search.rb b/lib/administrate/search.rb index 77fbef2b50..428abcc433 100644 --- a/lib/administrate/search.rb +++ b/lib/administrate/search.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb index 87b7862096..7a7ae89532 100644 --- a/spec/lib/administrate/search_spec.rb +++ b/spec/lib/administrate/search_spec.rb @@ -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" @@ -13,6 +14,7 @@ class MockDashboard name: Administrate::Field::String, email: Administrate::Field::Email, phone: Administrate::Field::Number, + activated: Administrate::Field::Boolean, }.freeze end @@ -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 + 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 + 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 + 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 + remove_constants :User end context "when searching through associations" do @@ -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 + 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 + 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 + 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 + remove_constants :User + end + end end end From 09fce9b9aa82a05be69593c537170f18c3940633 Mon Sep 17 00:00:00 2001 From: Patrick Gleeson Date: Wed, 19 Sep 2018 16:52:07 +0100 Subject: [PATCH 2/2] Accommodate Hound requirements --- spec/lib/administrate/search_spec.rb | 262 ++++++++++++++------------- 1 file changed, 139 insertions(+), 123 deletions(-) diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb index 7a7ae89532..9689a4a3bd 100644 --- a/spec/lib/administrate/search_spec.rb +++ b/spec/lib/administrate/search_spec.rb @@ -34,75 +34,83 @@ class MockDashboardWithAssociation describe Administrate::Search do describe "#run" do it "returns all records when no search term" do - 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 + 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 end it "returns all records when search is empty" do - 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 + 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 end it "searches using LOWER + LIKE for all searchable fields" do - 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 + 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 end it "converts search term LOWER case for latin and cyrillic strings" do - 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 + 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 end context "when searching through associations" do @@ -146,78 +154,86 @@ class User < ActiveRecord::Base; 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 - remove_constants :User + begin + 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 + remove_constants :User + end 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 - remove_constants :User + begin + 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 + remove_constants :User + end 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 - remove_constants :User + begin + 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 + remove_constants :User + end 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 - remove_constants :User + begin + 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 + remove_constants :User + end end end end