Skip to content

Commit

Permalink
Support providing arbitrary SQL query generators
Browse files Browse the repository at this point in the history
This commit provides an extension point in the `search_scope` definition that
allows you to define a named `generator` that can be used with the hash
structure to perform arbitrary SQL queries.

This allows people to query for any DB types or operators that aren't directly
supported in SearchCop, and SearchCop doesn't have to support them.

Related to mrkamel#14, mrkamel#28 and mrkamel#31.
  • Loading branch information
jakecraige committed Apr 21, 2017
1 parent 743e230 commit 6a02b1c
Show file tree
Hide file tree
Showing 9 changed files with 93 additions and 3 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Book.search(:author => "Rowling", :title => "Harry Potter")
Book.search(:or => [{:author => "Rowling"}, {:author => "Tolkien"}])
Book.search(:and => [{:price => {:gt => 10}}, {:not => {:stock => 0}}, :or => [{:title => "Potter"}, {:author => "Rowling"}]])
Book.search(:or => [{:query => "Rowling -Potter"}, {:query => "Tolkien -Rings"}])
Book.search(:title => {:my_custom_sql_query => "Rowl"}})
# ...
```

Expand Down Expand Up @@ -446,6 +447,38 @@ of `:not => {...}`, `:matches => {...}`, `:eq => {...}`, `:not_eq => {...}`,
arguments. Moreover, `:query => "..."` makes it possible to create sub-queries.
The other rules for query string queries apply to hash based queries as well.

### Custom operators

SearchCop also provides the ability to define custom operators by defining
a `generator` in `search_scope`. They can then be used with the hash based
query search. This is useful when you want to use database operators that are
not supported by SearchCop.

For example, if you wanted to perform a `LIKE` query where a book title starts
with a string, you can define the search scope like so:

```ruby
search_scope :search do
attributes :title

generator :starts_with do |column_name, raw_value|
pattern = "#{raw_value}%"
"#{column_name} LIKE #{quote pattern}"
end
end
```

When you want to perform the search you use it like this:

```ruby
Book.search({:title => {:starts_with => "The Great"}}})
```

Security Note: The query returned from the generator will be interpolated
directly into the query that goes to your database. This opens up a potential
SQL Injection point in your app. If you use this feature you'll want to make
sure the query you're returning is safe to execute.

## Mapping

When searching in boolean, datetime, timestamp, etc. fields, SearchCop
Expand Down
8 changes: 6 additions & 2 deletions lib/search_cop/hash_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ def parse_attribute(key, value)
collection = SearchCopGrammar::Attributes::Collection.new(query_info, key.to_s)

if value.is_a?(Hash)
raise(SearchCop::ParseError, "Unknown operator #{value.keys.first}") unless [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].include?(value.keys.first)
raise(SearchCop::ParseError, "Unknown operator #{value.keys.first}") unless collection.valid_operator?(value.keys.first)

collection.send value.keys.first, value.values.first.to_s
if generator = collection.generator_for(value.keys.first)
collection.generator generator, value.values.first
else
collection.send value.keys.first, value.values.first.to_s
end
else
collection.send :matches, value.to_s
end
Expand Down
7 changes: 6 additions & 1 deletion lib/search_cop/search_scope.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@

module SearchCop
class Reflection
attr_accessor :attributes, :options, :aliases, :scope
attr_accessor :attributes, :options, :aliases, :scope, :generators

def initialize
self.attributes = {}
self.options = {}
self.aliases = {}
self.generators = {}
end

def default_attributes
Expand Down Expand Up @@ -46,6 +47,10 @@ def scope(&block)
reflection.scope = block
end

def generator(name, &block)
reflection.generators[name] = block
end

private

def attributes_hash(hash)
Expand Down
4 changes: 4 additions & 0 deletions lib/search_cop/visitors/visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def visit_SearchCopGrammar_Nodes_Not(node)
"NOT (#{visit node.object})"
end

def visit_SearchCopGrammar_Nodes_Generator(node)
instance_exec visit(node.left), node.right[:value], &node.right[:generator]
end

def quote_table_name(name)
connection.quote_table_name name
end
Expand Down
20 changes: 20 additions & 0 deletions lib/search_cop_grammar/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Attributes
class Collection
attr_reader :query_info, :key

INCLUDED_OPERATORS = [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].freeze

def initialize(query_info, key)
raise(SearchCop::UnknownColumn, "Unknown column #{key}") unless query_info.scope.reflection.attributes[key]

Expand All @@ -31,6 +33,12 @@ def hash
end
end

def generator(generator, value)
attributes.collect! do |attribute|
SearchCopGrammar::Nodes::Generator.new(attribute, generator: generator, value: value)
end.inject(:or)
end

def matches(value)
if fulltext?
SearchCopGrammar::Nodes::MatchesFulltext.new self, value.to_s
Expand Down Expand Up @@ -88,6 +96,18 @@ def attribute_for(attribute_definition)

Attributes.const_get(klass.columns_hash[column].type.to_s.classify).new(klass, alias_for(table), column, options)
end

def generator_for(name)
generators[name]
end

def valid_operator?(operator)
(INCLUDED_OPERATORS + generators.keys).include?(operator)
end

def generators
query_info.scope.reflection.generators
end
end

class Base
Expand Down
1 change: 1 addition & 0 deletions lib/search_cop_grammar/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class GreaterThanOrEqual < Binary; end
class LessThan < Binary; end
class LessThanOrEqual < Binary; end
class Matches < Binary; end
class Generator < Binary; end

class Not
include Base
Expand Down
10 changes: 10 additions & 0 deletions test/hash_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,15 @@ def test_lteq
assert_includes results, expected
refute_includes results, rejected
end

def test_custom_matcher
expected = create(:product, :title => "Expected")
rejected = create(:product, :title => "Rejected")

results = Product.search(:title => { :custom_eq => "Expected" })

assert_includes results, expected
refute_includes results, rejected
end
end

4 changes: 4 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class Product < ActiveRecord::Base
if DATABASE == "postgres"
options :title, :dictionary => "english"
end

generator :custom_eq do |column_name, raw_value|
"#{column_name} = #{quote raw_value}"
end
end

search_scope :user_search do
Expand Down
9 changes: 9 additions & 0 deletions test/visitor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,14 @@ def test_fulltext_or
assert_equal("(MATCH(`products`.`title`) AGAINST('(Query1) (Query2)' IN BOOLEAN MODE))", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "mysql"
assert_equal("(to_tsvector('english', COALESCE(\"products\".\"title\", '')) @@ to_tsquery('english', '(''Query1'') | (''Query2'')'))", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "postgres"
end

def test_generator
generator = ->(column_name, value) do
"#{column_name} = #{quote value}"
end
node = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").generator(generator, "value").optimize!

assert_equal "#{quote_table_name "products"}.#{quote_column_name "title"} = 'value'", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)
end
end

0 comments on commit 6a02b1c

Please sign in to comment.