├── lib ├── search_cop │ ├── version.rb │ ├── visitors.rb │ ├── query_info.rb │ ├── visitors │ │ ├── sqlite.rb │ │ ├── mysql.rb │ │ ├── postgres.rb │ │ └── visitor.rb │ ├── helpers.rb │ ├── grammar_parser.rb │ ├── query_builder.rb │ ├── hash_parser.rb │ └── search_scope.rb ├── search_cop_grammar.treetop ├── search_cop.rb ├── search_cop_grammar.rb └── search_cop_grammar │ ├── nodes.rb │ └── attributes.rb ├── Rakefile ├── .gitignore ├── Gemfile ├── gemfiles ├── rails5.gemfile ├── rails7.gemfile └── rails6.gemfile ├── test ├── error_test.rb ├── database.yml ├── not_test.rb ├── or_test.rb ├── and_test.rb ├── fulltext_test.rb ├── boolean_test.rb ├── scope_test.rb ├── integer_test.rb ├── default_operator_test.rb ├── float_test.rb ├── hash_test.rb ├── date_test.rb ├── datetime_test.rb ├── search_cop_test.rb ├── test_helper.rb ├── string_test.rb └── visitor_test.rb ├── docker-compose.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── search_cop.gemspec ├── .github └── workflows │ └── test.yml ├── MIGRATION.md ├── .rubocop.yml ├── CHANGELOG.md └── README.md /lib/search_cop/version.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | VERSION = "1.4.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/search_cop/visitors.rb: -------------------------------------------------------------------------------- 1 | require "search_cop/visitors/visitor" 2 | require "search_cop/visitors/mysql" 3 | require "search_cop/visitors/postgres" 4 | require "search_cop/visitors/sqlite" 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "lib" 6 | t.pattern = "test/**/*_test.rb" 7 | t.verbose = true 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | gemfiles/*.lock 19 | -------------------------------------------------------------------------------- /lib/search_cop/query_info.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | class QueryInfo 3 | attr_accessor :model, :scope, :references 4 | 5 | def initialize(model, scope) 6 | self.model = model 7 | self.scope = scope 8 | self.references = [] 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in search_cop.gemspec 4 | gemspec 5 | 6 | platforms :ruby do 7 | gem "activerecord", ">= 3.0.0" 8 | gem "bundler" 9 | gem "factory_bot" 10 | gem "minitest" 11 | gem "mysql2" 12 | gem "pg" 13 | gem "rake" 14 | gem "rubocop" 15 | gem "sqlite3" 16 | end 17 | -------------------------------------------------------------------------------- /gemfiles/rails5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.1" 6 | 7 | platforms :ruby do 8 | gem "bundler" 9 | gem "factory_bot" 10 | gem "minitest" 11 | gem "mysql2" 12 | gem "pg" 13 | gem "rake" 14 | gem "rubocop" 15 | gem "sqlite3" 16 | end 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0" 6 | 7 | platforms :ruby do 8 | gem "bundler" 9 | gem "factory_bot" 10 | gem "minitest" 11 | gem "mysql2" 12 | gem "trilogy" 13 | gem "pg" 14 | gem "rake" 15 | gem "rubocop" 16 | gem "sqlite3", "~> 1.7" 17 | end 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /gemfiles/rails6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0" 6 | gem "concurrent-ruby", "1.3.4" 7 | 8 | platforms :ruby do 9 | gem "bundler" 10 | gem "factory_bot" 11 | gem "minitest" 12 | gem "mysql2" 13 | gem "pg" 14 | gem "rake" 15 | gem "rubocop" 16 | gem "sqlite3", "~> 1.7" 17 | end 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /test/error_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class ErrorTest < SearchCop::TestCase 4 | def test_parse_error 5 | assert_raises SearchCop::ParseError do 6 | Product.unsafe_search title: { unknown_operator: "Value" } 7 | end 8 | end 9 | 10 | def test_unknown_column 11 | assert_raises SearchCop::UnknownColumn do 12 | Product.unsafe_search "Unknown: Column" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/search_cop/visitors/sqlite.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | module Visitors 3 | module Sqlite 4 | # rubocop:disable Naming/MethodName 5 | 6 | def visit_SearchCopGrammar_Attributes_Json(attribute) 7 | "json_extract(#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}, #{quote "$.#{attribute.field_names.join(".")}"})" 8 | end 9 | 10 | # rubocop:enable Naming/MethodName 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/database.yml: -------------------------------------------------------------------------------- 1 | 2 | sqlite: 3 | adapter: sqlite3 4 | database: ":memory:" 5 | 6 | mysql: 7 | adapter: <%= ENV["DATABASE"] == "mysql" && ENV["ADAPTER"] == "trilogy" ? "trilogy" : "mysql2" %> 8 | database: search_cop 9 | host: 127.0.0.1 10 | username: root 11 | encoding: utf8 12 | 13 | postgres: 14 | host: 127.0.0.1 15 | adapter: postgresql 16 | database: search_cop 17 | username: search_cop 18 | password: secret 19 | encoding: utf8 20 | 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | version: '2' 3 | services: 4 | mysql: 5 | image: percona:5.7 6 | environment: 7 | - MYSQL_ALLOW_EMPTY_PASSWORD=yes 8 | - MYSQL_ROOT_PASSWORD= 9 | - MYSQL_DATABASE=search_cop 10 | ports: 11 | - 3306:3306 12 | postgres: 13 | image: styriadigital/postgres_hstore:10 14 | environment: 15 | POSTGRES_DB: search_cop 16 | POSTGRES_USER: search_cop 17 | POSTGRES_PASSWORD: secret 18 | ports: 19 | - 5432:5432 20 | -------------------------------------------------------------------------------- /lib/search_cop/helpers.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | module Helpers 3 | def self.sanitize_default_operator(query_options) 4 | return "and" unless query_options.key?(:default_operator) 5 | 6 | default_operator = query_options[:default_operator].to_s.downcase 7 | 8 | unless ["and", "or"].include?(default_operator) 9 | raise(SearchCop::UnknownDefaultOperator, "Unknown default operator value #{default_operator}") 10 | end 11 | 12 | default_operator 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | There are two ways to contribute: issues and pull requests. 5 | 6 | ## Issues 7 | 8 | You are very welcome to submit a github issue in case you find a bug. 9 | The more detailed, the easier to reproduce. So please be verbose. 10 | 11 | ## Pull Requests 12 | 13 | 1. Fork it 14 | 2. Create your feature branch (`git checkout -b my-new-feature`) 15 | 3. Commit your changes (`git commit -am 'Add some feature'`) 16 | 4. Push to the branch (`git push origin my-new-feature`) 17 | 5. Create new Pull Request 18 | 19 | -------------------------------------------------------------------------------- /lib/search_cop/grammar_parser.rb: -------------------------------------------------------------------------------- 1 | require "search_cop_grammar" 2 | require "treetop" 3 | 4 | Treetop.load File.expand_path("../search_cop_grammar.treetop", __dir__) 5 | 6 | module SearchCop 7 | class GrammarParser 8 | attr_reader :query_info 9 | 10 | def initialize(query_info) 11 | @query_info = query_info 12 | end 13 | 14 | def parse(string, query_options) 15 | node = SearchCopGrammarParser.new.parse(string) || raise(ParseError) 16 | node.query_info = query_info 17 | node.query_options = query_options 18 | node.evaluate 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/not_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class NotTest < SearchCop::TestCase 4 | def test_not_string 5 | expected = create(:product, title: "Expected title") 6 | rejected = create(:product, title: "Rejected title") 7 | 8 | results = Product.search("title: Title NOT title: Rejected") 9 | 10 | assert_includes results, expected 11 | refute_includes results, rejected 12 | 13 | assert_equal results, Product.search("title: Title -title: Rejected") 14 | end 15 | 16 | def test_not_hash 17 | expected = create(:product, title: "Expected title") 18 | rejected = create(:product, title: "Rejected title") 19 | 20 | results = Product.search(and: [{ title: "Title" }, { not: { title: "Rejected" } }]) 21 | 22 | assert_includes results, expected 23 | refute_includes results, rejected 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/or_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class OrTest < SearchCop::TestCase 4 | def test_or_string 5 | product1 = create(:product, title: "Title1") 6 | product2 = create(:product, title: "Title2") 7 | product3 = create(:product, title: "Title3") 8 | 9 | results = Product.search("title: Title1 OR title: Title2") 10 | 11 | assert_includes results, product1 12 | assert_includes results, product2 13 | refute_includes results, product3 14 | end 15 | 16 | def test_or_hash 17 | product1 = create(:product, title: "Title1") 18 | product2 = create(:product, title: "Title2") 19 | product3 = create(:product, title: "Title3") 20 | 21 | results = Product.search(or: [{ title: "Title1" }, { title: "Title2" }]) 22 | 23 | assert_includes results, product1 24 | assert_includes results, product2 25 | refute_includes results, product3 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/and_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class AndTest < SearchCop::TestCase 4 | def test_and_string 5 | expected = create(:product, title: "expected title", description: "Description") 6 | rejected = create(:product, title: "Rejected title", description: "Description") 7 | 8 | results = Product.search("title: 'Expected title' description: Description") 9 | 10 | assert_includes results, expected 11 | refute_includes results, rejected 12 | 13 | assert_equal results, Product.search("title: 'Expected title' AND description: Description") 14 | end 15 | 16 | def test_and_hash 17 | expected = create(:product, title: "Expected title", description: "Description") 18 | rejected = create(:product, title: "Rejected title", description: "Description") 19 | 20 | results = Product.search(and: [{ title: "Expected title" }, { description: "Description" }]) 21 | 22 | assert_includes results, expected 23 | refute_includes results, rejected 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/search_cop/query_builder.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | class QueryBuilder 3 | attr_accessor :query_info, :scope, :sql 4 | 5 | def initialize(model, query, scope, query_options) 6 | self.scope = scope 7 | self.query_info = QueryInfo.new(model, scope) 8 | 9 | arel = SearchCop::Parser.parse(query, query_info, query_options).optimize! 10 | 11 | self.sql = SearchCop::Visitors::Visitor.new(model.connection).visit(arel) 12 | end 13 | 14 | def associations 15 | all_associations - [query_info.model.name.tableize.to_sym] - [query_info.model.table_name.to_sym] 16 | end 17 | 18 | private 19 | 20 | def all_associations 21 | scope.reflection.attributes.values.flatten.collect { |column| association_for column.split(".").first }.uniq 22 | end 23 | 24 | def association_for(column) 25 | alias_value = scope.reflection.aliases[column] 26 | 27 | association = alias_value.respond_to?(:table_name) ? alias_value.table_name : alias_value 28 | association ||= column 29 | 30 | association.to_sym 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/fulltext_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class FulltextTest < SearchCop::TestCase 4 | def test_complex 5 | product1 = create(:product, title: "word1") 6 | product2 = create(:product, title: "word2 word3") 7 | product3 = create(:product, title: "word2") 8 | 9 | results = Product.search("word1 OR (title:word2 -word3)") 10 | 11 | assert_includes results, product1 12 | refute_includes results, product2 13 | assert_includes results, product3 14 | end 15 | 16 | def test_special_characters 17 | product1 = create(:product, title: "+") 18 | product2 = create(:product, title: "-") 19 | product3 = create(:product, title: "other") 20 | 21 | assert Product.search("+-").empty? 22 | end 23 | 24 | def test_mixed 25 | expected = create(:product, title: "Expected title", stock: 1) 26 | rejected = create(:product, title: "Expected title", stock: 0) 27 | 28 | results = Product.search("Expected title:Title stock > 0") 29 | 30 | assert_includes results, expected 31 | refute_includes results, rejected 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Benjamin Vetter 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/search_cop/hash_parser.rb: -------------------------------------------------------------------------------- 1 | class SearchCop::HashParser 2 | attr_reader :query_info 3 | 4 | def initialize(query_info) 5 | @query_info = query_info 6 | end 7 | 8 | def parse(hash, query_options = {}) 9 | default_operator = SearchCop::Helpers.sanitize_default_operator(query_options) 10 | 11 | res = hash.collect do |key, value| 12 | case key 13 | when :and 14 | value.collect { |val| parse val }.inject(:and) 15 | when :or 16 | value.collect { |val| parse val }.inject(:or) 17 | when :not 18 | parse(value).not 19 | when :query 20 | SearchCop::Parser.parse(value, query_info) 21 | else 22 | parse_attribute(key, value) 23 | end 24 | end 25 | 26 | res.inject(default_operator) 27 | end 28 | 29 | private 30 | 31 | def parse_attribute(key, value) 32 | collection = SearchCopGrammar::Attributes::Collection.new(query_info, key.to_s) 33 | 34 | if value.is_a?(Hash) 35 | raise(SearchCop::ParseError, "Unknown operator #{value.keys.first}") unless collection.valid_operator?(value.keys.first) 36 | 37 | generator = collection.generator_for(value.keys.first) 38 | 39 | if generator 40 | collection.generator(generator, value.values.first) 41 | else 42 | collection.send(value.keys.first, value.values.first.to_s) 43 | end 44 | else 45 | collection.send(:matches, value.to_s) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /search_cop.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "search_cop/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "search_cop" 7 | spec.version = SearchCop::VERSION 8 | spec.authors = ["Benjamin Vetter"] 9 | spec.email = ["vetter@flakks.com"] 10 | spec.description = "Search engine like fulltext query support for ActiveRecord" 11 | spec.summary = "Easily perform complex search engine like fulltext queries on your ActiveRecord models" 12 | spec.homepage = "https://github.com/mrkamel/search_cop" 13 | spec.license = "MIT" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/mrkamel/search_cop" 17 | spec.metadata["changelog_uri"] = "https://github.com/mrkamel/search_cop/blob/master/CHANGELOG.md" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(__dir__) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 24 | end 25 | end 26 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_dependency "treetop" 30 | end 31 | -------------------------------------------------------------------------------- /test/boolean_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class BooleanTest < SearchCop::TestCase 4 | def test_mapping 5 | product = create(:product, available: true) 6 | 7 | assert_includes Product.search("available: 1"), product 8 | assert_includes Product.search("available: true"), product 9 | assert_includes Product.search("available: yes"), product 10 | 11 | product = create(:product, available: false) 12 | 13 | assert_includes Product.search("available: 0"), product 14 | assert_includes Product.search("available: false"), product 15 | assert_includes Product.search("available: no"), product 16 | end 17 | 18 | def test_anywhere 19 | product = create(:product, available: true) 20 | 21 | assert_includes Product.search("true"), product 22 | refute_includes Product.search("false"), product 23 | end 24 | 25 | def test_includes 26 | product = create(:product, available: true) 27 | 28 | assert_includes Product.search("available: true"), product 29 | refute_includes Product.search("available: false"), product 30 | end 31 | 32 | def test_equals 33 | product = create(:product, available: true) 34 | 35 | assert_includes Product.search("available = true"), product 36 | refute_includes Product.search("available = false"), product 37 | end 38 | 39 | def test_equals_not 40 | product = create(:product, available: false) 41 | 42 | assert_includes Product.search("available != true"), product 43 | refute_includes Product.search("available != false"), product 44 | end 45 | 46 | def test_incompatible_datatype 47 | assert_raises SearchCop::IncompatibleDatatype do 48 | Product.unsafe_search "available: Value" 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/scope_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class ScopeTest < SearchCop::TestCase 4 | def test_scope_name 5 | expected = create(:product, title: "Expected") 6 | rejected = create(:product, notice: "Expected") 7 | 8 | results = Product.user_search("Expected") 9 | 10 | assert_includes results, expected 11 | refute_includes results, rejected 12 | end 13 | 14 | def test_options 15 | expected = create(:product, title: "Expected") 16 | rejected = create(:product, description: "Expected") 17 | 18 | results = Product.user_search("Expected") 19 | 20 | assert_includes results, expected 21 | refute_includes results, rejected 22 | end 23 | 24 | def test_custom_scope 25 | expected = create(:product, user: create(:user, username: "Expected")) 26 | rejected = create(:product, user: create(:user, username: "Rejected")) 27 | 28 | results = Product.user_search("user: Expected") 29 | 30 | assert_includes results, expected 31 | refute_includes results, rejected 32 | end 33 | 34 | def test_aliases_with_association 35 | expected = create(:product, user: create(:user, username: "Expected")) 36 | rejected = create(:product, user: create(:user, username: "Rejected")) 37 | 38 | results = Product.search("user: Expected") 39 | 40 | assert_includes results, expected 41 | refute_includes results, rejected 42 | end 43 | 44 | def test_aliases_with_model 45 | expected = create(:product, user: create(:user, username: "Expected")) 46 | rejected = create(:product, user: create(:user, username: "Rejected")) 47 | 48 | results = Product.user_search("user: Expected") 49 | 50 | assert_includes results, expected 51 | refute_includes results, rejected 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] 10 | rails: ["rails5", "rails6", "rails7"] 11 | database: ["sqlite", "postgres", "mysql"] 12 | adapter: ["default"] 13 | exclude: 14 | - ruby: "3.0" 15 | rails: "rails5" 16 | - ruby: "3.1" 17 | rails: "rails5" 18 | - ruby: "3.2" 19 | rails: "rails5" 20 | - ruby: "3.3" 21 | rails: "rails5" 22 | include: 23 | - ruby: "3.3" 24 | rails: "rails7" 25 | database: "mysql" 26 | adapter: "trilogy" 27 | services: 28 | postgres: 29 | image: styriadigital/postgres_hstore:10 30 | env: 31 | POSTGRES_USER: search_cop 32 | POSTGRES_PASSWORD: secret 33 | POSTGRES_DB: search_cop 34 | ports: 35 | - 5432:5432 36 | mysql: 37 | image: mysql 38 | env: 39 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 40 | MYSQL_ROOT_PASSWORD: "" 41 | MYSQL_DATABASE: search_cop 42 | ports: 43 | - 3306:3306 44 | env: 45 | BUNDLE_PATH: ../vendor/bundle 46 | steps: 47 | - uses: actions/checkout@v1 48 | - uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{ matrix.ruby }} 51 | - name: test 52 | env: 53 | DATABASE: ${{ matrix.database }} 54 | ADAPTER: ${{ matrix.adapter }} 55 | run: | 56 | bundle config set --local gemfile "gemfiles/${{ matrix.rails }}.gemfile" 57 | bundle install 58 | bundle exec rake test 59 | -------------------------------------------------------------------------------- /lib/search_cop_grammar.treetop: -------------------------------------------------------------------------------- 1 | 2 | grammar SearchCopGrammar 3 | rule complex_expression 4 | space? (boolean_expression / expression) space? 5 | end 6 | 7 | rule boolean_expression 8 | and_expression 9 | end 10 | 11 | rule and_expression 12 | or_expression space? ('AND' / 'and') space? complex_expression / 13 | or_expression space !('OR' / 'or') complex_expression / 14 | or_expression 15 | end 16 | 17 | rule or_expression 18 | expression space? ('OR' / 'or') space? (or_expression / expression) / expression 19 | end 20 | 21 | rule expression 22 | parentheses_expression / not_expression / comparative_expression / anywhere_expression 23 | end 24 | 25 | rule parentheses_expression 26 | '(' complex_expression ')' 27 | end 28 | 29 | rule not_expression 30 | ('NOT' space / 'not' space / '-') (comparative_expression / anywhere_expression) 31 | end 32 | 33 | rule comparative_expression 34 | simple_column space? comparison_operator space? value 35 | end 36 | 37 | rule comparison_operator 38 | ':' / '=' / '!=' / '>=' / '>' / '<=' / '<' 39 | end 40 | 41 | rule anywhere_expression 42 | "'" [^\']* "'" / '"' [^\"]* '"' / [^[:blank:]()]+ 43 | end 44 | 45 | rule simple_column 46 | [a-zA-Z0-9_.]+ 47 | end 48 | 49 | rule value 50 | "'" [^\']* "'" / '"' [^\"]* '"' / [^[:blank:]()]+ 51 | end 52 | 53 | rule space 54 | [[:blank:]]+ 55 | end 56 | end 57 | 58 | -------------------------------------------------------------------------------- /lib/search_cop/search_scope.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | class Reflection 3 | attr_accessor :attributes, :options, :aliases, :scope, :generators 4 | 5 | def initialize 6 | self.attributes = {} 7 | self.options = {} 8 | self.aliases = {} 9 | self.generators = {} 10 | end 11 | 12 | def default_attributes 13 | keys = options.select { |_key, value| value[:default] == true }.keys 14 | keys = attributes.keys.reject { |key| options[key] && options[key][:default] == false } if keys.empty? 15 | keys = keys.to_set 16 | 17 | attributes.select { |key, _value| keys.include? key } 18 | end 19 | end 20 | 21 | class SearchScope 22 | attr_accessor :name, :model, :reflection 23 | 24 | def initialize(name, model) 25 | self.model = model 26 | self.reflection = Reflection.new 27 | end 28 | 29 | def attributes(*args) 30 | args.each do |arg| 31 | attributes_hash arg.is_a?(Hash) ? arg : { arg => arg } 32 | end 33 | end 34 | 35 | def options(key, options = {}) 36 | reflection.options[key.to_s] = (reflection.options[key.to_s] || {}).merge(options) 37 | end 38 | 39 | def aliases(hash) 40 | hash.each do |key, value| 41 | reflection.aliases[key.to_s] = value.is_a?(Class) ? value : value.to_s 42 | end 43 | end 44 | 45 | def scope(&block) 46 | reflection.scope = block 47 | end 48 | 49 | def generator(name, &block) 50 | reflection.generators[name] = block 51 | end 52 | 53 | private 54 | 55 | def attributes_hash(hash) 56 | hash.each do |key, value| 57 | reflection.attributes[key.to_s] = Array(value).collect do |column| 58 | table, attribute = column.to_s =~ /\./ ? column.to_s.split(".") : [model.name.tableize, column] 59 | 60 | "#{table}.#{attribute}" 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/search_cop/visitors/mysql.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | module Visitors 3 | module Mysql 4 | # rubocop:disable Naming/MethodName 5 | 6 | def visit_SearchCopGrammar_Attributes_Json(attribute) 7 | "#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}->#{quote "$.#{attribute.field_names.join(".")}"}" 8 | end 9 | 10 | class FulltextQuery < Visitor 11 | def visit_SearchCopGrammar_Nodes_MatchesFulltextNot(node) 12 | node.right.split(/[\s+'"<>()~-]+/).collect { |word| "-#{word}" }.join(" ") 13 | end 14 | 15 | def visit_SearchCopGrammar_Nodes_MatchesFulltext(node) 16 | words = node.right.split(/[\s+'"<>()~-]+/) 17 | 18 | return "" if words.empty? 19 | 20 | words.size > 1 ? "\"#{words.join " "}\"" : words.first 21 | end 22 | 23 | def visit_SearchCopGrammar_Nodes_And_Fulltext(node) 24 | res = node.nodes.collect do |child_node| 25 | if child_node.is_a?(SearchCopGrammar::Nodes::MatchesFulltextNot) 26 | visit child_node 27 | else 28 | child_node.nodes.size > 1 ? "+(#{visit child_node})" : "+#{visit child_node}" 29 | end 30 | end 31 | 32 | res.join " " 33 | end 34 | 35 | def visit_SearchCopGrammar_Nodes_Or_Fulltext(node) 36 | node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" ") 37 | end 38 | end 39 | 40 | def visit_SearchCopGrammar_Attributes_Collection(node) 41 | node.attributes.collect { |attribute| visit attribute }.join(", ") 42 | end 43 | 44 | def visit_SearchCopGrammar_Nodes_FulltextExpression(node) 45 | "MATCH(#{visit node.collection}) AGAINST(#{visit FulltextQuery.new(connection).visit(node.node)} IN BOOLEAN MODE)" 46 | end 47 | 48 | # rubocop:enable Naming/MethodName 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/integer_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class IntegerTest < SearchCop::TestCase 4 | def test_anywhere 5 | product = create(:product, stock: 1) 6 | 7 | assert_includes Product.search("1"), product 8 | refute_includes Product.search("0"), product 9 | end 10 | 11 | def test_includes 12 | product = create(:product, stock: 1) 13 | 14 | assert_includes Product.search("stock: 1"), product 15 | refute_includes Product.search("stock: 10"), product 16 | end 17 | 18 | def test_equals 19 | product = create(:product, stock: 1) 20 | 21 | assert_includes Product.search("stock = 1"), product 22 | refute_includes Product.search("stock = 0"), product 23 | end 24 | 25 | def test_equals_not 26 | product = create(:product, stock: 1) 27 | 28 | assert_includes Product.search("stock != 0"), product 29 | refute_includes Product.search("stock != 1"), product 30 | end 31 | 32 | def test_greater 33 | product = create(:product, stock: 1) 34 | 35 | assert_includes Product.search("stock > 0"), product 36 | refute_includes Product.search("stock < 1"), product 37 | end 38 | 39 | def test_greater_equals 40 | product = create(:product, stock: 1) 41 | 42 | assert_includes Product.search("stock >= 1"), product 43 | refute_includes Product.search("stock >= 2"), product 44 | end 45 | 46 | def test_less 47 | product = create(:product, stock: 1) 48 | 49 | assert_includes Product.search("stock < 2"), product 50 | refute_includes Product.search("stock < 1"), product 51 | end 52 | 53 | def test_less_equals 54 | product = create(:product, stock: 1) 55 | 56 | assert_includes Product.search("stock <= 1"), product 57 | refute_includes Product.search("stock <= 0"), product 58 | end 59 | 60 | def test_incompatible_datatype 61 | assert_raises SearchCop::IncompatibleDatatype do 62 | Product.unsafe_search "stock: Value" 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | 2 | # AttrSearchable to SearchCop 3 | 4 | As the set of features of AttrSearchable grew and grew, it has been neccessary 5 | to change its DSL and name, as no `attr_searchable` method is present anymore. 6 | The new DSL is cleaner and more concise. Morever, the migration process is 7 | simple. 8 | 9 | ## Installation 10 | 11 | Change 12 | 13 | gem 'attr_searchable' 14 | 15 | to 16 | 17 | gem 'search_cop' 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install search_cop 26 | 27 | ## General DSL 28 | 29 | AttrSearchable: 30 | 31 | ```ruby 32 | class Book < ActiveRecord::Base 33 | include AttrSearchable 34 | 35 | scope :search_scope, lambda { eager_load :comments, :users, :user } 36 | 37 | attr_searchable :title, :description 38 | attr_searchable :comment => ["comments.title", "comments.message"] 39 | attr_searchable :user => ["users.username", "users_books.username"] 40 | 41 | attr_searchable_options :title, :type => :fulltext, :default => true 42 | 43 | attr_searchable_alias :users_books => :user 44 | end 45 | ``` 46 | 47 | SearchCop: 48 | 49 | ```ruby 50 | class Book < ActiveRecord::Base 51 | include SearchCop 52 | 53 | search_scope :search do 54 | attributes :title, :description 55 | attributes :comment => ["comments.title", "comments.message"] 56 | attributes :user => ["users.username", "users_books.username"] 57 | 58 | options :title, :type => :fulltext, :default => true 59 | 60 | aliases :users_books => :user 61 | 62 | scope { eager_load :comments, :users, :user } 63 | end 64 | end 65 | ``` 66 | 67 | ## Reflection 68 | 69 | AttrSearchable: 70 | 71 | ```ruby 72 | Book.searchable_attributes 73 | Book.searchable_attribute_options 74 | Book.default_searchable_attributes 75 | Book.searchable_aliases 76 | ``` 77 | 78 | SearchCop: 79 | 80 | ```ruby 81 | Book.search_reflection(:search).attributes 82 | Book.search_reflection(:search).options 83 | Book.search_reflection(:search).default_attributes 84 | Book.search_reflection(:search).aliases 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /test/default_operator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class DefaultOperatorTest < SearchCop::TestCase 4 | def test_without_default_operator 5 | avengers = create(:product, title: "Title Avengers", description: "2012") 6 | inception = create(:product, title: "Title Inception", description: "2010") 7 | 8 | results = Product.search_multi_columns("Title Avengers") 9 | assert_includes results, avengers 10 | refute_includes results, inception 11 | 12 | results = Product.search_multi_columns("Title AND Avengers") 13 | assert_includes results, avengers 14 | refute_includes results, inception 15 | 16 | results = Product.search_multi_columns("Title OR Avengers") 17 | assert_includes results, avengers 18 | assert_includes results, inception 19 | 20 | results = Product.search(title: "Avengers", description: "2012") 21 | assert_includes results, avengers 22 | refute_includes results, inception 23 | end 24 | 25 | def test_with_specific_default_operator 26 | matrix = create(:product, title: "Matrix", description: "1999 Fantasy Sci-fi 2h 30m") 27 | star_wars = create(:product, title: "Star Wars", description: "2010 Sci-fi Thriller 2h 28m") 28 | 29 | results = Product.search("Star Wars", default_operator: "AND") 30 | refute_includes results, matrix 31 | assert_includes results, star_wars 32 | 33 | results = Product.search_multi_columns("Matrix movie", default_operator: "OR") 34 | assert_includes results, matrix 35 | refute_includes results, star_wars 36 | 37 | results = Product.search({ title: "Matrix", description: "2000" }, default_operator: :or) 38 | assert_includes results, matrix 39 | refute_includes results, star_wars 40 | end 41 | 42 | def test_with_invalid_default_operator 43 | assert_raises SearchCop::UnknownDefaultOperator do 44 | Product.search_multi_columns("Matrix movie", default_operator: :xpto) 45 | end 46 | 47 | assert_raises SearchCop::UnknownDefaultOperator do 48 | Product.search_multi_columns({ title: "Matrix movie" }, default_operator: :xpto) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/float_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class FloatTest < SearchCop::TestCase 4 | def test_anywhere 5 | product = create(:product, price: 10.5, created_at: Time.now - 1.day) 6 | 7 | assert_includes Product.search("10.5"), product 8 | refute_includes Product.search("11.5"), product 9 | end 10 | 11 | def test_includes 12 | product = create(:product, price: 10.5) 13 | 14 | assert_includes Product.search("price: 10.5"), product 15 | refute_includes Product.search("price: 11.5"), product 16 | end 17 | 18 | def test_equals 19 | product = create(:product, price: 10.5) 20 | 21 | assert_includes Product.search("price = 10.5"), product 22 | refute_includes Product.search("price = 11.5"), product 23 | end 24 | 25 | def test_equals_not 26 | product = create(:product, price: 10.5) 27 | 28 | assert_includes Product.search("price != 11.5"), product 29 | refute_includes Product.search("price != 10.5"), product 30 | end 31 | 32 | def test_greater 33 | product = create(:product, price: 10.5) 34 | 35 | assert_includes Product.search("price > 10.4"), product 36 | refute_includes Product.search("price < 10.5"), product 37 | end 38 | 39 | def test_greater_equals 40 | product = create(:product, price: 10.5) 41 | 42 | assert_includes Product.search("price >= 10.5"), product 43 | refute_includes Product.search("price >= 10.6"), product 44 | end 45 | 46 | def test_less 47 | product = create(:product, price: 10.5) 48 | 49 | assert_includes Product.search("price < 10.6"), product 50 | refute_includes Product.search("price < 10.5"), product 51 | end 52 | 53 | def test_less_equals 54 | product = create(:product, price: 10.5) 55 | 56 | assert_includes Product.search("price <= 10.5"), product 57 | refute_includes Product.search("price <= 10.4"), product 58 | end 59 | 60 | def test_negative 61 | product = create(:product, price: -10) 62 | 63 | assert_includes Product.search("price = -10"), product 64 | refute_includes Product.search("price = -11"), product 65 | end 66 | 67 | def test_incompatible_datatype 68 | assert_raises SearchCop::IncompatibleDatatype do 69 | Product.unsafe_search "price: Value" 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/search_cop.rb: -------------------------------------------------------------------------------- 1 | require "search_cop/version" 2 | require "search_cop/helpers" 3 | require "search_cop/search_scope" 4 | require "search_cop/query_info" 5 | require "search_cop/query_builder" 6 | require "search_cop/grammar_parser" 7 | require "search_cop/hash_parser" 8 | require "search_cop/visitors" 9 | 10 | module SearchCop 11 | class Error < StandardError; end 12 | 13 | class SpecificationError < Error; end 14 | class UnknownAttribute < SpecificationError; end 15 | class UnknownDefaultOperator < SpecificationError; end 16 | 17 | class RuntimeError < Error; end 18 | class UnknownColumn < RuntimeError; end 19 | class NoSearchableAttributes < RuntimeError; end 20 | class IncompatibleDatatype < RuntimeError; end 21 | class ParseError < RuntimeError; end 22 | 23 | module Parser 24 | def self.parse(query, query_info, query_options = {}) 25 | if query.is_a?(Hash) 26 | SearchCop::HashParser.new(query_info).parse(query, query_options) 27 | else 28 | SearchCop::GrammarParser.new(query_info).parse(query, query_options) 29 | end 30 | end 31 | end 32 | 33 | def self.included(base) 34 | base.extend ClassMethods 35 | 36 | base.class_attribute :search_scopes 37 | base.search_scopes = {} 38 | end 39 | 40 | module ClassMethods 41 | def search_scope(name, &block) 42 | self.search_scopes = search_scopes.dup 43 | 44 | search_scopes[name] = SearchScope.new(name, self) 45 | search_scopes[name].instance_exec(&block) 46 | 47 | send(:define_singleton_method, name) { |query, query_options = {}| search_cop(query, name, query_options) } 48 | send(:define_singleton_method, "unsafe_#{name}") { |query, query_options = {}| unsafe_search_cop(query, name, query_options) } 49 | end 50 | 51 | def search_reflection(scope_name) 52 | search_scopes[scope_name].reflection 53 | end 54 | 55 | def search_cop(query, scope_name, query_options) 56 | unsafe_search_cop(query, scope_name, query_options) 57 | rescue SearchCop::RuntimeError 58 | respond_to?(:none) ? none : where("1 = 0") 59 | end 60 | 61 | def unsafe_search_cop(query, scope_name, query_options) 62 | return respond_to?(:scoped) ? scoped : all if query.blank? 63 | 64 | query_builder = QueryBuilder.new(self, query, search_scopes[scope_name], query_options) 65 | 66 | scope = instance_exec(&search_scopes[scope_name].reflection.scope) if search_scopes[scope_name].reflection.scope 67 | scope ||= eager_load(query_builder.associations) if query_builder.associations.any? 68 | 69 | (scope || self).where(query_builder.sql) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/search_cop/visitors/postgres.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | module Visitors 3 | module Postgres 4 | # rubocop:disable Naming/MethodName 5 | 6 | def visit_SearchCopGrammar_Attributes_Json(attribute) 7 | elements = ["#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}", *attribute.field_names.map { |field_name| quote(field_name) }] 8 | 9 | "#{elements[0...-1].join("->")}->>#{elements.last}" 10 | end 11 | 12 | def visit_SearchCopGrammar_Attributes_Jsonb(attribute) 13 | elements = ["#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}", *attribute.field_names.map { |field_name| quote(field_name) }] 14 | 15 | "#{elements[0...-1].join("->")}->>#{elements.last}" 16 | end 17 | 18 | def visit_SearchCopGrammar_Attributes_Hstore(attribute) 19 | "#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}->#{quote attribute.field_names.first}" 20 | end 21 | 22 | class FulltextQuery < Visitor 23 | def visit_SearchCopGrammar_Nodes_MatchesFulltextNot(node) 24 | "!'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'" 25 | end 26 | 27 | def visit_SearchCopGrammar_Nodes_MatchesFulltext(node) 28 | "'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'" 29 | end 30 | 31 | def visit_SearchCopGrammar_Nodes_And_Fulltext(node) 32 | node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" & ") 33 | end 34 | 35 | def visit_SearchCopGrammar_Nodes_Or_Fulltext(node) 36 | node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" | ") 37 | end 38 | end 39 | 40 | def visit_SearchCopGrammar_Nodes_Matches(node) 41 | "(#{visit node.left} IS NOT NULL AND #{visit node.left} ILIKE #{visit node.right} ESCAPE #{visit "\\"})" 42 | end 43 | 44 | def visit_SearchCopGrammar_Attributes_Collection(node) 45 | res = node.attributes.collect do |attribute| 46 | if attribute.options[:coalesce] 47 | "COALESCE(#{visit attribute}, '')" 48 | else 49 | visit attribute 50 | end 51 | end 52 | 53 | res.join(" || ' ' || ") 54 | end 55 | 56 | def visit_SearchCopGrammar_Nodes_FulltextExpression(node) 57 | dictionary = node.collection.options[:dictionary] || "simple" 58 | 59 | "to_tsvector(#{visit dictionary}, #{visit node.collection}) @@ to_tsquery(#{visit dictionary}, #{visit FulltextQuery.new(connection).visit(node.node)})" 60 | end 61 | 62 | # rubocop:enable Naming/MethodName 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Gemspec/RequireMFA: 5 | Enabled: false 6 | 7 | Style/FetchEnvVar: 8 | Enabled: false 9 | 10 | Gemspec/RequiredRubyVersion: 11 | Enabled: false 12 | 13 | Style/CaseLikeIf: 14 | Enabled: false 15 | 16 | Style/RedundantArgument: 17 | Enabled: false 18 | 19 | Lint/EmptyBlock: 20 | Enabled: false 21 | 22 | Layout/EmptyLineBetweenDefs: 23 | Enabled: false 24 | 25 | Style/FrozenStringLiteralComment: 26 | Enabled: false 27 | 28 | Lint/RedundantRequireStatement: 29 | Enabled: false 30 | 31 | Layout/ArgumentAlignment: 32 | EnforcedStyle: with_fixed_indentation 33 | 34 | Layout/FirstArrayElementIndentation: 35 | EnforcedStyle: consistent 36 | 37 | Style/PercentLiteralDelimiters: 38 | Enabled: false 39 | 40 | Style/SpecialGlobalVars: 41 | EnforcedStyle: use_english_names 42 | 43 | Security/Eval: 44 | Enabled: false 45 | 46 | Style/WordArray: 47 | EnforcedStyle: brackets 48 | 49 | Style/ClassAndModuleChildren: 50 | Enabled: false 51 | 52 | Style/TrivialAccessors: 53 | Enabled: false 54 | 55 | Style/Alias: 56 | Enabled: false 57 | 58 | Style/StringLiteralsInInterpolation: 59 | EnforcedStyle: double_quotes 60 | 61 | Metrics/ClassLength: 62 | Enabled: false 63 | 64 | Naming/MethodParameterName: 65 | Enabled: false 66 | 67 | Style/SymbolArray: 68 | EnforcedStyle: brackets 69 | 70 | Layout/RescueEnsureAlignment: 71 | Enabled: false 72 | 73 | Layout/LineLength: 74 | Enabled: false 75 | 76 | Metrics/MethodLength: 77 | Enabled: false 78 | 79 | Metrics/ModuleLength: 80 | Enabled: false 81 | 82 | Style/ZeroLengthPredicate: 83 | Enabled: false 84 | 85 | Metrics/PerceivedComplexity: 86 | Enabled: false 87 | 88 | Metrics/AbcSize: 89 | Enabled: false 90 | 91 | Metrics/CyclomaticComplexity: 92 | Enabled: false 93 | 94 | Metrics/BlockLength: 95 | Enabled: false 96 | 97 | Metrics/BlockNesting: 98 | Enabled: false 99 | 100 | Style/NumericPredicate: 101 | Enabled: false 102 | 103 | Naming/AccessorMethodName: 104 | Enabled: false 105 | 106 | Naming/MemoizedInstanceVariableName: 107 | Enabled: false 108 | 109 | Style/StringLiterals: 110 | EnforcedStyle: double_quotes 111 | 112 | Style/Documentation: 113 | Enabled: false 114 | 115 | Naming/ConstantName: 116 | Enabled: false 117 | 118 | Style/MutableConstant: 119 | Enabled: false 120 | 121 | Layout/MultilineMethodCallIndentation: 122 | EnforcedStyle: indented 123 | 124 | Layout/ParameterAlignment: 125 | EnforcedStyle: with_fixed_indentation 126 | 127 | Lint/UnusedMethodArgument: 128 | Enabled: false 129 | 130 | Style/IfUnlessModifier: 131 | Enabled: false 132 | 133 | Style/RedundantBegin: 134 | Enabled: false 135 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | Version 1.4.1: 5 | 6 | * Fix fulltext query with only special charaters 7 | 8 | Version 1.4.0: 9 | 10 | * Add support for the trilogy mysql connection adapter 11 | 12 | Version 1.3.0: 13 | 14 | * Support json fields for postgres, mysql and sqlite 15 | * Support jsonb and hstore fields for postgres 16 | * Add support for timestamptz for postgres 17 | 18 | Version 1.2.3: 19 | 20 | * Fix table name for namespaced and inherited models #70 #67 21 | 22 | Version 1.2.2: 23 | 24 | * Fix table name for namespaced models #70 25 | 26 | Version 1.2.1: 27 | 28 | * Fix use of `table_name` to work with inherited models 29 | * Fix linter, add ruby 3 and rails 7 to ci pipeline 30 | 31 | Version 1.2.0: 32 | 33 | * Added support for disabling the right wildcard 34 | * Fixed escaping of wildcard chars (`_`, `%`) 35 | 36 | Version 1.1.0: 37 | 38 | * Adds customizable default operator to concatenate conditions (#49) 39 | * Make the postgis adapter use the postgres extensions 40 | 41 | Version 1.0.9: 42 | 43 | * Use `[:blank:]` instead of `\s` for space (#46) 44 | * Updated `SearchCop::Visitors::Visitor` to check the connection's `adapter_name` when extending. (#47) 45 | * Fix for negative numeric values 46 | * allow searching for relative dates, like hours, days, weeks, months or years ago 47 | 48 | Version 1.0.8: 49 | 50 | * No longer add search scope methods globally #34 51 | 52 | Version 1.0.7: 53 | 54 | * Bugfix regarding `NOT` queries in fulltext mode #32 55 | * Safely handle `NULL` values for match queries 56 | * Added coalesce option 57 | 58 | Version 1.0.6: 59 | 60 | * Fixes a bug regarding date overflows in PostgreSQL 61 | 62 | Version 1.0.5: 63 | 64 | * Fixes a bug regarding quotation 65 | 66 | Version 1.0.4: 67 | 68 | * Fix for Rails 4.2 regression regarding reflection access 69 | 70 | Version 1.0.3: 71 | 72 | * Supporting Rails 4.2 73 | * Dropped Arel dependencies 74 | 75 | Version 1.0.2: 76 | 77 | * Avoid eager loading when no associations referenced 78 | * Prefer objects over class names 79 | * Readme extended 80 | 81 | Version 1.0.1: 82 | 83 | * Inheritance fix 84 | 85 | Version 1.0.0: 86 | 87 | * Project name changed to SearchCop 88 | * Scope support added 89 | * Multiple DSL changes 90 | 91 | Version 0.0.5: 92 | 93 | * Supporting `:default => false` 94 | * Datetime/Date greater operator fix 95 | * Use reflection to find associated models 96 | * Providing reflection 97 | 98 | Version 0.0.4: 99 | 100 | * Fixed date attributes 101 | * Fail softly for mixed datatype attributes 102 | * Support custom table, class and alias names via `attr_searchable_alias` 103 | 104 | Version 0.0.3: 105 | 106 | * `belongs_to` association fixes 107 | 108 | Version 0.0.2: 109 | 110 | * Arel abstraction layer added 111 | * `count()` queries resulting in "Cannot visit AttrSearchableGrammar::Nodes..." fixed 112 | * Better error messages 113 | * `Model#unsafe_search` added 114 | 115 | -------------------------------------------------------------------------------- /test/hash_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class HashTest < SearchCop::TestCase 4 | def test_subquery 5 | product1 = create(:product, title: "Title1", description: "Description") 6 | product2 = create(:product, title: "Title2", description: "Description") 7 | product3 = create(:product, title: "TItle3", description: "Description") 8 | 9 | results = Product.search(or: [{ query: "Description Title1" }, { query: "Description Title2" }]) 10 | 11 | assert_includes results, product1 12 | assert_includes results, product2 13 | refute_includes results, product3 14 | end 15 | 16 | def test_matches 17 | expected = create(:product, title: "Expected") 18 | rejected = create(:product, title: "Rejected") 19 | 20 | results = Product.search(title: { matches: "Expected" }) 21 | 22 | assert_includes results, expected 23 | refute_includes results, rejected 24 | end 25 | 26 | def test_matches_default 27 | expected = create(:product, title: "Expected") 28 | rejected = create(:product, title: "Rejected") 29 | 30 | results = Product.search(title: "Expected") 31 | 32 | assert_includes results, expected 33 | refute_includes results, rejected 34 | end 35 | 36 | def test_eq 37 | expected = create(:product, title: "Expected") 38 | rejected = create(:product, title: "Rejected") 39 | 40 | results = Product.search(title: { eq: "Expected" }) 41 | 42 | assert_includes results, expected 43 | refute_includes results, rejected 44 | end 45 | 46 | def test_not_eq 47 | expected = create(:product, title: "Expected") 48 | rejected = create(:product, title: "Rejected") 49 | 50 | results = Product.search(title: { not_eq: "Rejected" }) 51 | 52 | assert_includes results, expected 53 | refute_includes results, rejected 54 | end 55 | 56 | def test_gt 57 | expected = create(:product, stock: 1) 58 | rejected = create(:product, stock: 0) 59 | 60 | results = Product.search(stock: { gt: 0 }) 61 | 62 | assert_includes results, expected 63 | refute_includes results, rejected 64 | end 65 | 66 | def test_gteq 67 | expected = create(:product, stock: 1) 68 | rejected = create(:product, stock: 0) 69 | 70 | results = Product.search(stock: { gteq: 1 }) 71 | 72 | assert_includes results, expected 73 | refute_includes results, rejected 74 | end 75 | 76 | def test_lt 77 | expected = create(:product, stock: 0) 78 | rejected = create(:product, stock: 1) 79 | 80 | results = Product.search(stock: { lt: 1 }) 81 | 82 | assert_includes results, expected 83 | refute_includes results, rejected 84 | end 85 | 86 | def test_lteq 87 | expected = create(:product, stock: 0) 88 | rejected = create(:product, stock: 1) 89 | 90 | results = Product.search(stock: { lteq: 0 }) 91 | 92 | assert_includes results, expected 93 | refute_includes results, rejected 94 | end 95 | 96 | def test_custom_matcher 97 | expected = create(:product, title: "Expected") 98 | rejected = create(:product, title: "Rejected") 99 | 100 | results = Product.search(title: { custom_eq: "Expected" }) 101 | 102 | assert_includes results, expected 103 | refute_includes results, rejected 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/date_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class DateTest < SearchCop::TestCase 4 | def test_mapping 5 | product = create(:product, created_on: Date.parse("2014-05-01")) 6 | 7 | assert_includes Product.search("created_on: 2014"), product 8 | assert_includes Product.search("created_on: 2014-05"), product 9 | assert_includes Product.search("created_on: 2014-05-01"), product 10 | end 11 | 12 | def test_anywhere 13 | product = create(:product, created_on: Date.parse("2014-05-01")) 14 | 15 | assert_includes Product.search("2014-05-01"), product 16 | refute_includes Product.search("2014-05-02"), product 17 | end 18 | 19 | def test_includes 20 | product = create(:product, created_on: Date.parse("2014-05-01")) 21 | 22 | assert_includes Product.search("created_on: 2014-05-01"), product 23 | refute_includes Product.search("created_on: 2014-05-02"), product 24 | end 25 | 26 | def test_equals 27 | product = create(:product, created_on: Date.parse("2014-05-01")) 28 | 29 | assert_includes Product.search("created_on = 2014-05-01"), product 30 | refute_includes Product.search("created_on = 2014-05-02"), product 31 | end 32 | 33 | def test_equals_not 34 | product = create(:product, created_on: Date.parse("2014-05-01")) 35 | 36 | assert_includes Product.search("created_on != 2014-05-02"), product 37 | refute_includes Product.search("created_on != 2014-05-01"), product 38 | end 39 | 40 | def test_greater 41 | product = create(:product, created_on: Date.parse("2014-05-01")) 42 | 43 | assert_includes Product.search("created_on > 2014-04-01"), product 44 | refute_includes Product.search("created_on > 2014-05-01"), product 45 | end 46 | 47 | def test_greater_equals 48 | product = create(:product, created_on: Date.parse("2014-05-01")) 49 | 50 | assert_includes Product.search("created_on >= 2014-05-01"), product 51 | refute_includes Product.search("created_on >= 2014-05-02"), product 52 | end 53 | 54 | def test_less 55 | product = create(:product, created_on: Date.parse("2014-05-01")) 56 | 57 | assert_includes Product.search("created_on < 2014-05-02"), product 58 | refute_includes Product.search("created_on < 2014-05-01"), product 59 | end 60 | 61 | def test_less_equals 62 | product = create(:product, created_on: Date.parse("2014-05-02")) 63 | 64 | assert_includes Product.search("created_on <= 2014-05-02"), product 65 | refute_includes Product.search("created_on <= 2014-05-01"), product 66 | end 67 | 68 | def test_days_ago 69 | product = create(:product, created_at: 2.days.ago.to_date) 70 | 71 | assert_includes Product.search("created_at <= '1 day ago'"), product 72 | refute_includes Product.search("created_at <= '3 days ago'"), product 73 | end 74 | 75 | def test_weeks_ago 76 | product = create(:product, created_at: 2.weeks.ago.to_date) 77 | 78 | assert_includes Product.search("created_at <= '1 weeks ago'"), product 79 | refute_includes Product.search("created_at <= '3 weeks ago'"), product 80 | end 81 | 82 | def test_months_ago 83 | product = create(:product, created_at: 2.months.ago.to_date) 84 | 85 | assert_includes Product.search("created_at <= '1 months ago'"), product 86 | refute_includes Product.search("created_at <= '3 months ago'"), product 87 | end 88 | 89 | def test_years_ago 90 | product = create(:product, created_at: 2.years.ago.to_date) 91 | 92 | assert_includes Product.search("created_at <= '1 years ago'"), product 93 | refute_includes Product.search("created_at <= '3 years ago'"), product 94 | end 95 | 96 | def test_no_overflow 97 | assert_nothing_raised do 98 | Product.search("created_on: 1000000").to_a 99 | end 100 | end 101 | 102 | def test_incompatible_datatype 103 | assert_raises SearchCop::IncompatibleDatatype do 104 | Product.unsafe_search "created_on: Value" 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/search_cop/visitors/visitor.rb: -------------------------------------------------------------------------------- 1 | module SearchCop 2 | module Visitors 3 | class Visitor 4 | # rubocop:disable Naming/MethodName 5 | 6 | attr_accessor :connection 7 | 8 | def initialize(connection) 9 | @connection = connection 10 | 11 | extend(SearchCop::Visitors::Mysql) if @connection.adapter_name =~ /mysql|trilogy/i 12 | extend(SearchCop::Visitors::Postgres) if @connection.adapter_name =~ /postgres|postgis/i 13 | extend(SearchCop::Visitors::Sqlite) if @connection.adapter_name =~ /sqlite/i 14 | end 15 | 16 | def visit(visit_node = node) 17 | send "visit_#{visit_node.class.name.gsub("::", "_")}", visit_node 18 | end 19 | 20 | def visit_SearchCopGrammar_Nodes_And(node) 21 | "(#{node.nodes.collect { |n| visit n }.join(" AND ")})" 22 | end 23 | 24 | def visit_SearchCopGrammar_Nodes_Or(node) 25 | "(#{node.nodes.collect { |n| visit n }.join(" OR ")})" 26 | end 27 | 28 | def visit_SearchCopGrammar_Nodes_GreaterThan(node) 29 | "#{visit node.left} > #{visit node.right}" 30 | end 31 | 32 | def visit_SearchCopGrammar_Nodes_GreaterThanOrEqual(node) 33 | "#{visit node.left} >= #{visit node.right}" 34 | end 35 | 36 | def visit_SearchCopGrammar_Nodes_LessThan(node) 37 | "#{visit node.left} < #{visit node.right}" 38 | end 39 | 40 | def visit_SearchCopGrammar_Nodes_LessThanOrEqual(node) 41 | "#{visit node.left} <= #{visit node.right}" 42 | end 43 | 44 | def visit_SearchCopGrammar_Nodes_Equality(node) 45 | "#{visit node.left} = #{visit node.right}" 46 | end 47 | 48 | def visit_SearchCopGrammar_Nodes_NotEqual(node) 49 | "#{visit node.left} != #{visit node.right}" 50 | end 51 | 52 | def visit_SearchCopGrammar_Nodes_Matches(node) 53 | "(#{visit node.left} IS NOT NULL AND #{visit node.left} LIKE #{visit node.right} ESCAPE #{visit "\\"})" 54 | end 55 | 56 | def visit_SearchCopGrammar_Nodes_Not(node) 57 | "NOT (#{visit node.object})" 58 | end 59 | 60 | def visit_SearchCopGrammar_Nodes_Generator(node) 61 | instance_exec visit(node.left), node.right[:value], &node.right[:generator] 62 | end 63 | 64 | def quote_table_name(name) 65 | connection.quote_table_name name 66 | end 67 | 68 | def quote_column_name(name) 69 | connection.quote_column_name name 70 | end 71 | 72 | def visit_attribute(attribute) 73 | "#{quote_table_name attribute.table_alias}.#{quote_column_name attribute.column_name}" 74 | end 75 | 76 | alias :visit_SearchCopGrammar_Attributes_String :visit_attribute 77 | alias :visit_SearchCopGrammar_Attributes_Text :visit_attribute 78 | alias :visit_SearchCopGrammar_Attributes_Float :visit_attribute 79 | alias :visit_SearchCopGrammar_Attributes_Integer :visit_attribute 80 | alias :visit_SearchCopGrammar_Attributes_Decimal :visit_attribute 81 | alias :visit_SearchCopGrammar_Attributes_Datetime :visit_attribute 82 | alias :visit_SearchCopGrammar_Attributes_Timestamp :visit_attribute 83 | alias :visit_SearchCopGrammar_Attributes_Timestamptz :visit_attribute 84 | alias :visit_SearchCopGrammar_Attributes_Date :visit_attribute 85 | alias :visit_SearchCopGrammar_Attributes_Time :visit_attribute 86 | alias :visit_SearchCopGrammar_Attributes_Boolean :visit_attribute 87 | 88 | def quote(value) 89 | connection.quote value 90 | end 91 | 92 | alias :visit_TrueClass :quote 93 | alias :visit_FalseClass :quote 94 | alias :visit_String :quote 95 | alias :visit_Time :quote 96 | alias :visit_Date :quote 97 | alias :visit_Float :quote 98 | alias :visit_Fixnum :quote 99 | alias :visit_Symbol :quote 100 | alias :visit_Integer :quote 101 | 102 | # rubocop:enable Naming/MethodName 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/search_cop_grammar.rb: -------------------------------------------------------------------------------- 1 | require "search_cop_grammar/attributes" 2 | require "search_cop_grammar/nodes" 3 | 4 | module SearchCopGrammar 5 | class BaseNode < Treetop::Runtime::SyntaxNode 6 | attr_writer :query_info, :query_options 7 | 8 | def query_info 9 | (@query_info ||= nil) || parent.query_info 10 | end 11 | 12 | def query_options 13 | (@query_options ||= nil) || parent.query_options 14 | end 15 | 16 | def evaluate 17 | elements.collect(&:evaluate).inject(:and) 18 | end 19 | 20 | def elements 21 | super.reject { |element| element.instance_of?(Treetop::Runtime::SyntaxNode) } 22 | end 23 | 24 | def collection_for(key) 25 | raise(SearchCop::UnknownColumn, "Unknown column #{key}") if query_info.scope.reflection.attributes[key].nil? 26 | 27 | Attributes::Collection.new query_info, key 28 | end 29 | end 30 | 31 | class OperatorNode < Treetop::Runtime::SyntaxNode 32 | def evaluate 33 | text_value 34 | end 35 | end 36 | 37 | class ComplexExpression < BaseNode; end 38 | class ParenthesesExpression < BaseNode; end 39 | 40 | class ComparativeExpression < BaseNode 41 | def evaluate 42 | elements[0].collection.send elements[1].method_name, elements[2].text_value 43 | end 44 | end 45 | 46 | class IncludesOperator < OperatorNode 47 | def method_name 48 | :matches 49 | end 50 | end 51 | 52 | class EqualOperator < OperatorNode 53 | def method_name 54 | :eq 55 | end 56 | end 57 | 58 | class UnequalOperator < OperatorNode 59 | def method_name 60 | :not_eq 61 | end 62 | end 63 | 64 | class GreaterEqualOperator < OperatorNode 65 | def method_name 66 | :gteq 67 | end 68 | end 69 | 70 | class GreaterOperator < OperatorNode 71 | def method_name 72 | :gt 73 | end 74 | end 75 | 76 | class LessEqualOperator < OperatorNode 77 | def method_name 78 | :lteq 79 | end 80 | end 81 | 82 | class LessOperator < OperatorNode 83 | def method_name 84 | :lt 85 | end 86 | end 87 | 88 | class AnywhereExpression < BaseNode 89 | def evaluate 90 | queries = query_info.scope.reflection.default_attributes.keys.collect { |key| collection_for key }.select { |collection| collection.compatible? text_value }.collect { |collection| collection.matches text_value } 91 | 92 | raise SearchCop::NoSearchableAttributes if queries.empty? 93 | 94 | queries.flatten.inject(:or) 95 | end 96 | end 97 | 98 | class SingleQuotedAnywhereExpression < AnywhereExpression 99 | def text_value 100 | super.gsub(/^'|'$/, "") 101 | end 102 | end 103 | 104 | class DoubleQuotedAnywhereExpression < AnywhereExpression 105 | def text_value 106 | super.gsub(/^"|"$/, "") 107 | end 108 | end 109 | 110 | class AndExpression < BaseNode 111 | def evaluate 112 | [elements.first.evaluate, elements.last.evaluate].inject(:and) 113 | end 114 | end 115 | 116 | class AndOrExpression < BaseNode 117 | def evaluate 118 | default_operator = SearchCop::Helpers.sanitize_default_operator(query_options) 119 | [elements.first.evaluate, elements.last.evaluate].inject(default_operator) 120 | end 121 | end 122 | 123 | class OrExpression < BaseNode 124 | def evaluate 125 | [elements.first.evaluate, elements.last.evaluate].inject(:or) 126 | end 127 | end 128 | 129 | class NotExpression < BaseNode 130 | def evaluate 131 | elements.first.evaluate.not 132 | end 133 | end 134 | 135 | class Column < BaseNode 136 | def collection 137 | collection_for text_value 138 | end 139 | end 140 | 141 | class SingleQuotedValue < BaseNode 142 | def text_value 143 | super.gsub(/^'|'$/, "") 144 | end 145 | end 146 | 147 | class DoubleQuotedValue < BaseNode 148 | def text_value 149 | super.gsub(/^"|"$/, "") 150 | end 151 | end 152 | 153 | class Value < BaseNode; end 154 | end 155 | -------------------------------------------------------------------------------- /lib/search_cop_grammar/nodes.rb: -------------------------------------------------------------------------------- 1 | require "treetop" 2 | 3 | module SearchCopGrammar 4 | module Nodes 5 | module Base 6 | def and(node) 7 | And.new self, node 8 | end 9 | 10 | def or(node) 11 | Or.new self, node 12 | end 13 | 14 | def not 15 | Not.new self 16 | end 17 | 18 | def can_flatten? 19 | false 20 | end 21 | 22 | def flatten! 23 | self 24 | end 25 | 26 | def can_group? 27 | false 28 | end 29 | 30 | def group! 31 | self 32 | end 33 | 34 | def fulltext? 35 | false 36 | end 37 | 38 | def can_optimize? 39 | can_flatten? || can_group? 40 | end 41 | 42 | def optimize! 43 | flatten!.group! while can_optimize? 44 | 45 | finalize! 46 | end 47 | 48 | def finalize! 49 | self 50 | end 51 | 52 | def nodes 53 | [] 54 | end 55 | end 56 | 57 | class Binary 58 | include Base 59 | 60 | attr_accessor :left, :right 61 | 62 | def initialize(left, right) 63 | @left = left 64 | @right = right 65 | end 66 | end 67 | 68 | class Equality < Binary; end 69 | class NotEqual < Binary; end 70 | class GreaterThan < Binary; end 71 | class GreaterThanOrEqual < Binary; end 72 | class LessThan < Binary; end 73 | class LessThanOrEqual < Binary; end 74 | class Matches < Binary; end 75 | class Generator < Binary; end 76 | 77 | class Not 78 | include Base 79 | 80 | attr_accessor :object 81 | 82 | def initialize(object) 83 | @object = object 84 | end 85 | 86 | def finalize! 87 | @object.finalize! 88 | 89 | self 90 | end 91 | end 92 | 93 | class MatchesFulltext < Binary 94 | include Base 95 | 96 | def not 97 | MatchesFulltextNot.new left, right 98 | end 99 | 100 | def fulltext? 101 | true 102 | end 103 | 104 | def finalize! 105 | FulltextExpression.new collection, self 106 | end 107 | 108 | def collection 109 | left 110 | end 111 | end 112 | 113 | class MatchesFulltextNot < MatchesFulltext; end 114 | 115 | class FulltextExpression 116 | include Base 117 | 118 | attr_reader :collection, :node 119 | 120 | def initialize(collection, node) 121 | @collection = collection 122 | @node = node 123 | end 124 | end 125 | 126 | class Collection 127 | include Base 128 | 129 | attr_reader :nodes 130 | 131 | def initialize(*nodes) 132 | @nodes = nodes.flatten 133 | end 134 | 135 | def can_flatten? 136 | nodes.any?(&:can_flatten?) || nodes.any? { |node| node.is_a?(self.class) || node.nodes.size == 1 } 137 | end 138 | 139 | def flatten!(&block) 140 | @nodes = nodes.collect(&:flatten!).collect { |node| node.is_a?(self.class) || node.nodes.size == 1 ? node.nodes : node }.flatten 141 | 142 | self 143 | end 144 | 145 | def can_group? 146 | nodes.reject(&:fulltext?).any?(&:can_group?) || nodes.select(&:fulltext?).group_by(&:collection).any? { |_, group| group.size > 1 } 147 | end 148 | 149 | def group! 150 | @nodes = nodes.reject(&:fulltext?).collect(&:group!) + nodes.select(&:fulltext?).group_by(&:collection).collect { |collection, group| group.size > 1 ? self.class::Fulltext.new(collection, group) : group.first } 151 | 152 | self 153 | end 154 | 155 | def finalize! 156 | @nodes = nodes.collect(&:finalize!) 157 | 158 | self 159 | end 160 | end 161 | 162 | class FulltextCollection < Collection 163 | attr_reader :collection 164 | 165 | def initialize(collection, *nodes) 166 | @collection = collection 167 | 168 | super(*nodes) 169 | end 170 | 171 | def fulltext? 172 | true 173 | end 174 | 175 | def finalize! 176 | FulltextExpression.new collection, self 177 | end 178 | end 179 | 180 | class And < Collection 181 | class Fulltext < FulltextCollection; end 182 | end 183 | 184 | class Or < Collection 185 | class Fulltext < FulltextCollection; end 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /test/datetime_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class DatetimeTest < SearchCop::TestCase 4 | def test_mapping 5 | product = create(:product, created_at: Time.parse("2014-05-01 12:30:30")) 6 | 7 | assert_includes Product.search("created_at: 2014"), product 8 | assert_includes Product.search("created_at: 2014-05"), product 9 | assert_includes Product.search("created_at: 2014-05-01"), product 10 | assert_includes Product.search("created_at: '2014-05-01 12:30:30'"), product 11 | end 12 | 13 | def test_anywhere 14 | product = create(:product, created_at: Time.parse("2014-05-01")) 15 | 16 | assert_includes Product.search("2014-05-01"), product 17 | refute_includes Product.search("2014-05-02"), product 18 | end 19 | 20 | def test_includes 21 | product = create(:product, created_at: Time.parse("2014-05-01")) 22 | 23 | assert_includes Product.search("created_at: 2014-05-01"), product 24 | refute_includes Product.search("created_at: 2014-05-02"), product 25 | end 26 | 27 | def test_equals 28 | product = create(:product, created_at: Time.parse("2014-05-01")) 29 | 30 | assert_includes Product.search("created_at = 2014-05-01"), product 31 | refute_includes Product.search("created_at = 2014-05-02"), product 32 | end 33 | 34 | def test_equals_not 35 | product = create(:product, created_at: Time.parse("2014-05-01")) 36 | 37 | assert_includes Product.search("created_at != 2014-05-02"), product 38 | refute_includes Product.search("created_at != 2014-05-01"), product 39 | end 40 | 41 | def test_greater 42 | product = create(:product, created_at: Time.parse("2014-05-01")) 43 | 44 | assert_includes Product.search("created_at > 2014-04-01"), product 45 | refute_includes Product.search("created_at > 2014-05-01"), product 46 | end 47 | 48 | def test_greater_equals 49 | product = create(:product, created_at: Time.parse("2014-05-01")) 50 | 51 | assert_includes Product.search("created_at >= 2014-05-01"), product 52 | refute_includes Product.search("created_at >= 2014-05-02"), product 53 | end 54 | 55 | def test_less 56 | product = create(:product, created_at: Time.parse("2014-05-01")) 57 | 58 | assert_includes Product.search("created_at < 2014-05-02"), product 59 | refute_includes Product.search("created_at < 2014-05-01"), product 60 | end 61 | 62 | def test_less_equals 63 | product = create(:product, created_at: Time.parse("2014-05-02")) 64 | 65 | assert_includes Product.search("created_at <= 2014-05-02"), product 66 | refute_includes Product.search("created_at <= 2014-05-01"), product 67 | end 68 | 69 | def test_hours_ago 70 | product = create(:product, created_at: 5.hours.ago) 71 | 72 | assert_includes Product.search("created_at <= '4 hours ago'"), product 73 | refute_includes Product.search("created_at <= '6 hours ago'"), product 74 | end 75 | 76 | def test_days_ago 77 | product = create(:product, created_at: 2.days.ago) 78 | 79 | assert_includes Product.search("created_at <= '1 day ago'"), product 80 | refute_includes Product.search("created_at <= '3 days ago'"), product 81 | end 82 | 83 | def test_weeks_ago 84 | product = create(:product, created_at: 2.weeks.ago) 85 | 86 | assert_includes Product.search("created_at <= '1 weeks ago'"), product 87 | refute_includes Product.search("created_at <= '3 weeks ago'"), product 88 | end 89 | 90 | def test_months_ago 91 | product = create(:product, created_at: 2.months.ago) 92 | 93 | assert_includes Product.search("created_at <= '1 months ago'"), product 94 | refute_includes Product.search("created_at <= '3 months ago'"), product 95 | end 96 | 97 | def test_years_ago 98 | product = create(:product, created_at: 2.years.ago) 99 | 100 | assert_includes Product.search("created_at <= '1 years ago'"), product 101 | refute_includes Product.search("created_at <= '3 years ago'"), product 102 | end 103 | 104 | if DATABASE == "postgres" && ActiveRecord::VERSION::MAJOR >= 7 105 | def test_timestamp_with_zone 106 | product = create(:product, timestamp_with_zone: Time.parse("2014-05-01 12:30:30 CEST")) 107 | 108 | assert_includes Product.search("timestamp_with_zone: 2014"), product 109 | assert_includes Product.search("timestamp_with_zone: 2014-05"), product 110 | assert_includes Product.search("timestamp_with_zone: 2014-05-01"), product 111 | assert_includes Product.search("timestamp_with_zone: '2014-05-01 12:30:30 CEST'"), product 112 | refute_includes Product.search("timestsamp_with_zone '2014-05-01 12:30:30 UTC'"), product 113 | end 114 | end 115 | 116 | def test_no_overflow 117 | assert_nothing_raised do 118 | Product.search("created_at: 1000000").to_a 119 | end 120 | end 121 | 122 | def test_incompatible_datatype 123 | assert_raises SearchCop::IncompatibleDatatype do 124 | Product.unsafe_search "created_at: Value" 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/search_cop_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class SearchCopTest < SearchCop::TestCase 4 | def test_scope_before 5 | expected = create(:product, stock: 1, title: "Title") 6 | rejected = create(:product, stock: 0, title: "Title") 7 | 8 | results = Product.where(stock: 1).search("Title") 9 | 10 | assert_includes results, expected 11 | refute_includes results, rejected 12 | end 13 | 14 | def test_scope_after 15 | expected = create(:product, stock: 1, title: "Title") 16 | rejected = create(:product, stock: 0, title: "Title") 17 | 18 | results = Product.search("Title").where(stock: 1) 19 | 20 | assert_includes results, expected 21 | refute_includes results, rejected 22 | end 23 | 24 | def test_scope 25 | expected = create(:product, stock: 1, title: "Title") 26 | rejected = create(:product, stock: 0, title: "Title") 27 | 28 | results = with_scope(Product.search_scopes[:search], -> { where stock: 1 }) { Product.search("title: Title") } 29 | 30 | assert_includes results, expected 31 | refute_includes results, rejected 32 | end 33 | 34 | def test_multi_associations 35 | product = create(:product, comments: [ 36 | create(:comment, title: "Title1", message: "Message1"), 37 | create(:comment, title: "Title2", message: "Message2") 38 | ]) 39 | 40 | assert_includes Product.search("comment: Title1 comment: Message1"), product 41 | assert_includes Product.search("comment: Title2 comment: Message2"), product 42 | end 43 | 44 | def test_single_association 45 | expected = create(:comment, user: create(:user, username: "Expected")) 46 | rejected = create(:comment, user: create(:user, username: "Rejected")) 47 | 48 | results = Comment.search("user: Expected") 49 | 50 | assert_includes results, expected 51 | refute_includes results, rejected 52 | end 53 | 54 | def test_deep_associations 55 | expected = create(:product, comments: [create(:comment, user: create(:user, username: "Expected"))]) 56 | rejected = create(:product, comments: [create(:comment, user: create(:user, username: "Rejected"))]) 57 | 58 | results = Product.search("user: Expected") 59 | 60 | assert_includes results, expected 61 | refute_includes results, rejected 62 | end 63 | 64 | def test_inherited_model 65 | expected = create(:available_product, comments: [create(:comment, user: create(:user, username: "Expected"))]) 66 | rejected = create(:available_product, comments: [create(:comment, user: create(:user, username: "Rejected"))]) 67 | 68 | results = AvailableProduct.search("user: Expected") 69 | assert_includes results, expected 70 | refute_includes results, rejected 71 | end 72 | 73 | def test_namespaced_model 74 | expected = create(:blog_post, title: "Expected") 75 | rejected = create(:blog_post, title: "Rejected") 76 | 77 | results = Blog::Post.search("Expected") 78 | 79 | assert_includes results, expected 80 | refute_includes results, rejected 81 | end 82 | 83 | def test_namespaced_model_with_associations 84 | expected = create(:blog_post, user: create(:user, username: "Expected")) 85 | rejected = create(:blog_post, user: create(:user, username: "Rejected")) 86 | 87 | results = Blog::Post.search("user:Expected") 88 | 89 | assert_includes results, expected 90 | refute_includes results, rejected 91 | end 92 | 93 | def test_multiple 94 | product = create(:product, comments: [create(:comment, title: "Title", message: "Message")]) 95 | 96 | assert_includes Product.search("comment: Title"), product 97 | assert_includes Product.search("comment: Message"), product 98 | end 99 | 100 | def test_default 101 | product1 = create(:product, title: "Expected") 102 | product2 = create(:product, description: "Expected") 103 | 104 | results = Product.search("Expected") 105 | 106 | assert_includes results, product1 107 | assert_includes results, product2 108 | end 109 | 110 | def test_custom_default_enabled 111 | product1 = create(:product, title: "Expected") 112 | product2 = create(:product, description: "Expected") 113 | product3 = create(:product, brand: "Expected") 114 | 115 | results = with_options(Product.search_scopes[:search], :primary, default: true) { Product.search "Expected" } 116 | 117 | assert_includes results, product1 118 | assert_includes results, product2 119 | refute_includes results, product3 120 | end 121 | 122 | def test_custom_default_disabled 123 | product1 = create(:product, brand: "Expected") 124 | product2 = create(:product, notice: "Expected") 125 | 126 | results = with_options(Product.search_scopes[:search], :notice, default: false) { Product.search "Expected" } 127 | 128 | assert_includes results, product1 129 | refute_includes results, product2 130 | end 131 | 132 | def test_count 133 | create_list :product, 2, title: "Expected" 134 | 135 | assert_equal 2, Product.search("Expected").count 136 | end 137 | 138 | def test_default_attributes_true 139 | with_options(Product.search_scopes[:search], :title, default: true) do 140 | with_options(Product.search_scopes[:search], :description, default: true) do 141 | assert_equal ["title", "description"], Product.search_scopes[:search].reflection.default_attributes.keys 142 | end 143 | end 144 | end 145 | 146 | def test_default_attributes_fales 147 | with_options(Product.search_scopes[:search], :title, default: false) do 148 | with_options(Product.search_scopes[:search], :description, default: false) do 149 | assert_equal Product.search_scopes[:search].reflection.attributes.keys - ["title", "description"], Product.search_scopes[:search].reflection.default_attributes.keys 150 | end 151 | end 152 | end 153 | 154 | def test_search_reflection 155 | assert_not_nil Product.search_reflection(:search) 156 | assert_not_nil Product.search_reflection(:user_search) 157 | end 158 | 159 | def test_blank 160 | assert_equal Product.all, Product.search("") 161 | end 162 | 163 | def test_not_adding_search_to_object 164 | refute Object.respond_to?(:search) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "search_cop" 2 | 3 | begin 4 | require "minitest" 5 | 6 | class SearchCop::TestCase < Minitest::Test; end 7 | rescue LoadError 8 | require "minitest/unit" 9 | 10 | class SearchCop::TestCase < Minitest::Unit::TestCase; end 11 | end 12 | 13 | require "minitest/autorun" 14 | require "active_record" 15 | require "factory_bot" 16 | require "yaml" 17 | require "erb" 18 | 19 | DATABASE = ENV["DATABASE"] || "sqlite" 20 | 21 | ActiveRecord::Base.establish_connection YAML.load(ERB.new(File.read(File.expand_path("database.yml", __dir__))).result)[DATABASE] 22 | 23 | class User < ActiveRecord::Base; end 24 | 25 | class Comment < ActiveRecord::Base 26 | include SearchCop 27 | 28 | belongs_to :user 29 | 30 | search_scope :search do 31 | attributes user: "user.username" 32 | attributes :title, :message 33 | end 34 | end 35 | 36 | class Product < ActiveRecord::Base 37 | include SearchCop 38 | 39 | search_scope :search do 40 | attributes :title, :description, :brand, :notice, :stock, :price, :created_at, :created_on, :available 41 | attributes comment: ["comments.title", "comments.message"], user: ["users.username", "users_products.username"] 42 | attributes primary: [:title, :description] 43 | 44 | aliases users_products: :user 45 | 46 | if DATABASE != "sqlite" 47 | options :title, type: :fulltext, coalesce: true 48 | options :description, type: :fulltext, coalesce: true 49 | options :comment, type: :fulltext, coalesce: true 50 | end 51 | 52 | if DATABASE == "postgres" 53 | if ActiveRecord::VERSION::MAJOR >= 7 54 | attributes :timestamp_with_zone 55 | end 56 | 57 | attributes nested_jsonb_name: "nested_jsonb->nested->name", jsonb_name: "jsonb->name", hstore_name: "hstore->name" 58 | 59 | options :title, dictionary: "english" 60 | end 61 | 62 | attributes nested_json_name: "nested_json->nested->name", json_name: "json->name" 63 | 64 | generator :custom_eq do |column_name, raw_value| 65 | "#{column_name} = #{quote raw_value}" 66 | end 67 | end 68 | 69 | search_scope :user_search do 70 | scope { joins "LEFT OUTER JOIN users users_products ON users_products.id = products.user_id" } 71 | 72 | attributes :title, :description 73 | attributes user: "users_products.username" 74 | 75 | options :title, default: true 76 | aliases users_products: User 77 | end 78 | 79 | search_scope :search_multi_columns do 80 | attributes all: [:title, :description] 81 | end 82 | 83 | has_many :comments 84 | has_many :users, through: :comments 85 | 86 | belongs_to :user 87 | end 88 | 89 | module Blog 90 | class Post < ActiveRecord::Base 91 | include SearchCop 92 | 93 | belongs_to :user 94 | 95 | search_scope :search do 96 | attributes :title, :content 97 | attributes user: ["user.username"] 98 | end 99 | end 100 | end 101 | 102 | class AvailableProduct < Product 103 | default_scope { where(available: true) } 104 | end 105 | 106 | FactoryBot.define do 107 | factory :product do 108 | end 109 | 110 | factory :blog_post, class: Blog::Post do 111 | end 112 | 113 | factory :available_product do 114 | available { true } 115 | end 116 | 117 | factory :comment do 118 | end 119 | 120 | factory :user do 121 | end 122 | end 123 | 124 | ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS products" 125 | ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS posts" 126 | ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS comments" 127 | ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS users" 128 | 129 | ActiveRecord::Base.connection.create_table :products do |t| 130 | t.references :user 131 | t.string :title 132 | t.text :description 133 | t.integer :stock 134 | t.float :price 135 | t.datetime :created_at 136 | t.date :created_on 137 | t.boolean :available 138 | t.string :brand 139 | t.string :notice 140 | 141 | if DATABASE == "postgres" && ActiveRecord::VERSION::MAJOR >= 7 142 | t.timestamptz :timestamp_with_zone 143 | end 144 | 145 | if DATABASE == "postgres" 146 | t.jsonb :jsonb 147 | t.jsonb :nested_jsonb 148 | t.hstore :hstore 149 | end 150 | 151 | t.json :json 152 | t.json :nested_json 153 | end 154 | 155 | ActiveRecord::Base.connection.create_table :posts do |t| 156 | t.references :user 157 | t.string :title 158 | t.text :content 159 | end 160 | 161 | ActiveRecord::Base.connection.create_table :comments do |t| 162 | t.references :product 163 | t.references :user 164 | t.string :title 165 | t.text :message 166 | end 167 | 168 | ActiveRecord::Base.connection.create_table :users do |t| 169 | t.string :username 170 | end 171 | 172 | if DATABASE == "mysql" 173 | ActiveRecord::Base.connection.execute "ALTER TABLE products ENGINE=MyISAM" 174 | ActiveRecord::Base.connection.execute "ALTER TABLE products ADD FULLTEXT INDEX(title), ADD FULLTEXT INDEX(description), ADD FULLTEXT INDEX(title, description)" 175 | 176 | ActiveRecord::Base.connection.execute "ALTER TABLE comments ENGINE=MyISAM" 177 | ActiveRecord::Base.connection.execute "ALTER TABLE comments ADD FULLTEXT INDEX(title, message)" 178 | end 179 | 180 | class SearchCop::TestCase 181 | include FactoryBot::Syntax::Methods 182 | 183 | def teardown 184 | Product.delete_all 185 | Comment.delete_all 186 | end 187 | 188 | def with_options(scope, key, options = {}) 189 | opts = scope.reflection.options[key.to_s] || {} 190 | 191 | scope.reflection.options[key.to_s] = opts.merge(options) 192 | 193 | yield 194 | ensure 195 | scope.reflection.options[key.to_s] = opts 196 | end 197 | 198 | def with_scope(scope, blk) 199 | orig = scope.reflection.scope 200 | 201 | scope.reflection.scope = blk 202 | 203 | yield 204 | ensure 205 | scope.reflection.scope = orig 206 | end 207 | 208 | def assert_not_nil(value) 209 | assert value 210 | end 211 | 212 | def assert_nothing_raised 213 | yield 214 | end 215 | 216 | def quote_table_name(name) 217 | ActiveRecord::Base.connection.quote_table_name name 218 | end 219 | 220 | def quote_column_name(name) 221 | ActiveRecord::Base.connection.quote_column_name name 222 | end 223 | 224 | def quote(object) 225 | ActiveRecord::Base.connection.quote object 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/string_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class StringTest < SearchCop::TestCase 4 | def test_anywhere 5 | product = create(:product, title: "Expected title") 6 | 7 | assert_includes Product.search("Expected"), product 8 | refute_includes Product.search("Rejected"), product 9 | end 10 | 11 | def test_anywhere_quoted 12 | product = create(:product, title: "Expected title") 13 | 14 | assert_includes Product.search("'Expected title'"), product 15 | assert_includes Product.search('"Expected title"'), product 16 | 17 | refute_includes Product.search("'Rejected title'"), product 18 | refute_includes Product.search('"Rejected title"'), product 19 | end 20 | 21 | def test_multiple 22 | product = create(:product, comments: [create(:comment, title: "Expected title", message: "Expected message")]) 23 | 24 | assert_includes Product.search("Expected"), product 25 | refute_includes Product.search("Rejected"), product 26 | end 27 | 28 | def test_includes 29 | product = create(:product, title: "Expected") 30 | 31 | assert_includes Product.search("title: Expected"), product 32 | refute_includes Product.search("title: Rejected"), product 33 | end 34 | 35 | def test_query_string_wildcards 36 | product1 = create(:product, brand: "First brand") 37 | product2 = create(:product, brand: "Second brand") 38 | 39 | assert_equal Product.search("brand: First*"), [product1] 40 | assert_equal Product.search("brand: br*nd"), [] 41 | assert_equal Product.search("brand: brand*"), [] 42 | assert_equal Product.search("brand: *brand*").to_set, [product1, product2].to_set 43 | assert_equal Product.search("brand: *brand").to_set, [product1, product2].to_set 44 | end 45 | 46 | def test_query_string_wildcard_escaping 47 | product1 = create(:product, brand: "som% brand") 48 | product2 = create(:product, brand: "som_ brand") 49 | product3 = create(:product, brand: "som\\ brand") 50 | _product4 = create(:product, brand: "some brand") 51 | 52 | assert_equal Product.search("brand: som% brand"), [product1] 53 | assert_equal Product.search("brand: som_ brand"), [product2] 54 | assert_equal Product.search("brand: som\\ brand"), [product3] 55 | end 56 | 57 | def test_query_string_wildcards_with_left_wildcard_false 58 | product = create(:product, brand: "Some brand") 59 | 60 | with_options(Product.search_scopes[:search], :brand, left_wildcard: false) do 61 | refute_includes Product.search("brand: *brand"), product 62 | assert_includes Product.search("brand: Some"), product 63 | end 64 | end 65 | 66 | def test_query_string_wildcards_with_right_wildcard_false 67 | product = create(:product, brand: "Some brand") 68 | 69 | with_options(Product.search_scopes[:search], :brand, right_wildcard: false) do 70 | refute_includes Product.search("brand: Some*"), product 71 | assert_includes Product.search("brand: brand"), product 72 | end 73 | end 74 | 75 | def test_includes_with_left_wildcard 76 | product = create(:product, brand: "Some brand") 77 | 78 | assert_includes Product.search("brand: brand"), product 79 | end 80 | 81 | def test_includes_with_left_wildcard_false 82 | expected = create(:product, brand: "Brand") 83 | rejected = create(:product, brand: "Rejected brand") 84 | 85 | results = with_options(Product.search_scopes[:search], :brand, left_wildcard: false) { Product.search "brand: Brand" } 86 | 87 | assert_includes results, expected 88 | refute_includes results, rejected 89 | end 90 | 91 | def test_includes_with_right_wildcard_false 92 | expected = create(:product, brand: "Brand") 93 | rejected = create(:product, brand: "Brand rejected") 94 | 95 | results = with_options(Product.search_scopes[:search], :brand, right_wildcard: false) { Product.search "brand: Brand" } 96 | 97 | assert_includes results, expected 98 | refute_includes results, rejected 99 | end 100 | 101 | def test_equals 102 | product = create(:product, title: "Expected title") 103 | 104 | assert_includes Product.search("title = 'Expected title'"), product 105 | refute_includes Product.search("title = Expected"), product 106 | end 107 | 108 | def test_equals_not 109 | product = create(:product, title: "Expected") 110 | 111 | assert_includes Product.search("title != Rejected"), product 112 | refute_includes Product.search("title != Expected"), product 113 | end 114 | 115 | def test_greater 116 | product = create(:product, title: "Title B") 117 | 118 | assert_includes Product.search("title > 'Title A'"), product 119 | refute_includes Product.search("title > 'Title B'"), product 120 | end 121 | 122 | def test_greater_equals 123 | product = create(:product, title: "Title A") 124 | 125 | assert_includes Product.search("title >= 'Title A'"), product 126 | refute_includes Product.search("title >= 'Title B'"), product 127 | end 128 | 129 | def test_less 130 | product = create(:product, title: "Title A") 131 | 132 | assert_includes Product.search("title < 'Title B'"), product 133 | refute_includes Product.search("title < 'Title A'"), product 134 | end 135 | 136 | def test_less_or_greater 137 | product = create(:product, title: "Title B") 138 | 139 | assert_includes Product.search("title <= 'Title B'"), product 140 | refute_includes Product.search("title <= 'Title A'"), product 141 | end 142 | 143 | def test_jsonb 144 | return if DATABASE != "postgres" 145 | 146 | product = create(:product, jsonb: { name: "expected" }) 147 | 148 | assert_includes Product.search("jsonb_name: expected"), product 149 | refute_includes Product.search("jsonb_name: rejected"), product 150 | end 151 | 152 | def test_nested_jsonb 153 | return if DATABASE != "postgres" 154 | 155 | product = create(:product, nested_jsonb: { nested: { name: "expected" } }) 156 | 157 | assert_includes Product.search("nested_jsonb_name: expected"), product 158 | refute_includes Product.search("nested_jsonb_name: rejected"), product 159 | end 160 | 161 | def test_json 162 | product = create(:product, json: { name: "expected" }) 163 | 164 | assert_includes Product.search("json_name: expected"), product 165 | refute_includes Product.search("json_name: rejected"), product 166 | end 167 | 168 | def test_nested_json 169 | product = create(:product, nested_json: { nested: { name: "expected" } }) 170 | 171 | assert_includes Product.search("nested_json_name: expected"), product 172 | refute_includes Product.search("nested_json_name: rejected"), product 173 | end 174 | 175 | def test_hstore 176 | return if DATABASE != "postgres" 177 | 178 | product = create(:product, hstore: { name: "expected" }) 179 | 180 | assert_includes Product.search("hstore_name: expected"), product 181 | refute_includes Product.search("hstore_name: rejected"), product 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /test/visitor_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("test_helper", __dir__) 2 | 3 | class VisitorTest < SearchCop::TestCase 4 | def test_and 5 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).gt(0).and(SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).lt(2)) 6 | 7 | assert_equal "(#{quote_table_name "products"}.#{quote_column_name "stock"} > 0 AND #{quote_table_name "products"}.#{quote_column_name "stock"} < 2)", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 8 | end 9 | 10 | def test_or 11 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).gt(0).or(SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).lt(2)) 12 | 13 | assert_equal "(#{quote_table_name "products"}.#{quote_column_name "stock"} > 0 OR #{quote_table_name "products"}.#{quote_column_name "stock"} < 2)", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 14 | end 15 | 16 | def test_greater_than 17 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).gt(1) 18 | 19 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} > 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 20 | end 21 | 22 | def test_greater_than_or_equal 23 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).gteq(1) 24 | 25 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} >= 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 26 | end 27 | 28 | def test_less_than 29 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).lt(1) 30 | 31 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} < 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 32 | end 33 | 34 | def test_less_than_or_equal 35 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).lteq(1) 36 | 37 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} <= 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 38 | end 39 | 40 | def test_equality 41 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).eq(1) 42 | 43 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} = 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 44 | end 45 | 46 | def test_not_equal 47 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).not_eq(1) 48 | 49 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "stock"} != 1", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 50 | end 51 | 52 | def test_matches 53 | node = SearchCopGrammar::Attributes::String.new(Product, "products", "notice", nil).matches("Notice") 54 | 55 | assert_equal("(#{quote_table_name "products"}.#{quote_column_name "notice"} IS NOT NULL AND #{quote_table_name "products"}.#{quote_column_name "notice"} LIKE #{quote "%Notice%"} ESCAPE #{quote "\\"})", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] != "postgres" 56 | assert_equal("(#{quote_table_name "products"}.#{quote_column_name "notice"} IS NOT NULL AND #{quote_table_name "products"}.#{quote_column_name "notice"} ILIKE #{quote "%Notice%"} ESCAPE #{quote "\\"})", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "postgres" 57 | end 58 | 59 | def test_not 60 | node = SearchCopGrammar::Attributes::Integer.new(Product, "products", "stock", nil).eq(1).not 61 | 62 | assert_equal "NOT (#{quote_table_name "products"}.#{quote_column_name "stock"} = 1)", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 63 | end 64 | 65 | def test_attribute 66 | # Already tested 67 | end 68 | 69 | def test_quote 70 | assert_equal quote("Test"), SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit("Test") 71 | end 72 | 73 | def test_fulltext 74 | node = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").matches("Query").optimize! 75 | 76 | assert_equal("MATCH(`products`.`title`) AGAINST('Query' IN BOOLEAN MODE)", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "mysql" 77 | assert_equal("to_tsvector('english', COALESCE(\"products\".\"title\", '')) @@ to_tsquery('english', '''Query''')", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "postgres" 78 | end 79 | 80 | def test_fulltext_and 81 | query1 = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").matches("Query1") 82 | query2 = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").matches("Query2") 83 | 84 | node = query1.and(query2).optimize! 85 | 86 | assert_equal("(MATCH(`products`.`title`) AGAINST('+Query1 +Query2' IN BOOLEAN MODE))", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "mysql" 87 | 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" 88 | end 89 | 90 | def test_fulltext_or 91 | query1 = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").matches("Query1") 92 | query2 = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").matches("Query2") 93 | 94 | node = query1.or(query2).optimize! 95 | 96 | assert_equal("(MATCH(`products`.`title`) AGAINST('(Query1) (Query2)' IN BOOLEAN MODE))", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node)) if ENV["DATABASE"] == "mysql" 97 | 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" 98 | end 99 | 100 | def test_generator 101 | generator = lambda do |column_name, value| 102 | "#{column_name} = #{quote value}" 103 | end 104 | node = SearchCopGrammar::Attributes::Collection.new(SearchCop::QueryInfo.new(Product, Product.search_scopes[:search]), "title").generator(generator, "value").optimize! 105 | 106 | assert_equal "#{quote_table_name "products"}.#{quote_column_name "title"} = 'value'", SearchCop::Visitors::Visitor.new(ActiveRecord::Base.connection).visit(node) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/search_cop_grammar/attributes.rb: -------------------------------------------------------------------------------- 1 | require "treetop" 2 | 3 | module SearchCopGrammar 4 | module Attributes 5 | class Collection 6 | attr_reader :query_info, :key 7 | 8 | INCLUDED_OPERATORS = [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].freeze 9 | 10 | def initialize(query_info, key) 11 | raise(SearchCop::UnknownColumn, "Unknown column #{key}") unless query_info.scope.reflection.attributes[key] 12 | 13 | @query_info = query_info 14 | @key = key 15 | end 16 | 17 | def eql?(other) 18 | self == other 19 | end 20 | 21 | def ==(other) 22 | other.is_a?(self.class) && [query_info.model, key] == [query_info.model, other.key] 23 | end 24 | 25 | def hash 26 | [query_info.model, key].hash 27 | end 28 | 29 | [:eq, :not_eq, :lt, :lteq, :gt, :gteq].each do |method| 30 | define_method method do |value| 31 | attributes.collect! { |attribute| attribute.send method, value }.inject(:or) 32 | end 33 | end 34 | 35 | def generator(generator, value) 36 | attributes.collect! do |attribute| 37 | SearchCopGrammar::Nodes::Generator.new(attribute, generator: generator, value: value) 38 | end.inject(:or) 39 | end 40 | 41 | def matches(value) 42 | if fulltext? 43 | SearchCopGrammar::Nodes::MatchesFulltext.new self, value.to_s 44 | else 45 | attributes.collect! { |attribute| attribute.matches value }.inject(:or) 46 | end 47 | end 48 | 49 | def fulltext? 50 | (query_info.scope.reflection.options[key] || {})[:type] == :fulltext 51 | end 52 | 53 | def compatible?(value) 54 | attributes.all? { |attribute| attribute.compatible? value } 55 | end 56 | 57 | def options 58 | query_info.scope.reflection.options[key] 59 | end 60 | 61 | def attributes 62 | @attributes ||= query_info.scope.reflection.attributes[key].collect { |attribute_definition| attribute_for attribute_definition } 63 | end 64 | 65 | def klass_for_association(name) 66 | reflections = query_info.model.reflections 67 | 68 | return reflections[name].klass if reflections[name] 69 | return reflections[name.to_sym].klass if reflections[name.to_sym] 70 | 71 | nil 72 | end 73 | 74 | def klass_for(name) 75 | alias_value = query_info.scope.reflection.aliases[name] 76 | 77 | return alias_value if alias_value.is_a?(Class) 78 | 79 | value = alias_value || name 80 | 81 | klass_for_association(value) || value.classify.constantize 82 | end 83 | 84 | def alias_for(name) 85 | (query_info.scope.reflection.aliases[name] && name) || klass_for(name).table_name 86 | end 87 | 88 | def attribute_for(attribute_definition) 89 | query_info.references.push attribute_definition 90 | 91 | table, column_with_fields = attribute_definition.split(".") 92 | column, *fields = column_with_fields.split("->") 93 | klass = klass_for(table) 94 | 95 | raise(SearchCop::UnknownAttribute, "Unknown attribute #{attribute_definition}") unless klass.columns_hash[column] 96 | 97 | Attributes.const_get(klass.columns_hash[column].type.to_s.classify).new(klass, alias_for(table), column, fields, options) 98 | end 99 | 100 | def generator_for(name) 101 | generators[name] 102 | end 103 | 104 | def valid_operator?(operator) 105 | (INCLUDED_OPERATORS + generators.keys).include?(operator) 106 | end 107 | 108 | def generators 109 | query_info.scope.reflection.generators 110 | end 111 | end 112 | 113 | class Base 114 | attr_reader :attribute, :table_alias, :column_name, :field_names, :options 115 | 116 | def initialize(klass, table_alias, column_name, field_names, options = {}) 117 | @attribute = klass.arel_table.alias(table_alias)[column_name] 118 | @klass = klass 119 | @table_alias = table_alias 120 | @column_name = column_name 121 | @field_names = field_names 122 | @options = (options || {}) 123 | end 124 | 125 | def map(value) 126 | value 127 | end 128 | 129 | def compatible?(value) 130 | map value 131 | 132 | true 133 | rescue SearchCop::IncompatibleDatatype 134 | false 135 | end 136 | 137 | def fulltext? 138 | false 139 | end 140 | 141 | { eq: "Equality", not_eq: "NotEqual", lt: "LessThan", lteq: "LessThanOrEqual", gt: "GreaterThan", gteq: "GreaterThanOrEqual", matches: "Matches" }.each do |method, class_name| 142 | define_method method do |value| 143 | raise(SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}") unless compatible?(value) 144 | 145 | SearchCopGrammar::Nodes.const_get(class_name).new(self, map(value)) 146 | end 147 | end 148 | 149 | def method_missing(name, *args, &block) 150 | if @attribute.respond_to?(name) 151 | @attribute.send(name, *args, &block) 152 | else 153 | super 154 | end 155 | end 156 | 157 | def respond_to_missing?(*args) 158 | @attribute.respond_to?(*args) || super 159 | end 160 | end 161 | 162 | class String < Base 163 | def matches_value(value) 164 | res = value.gsub(/[%_\\]/) { |char| "\\#{char}" } 165 | 166 | if value.strip =~ /^\*|\*$/ 167 | res = res.gsub(/^\*/, "%") if options[:left_wildcard] != false 168 | res = res.gsub(/\*$/, "%") if options[:right_wildcard] != false 169 | 170 | return res 171 | end 172 | 173 | res = "%#{res}" if options[:left_wildcard] != false 174 | res = "#{res}%" if options[:right_wildcard] != false 175 | res 176 | end 177 | 178 | def matches(value) 179 | super matches_value(value) 180 | end 181 | end 182 | 183 | class Text < String; end 184 | class Jsonb < String; end 185 | class Json < String; end 186 | class Hstore < String; end 187 | 188 | class WithoutMatches < Base 189 | def matches(value) 190 | eq value 191 | end 192 | end 193 | 194 | class Float < WithoutMatches 195 | def compatible?(value) 196 | return true if value.to_s =~ /^-?[0-9]+(\.[0-9]+)?$/ 197 | 198 | false 199 | end 200 | 201 | def map(value) 202 | value.to_f 203 | end 204 | end 205 | 206 | class Integer < Float 207 | def map(value) 208 | value.to_i 209 | end 210 | end 211 | 212 | class Decimal < Float; end 213 | 214 | class Datetime < WithoutMatches 215 | def parse(value) 216 | return value..value unless value.is_a?(::String) 217 | 218 | if value =~ /^[0-9]+ (hour|day|week|month|year)s{0,1} (ago)$/ 219 | number, period, ago = value.split(" ") 220 | time = number.to_i.send(period.to_sym).send(ago.to_sym) 221 | time..::Time.now 222 | elsif value =~ /^[0-9]{4}$/ 223 | ::Time.new(value).beginning_of_year..::Time.new(value).end_of_year 224 | elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$} 225 | ::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).beginning_of_month..::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).end_of_month 226 | elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$} 227 | ::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).beginning_of_month..::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).end_of_month 228 | elsif value =~ %r{^[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}$} || value =~ %r{^[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}$} 229 | time = ::Time.parse(value) 230 | time.beginning_of_day..time.end_of_day 231 | elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}} 232 | time = ::Time.parse(value) 233 | time..time 234 | else 235 | raise ArgumentError 236 | end 237 | rescue ArgumentError 238 | raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}" 239 | end 240 | 241 | def map(value) 242 | parse(value).first 243 | end 244 | 245 | def eq(value) 246 | between parse(value) 247 | end 248 | 249 | def not_eq(value) 250 | between(parse(value)).not 251 | end 252 | 253 | def gt(value) 254 | super parse(value).last 255 | end 256 | 257 | def between(range) 258 | gteq(range.first).and(lteq(range.last)) 259 | end 260 | end 261 | 262 | class Timestamp < Datetime; end 263 | class Timestamptz < Datetime; end 264 | 265 | class Date < Datetime 266 | def parse(value) 267 | return value..value unless value.is_a?(::String) 268 | 269 | if value =~ /^[0-9]+ (day|week|month|year)s{0,1} (ago)$/ 270 | number, period, ago = value.split(" ") 271 | time = number.to_i.send(period.to_sym).send(ago.to_sym) 272 | time.to_date..::Date.today 273 | elsif value =~ /^[0-9]{4}$/ 274 | ::Date.new(value.to_i).beginning_of_year..::Date.new(value.to_i).end_of_year 275 | elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$} 276 | ::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).end_of_month 277 | elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$} 278 | ::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).end_of_month 279 | elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}} 280 | date = ::Date.parse(value) 281 | date..date 282 | else 283 | raise ArgumentError 284 | end 285 | rescue ArgumentError 286 | raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}" 287 | end 288 | end 289 | 290 | class Time < Datetime; end 291 | 292 | class Boolean < WithoutMatches 293 | def map(value) 294 | return true if value.to_s =~ /^(1|true|yes)$/i 295 | return false if value.to_s =~ /^(0|false|no)$/i 296 | 297 | raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}" 298 | end 299 | end 300 | end 301 | end 302 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SearchCop 2 | 3 | [![Build Status](https://github.com/mrkamel/search_cop/workflows/test/badge.svg?branch=master)](https://github.com/mrkamel/search_cop/actions?query=workflow%3Atest) 4 | [![Code Climate](https://codeclimate.com/github/mrkamel/search_cop.png)](https://codeclimate.com/github/mrkamel/search_cop) 5 | [![Gem Version](https://badge.fury.io/rb/search_cop.svg)](http://badge.fury.io/rb/search_cop) 6 | 7 | ![search_cop](https://raw.githubusercontent.com/mrkamel/search_cop_logo/master/search_cop.png) 8 | 9 | SearchCop extends your ActiveRecord models to support fulltext search 10 | engine like queries via simple query strings and hash-based queries. Assume you 11 | have a `Book` model having various attributes like `title`, `author`, `stock`, 12 | `price`, `available`. Using SearchCop you can perform: 13 | 14 | ```ruby 15 | Book.search("Joanne Rowling Harry Potter") 16 | Book.search("author: Rowling title:'Harry Potter'") 17 | Book.search("price > 10 AND price < 20 -stock:0 (Potter OR Rowling)") 18 | # ... 19 | ``` 20 | 21 | Thus, you can hand out a search query string to your models and you, your app's 22 | admins and/or users will get powerful query features without the need for 23 | integrating additional third party search servers, since SearchCop can use 24 | fulltext index capabilities of your RDBMS in a database agnostic way (currently 25 | MySQL and PostgreSQL fulltext indices are supported) and optimizes the queries 26 | to make optimal use of them. Read more below. 27 | 28 | Complex hash-based queries are supported as well: 29 | 30 | ```ruby 31 | Book.search(author: "Rowling", title: "Harry Potter") 32 | Book.search(or: [{author: "Rowling"}, {author: "Tolkien"}]) 33 | Book.search(and: [{price: {gt: 10}}, {not: {stock: 0}}, or: [{title: "Potter"}, {author: "Rowling"}]]) 34 | Book.search(or: [{query: "Rowling -Potter"}, {query: "Tolkien -Rings"}]) 35 | Book.search(title: {my_custom_sql_query: "Rowl"}}) 36 | # ... 37 | ``` 38 | 39 | ## Installation 40 | 41 | Add this line to your application's Gemfile: 42 | 43 | gem 'search_cop' 44 | 45 | And then execute: 46 | 47 | $ bundle 48 | 49 | Or install it yourself as: 50 | 51 | $ gem install search_cop 52 | 53 | ## Usage 54 | 55 | To enable SearchCop for a model, `include SearchCop` and specify the 56 | attributes you want to expose to search queries within a `search_scope`: 57 | 58 | ```ruby 59 | class Book < ActiveRecord::Base 60 | include SearchCop 61 | 62 | search_scope :search do 63 | attributes :title, :description, :stock, :price, :created_at, :available 64 | attributes comment: ["comments.title", "comments.message"] 65 | attributes author: "author.name" 66 | # ... 67 | end 68 | 69 | has_many :comments 70 | belongs_to :author 71 | end 72 | ``` 73 | 74 | You can of course as well specify multiple `search_scope` blocks as you like: 75 | 76 | ```ruby 77 | search_scope :admin_search do 78 | attributes :title, :description, :stock, :price, :created_at, :available 79 | 80 | # ... 81 | end 82 | 83 | search_scope :user_search do 84 | attributes :title, :description 85 | 86 | # ... 87 | end 88 | ``` 89 | 90 | ## How does it work 91 | 92 | SearchCop parses the query and maps it to an SQL Query in a database agnostic way. 93 | Thus, SearchCop is not bound to a specific RDBMS. 94 | 95 | ```ruby 96 | Book.search("stock > 0") 97 | # ... WHERE books.stock > 0 98 | 99 | Book.search("price > 10 stock > 0") 100 | # ... WHERE books.price > 10 AND books.stock > 0 101 | 102 | Book.search("Harry Potter") 103 | # ... WHERE (books.title LIKE '%Harry%' OR books.description LIKE '%Harry%' OR ...) AND (books.title LIKE '%Potter%' OR books.description LIKE '%Potter%' ...) 104 | 105 | Book.search("available:yes OR created_at:2014") 106 | # ... WHERE books.available = 1 OR (books.created_at >= '2014-01-01 00:00:00.00000' and books.created_at <= '2014-12-31 23:59:59.99999') 107 | ``` 108 | SearchCop is using ActiveSupport's beginning_of_year and end_of_year methods for the values used in building the SQL query for this case. 109 | 110 | Of course, these `LIKE '%...%'` queries won't achieve optimal performance, but 111 | check out the section below on SearchCop's fulltext capabilities to 112 | understand how the resulting queries can be optimized. 113 | 114 | As `Book.search(...)` returns an `ActiveRecord::Relation`, you are free to pre- 115 | or post-process the search results in every possible way: 116 | 117 | ```ruby 118 | Book.where(available: true).search("Harry Potter").order("books.id desc").paginate(page: params[:page]) 119 | ``` 120 | 121 | ## Security 122 | 123 | When you pass a query string to SearchCop, it gets parsed, analyzed and mapped 124 | to finally build up an SQL query. To be more precise, when SearchCop parses the 125 | query, it creates objects (nodes), which represent the query expressions (And-, 126 | Or-, Not-, String-, Date-, etc Nodes). To build the SQL query, SearchCop uses 127 | the concept of visitors like e.g. used in 128 | [Arel](https://github.com/rails/arel), such that, for every node there must be 129 | a [visitor](https://github.com/mrkamel/search_cop/blob/master/lib/search_cop/visitors/visitor.rb), 130 | which transforms the node to SQL. When there is no visitor, an exception is 131 | raised when the query builder tries to "visit" the node. The visitors are 132 | responsible for sanitizing the user supplied input. This is primilarly done via 133 | quoting (string-, table-name-, column-quoting, etc). SearchCop is using the 134 | methods provided by the ActiveRecord connection adapter for sanitizing/quoting 135 | to prevent SQL injection. While we can never be 100% safe from security issues, 136 | SearchCop takes security issues seriously. Please report responsibly via 137 | security at flakks dot com in case you find any security related issues. 138 | 139 | ## json/jsonb/hstore 140 | 141 | SearchCop supports json fields for MySQL, as well as json, jsonb and hstore 142 | fields for postgres. Currently, field values are always expected to be strings 143 | and no arrays are supported. You can specify json attributes via: 144 | 145 | ```ruby 146 | search_scope :search do 147 | attributes user_agent: "context->browser->user_agent" 148 | 149 | # ... 150 | end 151 | ``` 152 | 153 | where `context` is a json/jsonb column which e.g. contains: 154 | 155 | ```json 156 | { 157 | "browser": { 158 | "user_agent": "Firefox ..." 159 | } 160 | } 161 | ``` 162 | 163 | ## Fulltext index capabilities 164 | 165 | By default, i.e. if you don't tell SearchCop about your fulltext indices, 166 | SearchCop will use `LIKE '%...%'` queries. Unfortunately, unless you 167 | create a [trigram index](http://www.postgresql.org/docs/9.1/static/pgtrgm.html) 168 | (postgres only), these queries can not use SQL indices, such that every row 169 | needs to be scanned by your RDBMS when you search for `Book.search("Harry 170 | Potter")` or similar. To avoid the penalty of `LIKE` queries, SearchCop 171 | can exploit the fulltext index capabilities of MySQL and PostgreSQL. To use 172 | already existing fulltext indices, simply tell SearchCop to use them via: 173 | 174 | ```ruby 175 | class Book < ActiveRecord::Base 176 | # ... 177 | 178 | search_scope :search do 179 | attributes :title, :author 180 | 181 | options :title, :type => :fulltext 182 | options :author, :type => :fulltext 183 | end 184 | 185 | # ... 186 | end 187 | ``` 188 | 189 | SearchCop will then transparently change its SQL queries for the 190 | attributes having fulltext indices to: 191 | 192 | ```ruby 193 | Book.search("Harry Potter") 194 | # MySQL: ... WHERE (MATCH(books.title) AGAINST('+Harry' IN BOOLEAN MODE) OR MATCH(books.author) AGAINST('+Harry' IN BOOLEAN MODE)) AND (MATCH(books.title) AGAINST ('+Potter' IN BOOLEAN MODE) OR MATCH(books.author) AGAINST('+Potter' IN BOOLEAN MODE)) 195 | # PostgreSQL: ... WHERE (to_tsvector('simple', books.title) @@ to_tsquery('simple', 'Harry') OR to_tsvector('simple', books.author) @@ to_tsquery('simple', 'Harry')) AND (to_tsvector('simple', books.title) @@ to_tsquery('simple', 'Potter') OR to_tsvector('simple', books.author) @@ to_tsquery('simple', 'Potter')) 196 | ``` 197 | 198 | Obviously, these queries won't always return the same results as wildcard 199 | `LIKE` queries, because we search for words instead of sub-strings. However, 200 | fulltext indices will usually of course provide better performance. 201 | 202 | Moreover, the query above is not yet perfect. To improve it even more, 203 | SearchCop tries to optimize the queries to make optimal use of fulltext indices 204 | while still allowing to mix them with non-fulltext attributes. To improve 205 | queries even more, you can group attributes and specify a default field to 206 | search in, such that SearchCop must no longer search within all fields: 207 | 208 | ```ruby 209 | search_scope :search do 210 | attributes all: [:author, :title] 211 | 212 | options :all, :type => :fulltext, default: true 213 | 214 | # Use default: true to explicitly enable fields as default fields (whitelist approach) 215 | # Use default: false to explicitly disable fields as default fields (blacklist approach) 216 | end 217 | ``` 218 | 219 | Now SearchCop can optimize the following, not yet optimal query: 220 | 221 | ```ruby 222 | Book.search("Rowling OR Tolkien stock > 1") 223 | # MySQL: ... WHERE ((MATCH(books.author) AGAINST('+Rowling' IN BOOLEAN MODE) OR MATCH(books.title) AGAINST('+Rowling' IN BOOLEAN MODE)) OR (MATCH(books.author) AGAINST('+Tolkien' IN BOOLEAN MODE) OR MATCH(books.title) AGAINST('+Tolkien' IN BOOLEAN MODE))) AND books.stock > 1 224 | # PostgreSQL: ... WHERE ((to_tsvector('simple', books.author) @@ to_tsquery('simple', 'Rowling') OR to_tsvector('simple', books.title) @@ to_tsquery('simple', 'Rowling')) OR (to_tsvector('simple', books.author) @@ to_tsquery('simple', 'Tolkien') OR to_tsvector('simple', books.title) @@ to_tsquery('simple', 'Tolkien'))) AND books.stock > 1 225 | ``` 226 | 227 | to the following, more performant query: 228 | 229 | 230 | ```ruby 231 | Book.search("Rowling OR Tolkien stock > 1") 232 | # MySQL: ... WHERE MATCH(books.author, books.title) AGAINST('Rowling Tolkien' IN BOOLEAN MODE) AND books.stock > 1 233 | # PostgreSQL: ... WHERE to_tsvector('simple', books.author || ' ' || books.title) @@ to_tsquery('simple', 'Rowling | Tokien') and books.stock > 1 234 | ``` 235 | 236 | What is happening here? Well, we specified `all` as the name of an attribute 237 | group that consists of `author` and `title`. As we, in addition, specified 238 | `all` to be a fulltext attribute, SearchCop assumes there is a compound 239 | fulltext index present on `author` and `title`, such that the query is 240 | optimized accordingly. Finally, we specified `all` to be the default attribute 241 | to search in, such that SearchCop can ignore other attributes, like e.g. 242 | `stock`, as long as they are not specified within queries directly (like for 243 | `stock > 0`). 244 | 245 | Other queries will be optimized in a similar way, such that SearchCop 246 | tries to minimize the fultext constraints within a query, namely `MATCH() 247 | AGAINST()` for MySQL and `to_tsvector() @@ to_tsquery()` for PostgreSQL. 248 | 249 | ```ruby 250 | Book.search("(Rowling -Potter) OR Tolkien") 251 | # MySQL: ... WHERE MATCH(books.author, books.title) AGAINST('(+Rowling -Potter) Tolkien' IN BOOLEAN MODE) 252 | # PostgreSQL: ... WHERE to_tsvector('simple', books.author || ' ' || books.title) @@ to_tsquery('simple', '(Rowling & !Potter) | Tolkien') 253 | ``` 254 | 255 | To create a fulltext index on `books.title` in MySQL, simply use: 256 | 257 | ```ruby 258 | add_index :books, :title, :type => :fulltext 259 | ``` 260 | 261 | Regarding compound indices, which will e.g. be used for the default field `all` 262 | we already specified above, use: 263 | 264 | ```ruby 265 | add_index :books, [:author, :title], :type => :fulltext 266 | ``` 267 | 268 | Please note that MySQL supports fulltext indices for MyISAM and, as of MySQL 269 | version 5.6+, for InnoDB as well. For more details about MySQL fulltext indices 270 | visit 271 | [http://dev.mysql.com/doc/refman/5.6/en/fulltext-search.html](http://dev.mysql.com/doc/refman/5.6/en/fulltext-search.html) 272 | 273 | Regarding PostgreSQL there are more ways to create a fulltext index. However, 274 | one of the easiest ways is: 275 | 276 | ```ruby 277 | ActiveRecord::Base.connection.execute "CREATE INDEX fulltext_index_books_on_title ON books USING GIN(to_tsvector('simple', title))" 278 | ``` 279 | 280 | Moreover, for PostgreSQL you should change the schema format in 281 | `config/application.rb`: 282 | 283 | ```ruby 284 | config.active_record.schema_format = :sql 285 | ``` 286 | 287 | Regarding compound indices for PostgreSQL, use: 288 | 289 | ```ruby 290 | ActiveRecord::Base.connection.execute "CREATE INDEX fulltext_index_books_on_title ON books USING GIN(to_tsvector('simple', author || ' ' || title))" 291 | ``` 292 | 293 | To handle NULL values with PostgreSQL correctly, use COALESCE both at index 294 | creation time and when specifying the `search_scope`: 295 | 296 | ```ruby 297 | ActiveRecord::Base.connection.execute "CREATE INDEX fulltext_index_books_on_title ON books USING GIN(to_tsvector('simple', COALESCE(author, '') || ' ' || COALESCE(title, '')))" 298 | ``` 299 | 300 | plus: 301 | 302 | ```ruby 303 | search_scope :search do 304 | attributes :title 305 | 306 | options :title, :type => :fulltext, coalesce: true 307 | end 308 | ``` 309 | 310 | To use another PostgreSQL dictionary than `simple`, you have to create the 311 | index accordingly and you need tell SearchCop about it, e.g.: 312 | 313 | ```ruby 314 | search_scope :search do 315 | attributes :title 316 | 317 | options :title, :type => :fulltext, dictionary: "english" 318 | end 319 | ``` 320 | 321 | For more details about PostgreSQL fulltext indices visit 322 | [http://www.postgresql.org/docs/9.3/static/textsearch.html](http://www.postgresql.org/docs/9.3/static/textsearch.html) 323 | 324 | ## Other indices 325 | 326 | In case you expose non-fulltext attributes to search queries (price, stock, 327 | etc.), the respective queries, like `Book.search("stock > 0")`, will profit 328 | from the usual non-fulltext indices. Thus, you should add a usual index on 329 | every column you expose to search queries plus a fulltext index for every 330 | fulltext attribute. 331 | 332 | In case you can't use fulltext indices, because you're e.g. still on MySQL 5.5 333 | while using InnoDB or another RDBMS without fulltext support, you can make your 334 | RDBMS use usual non-fulltext indices for string columns if you don't need the 335 | left wildcard within `LIKE` queries. Simply supply the following option: 336 | 337 | ```ruby 338 | class User < ActiveRecord::Base 339 | include SearchCop 340 | 341 | search_scope :search do 342 | attributes :username 343 | 344 | options :username, left_wildcard: false 345 | end 346 | 347 | # ... 348 | ``` 349 | 350 | such that SearchCop will omit the left most wildcard. 351 | 352 | ```ruby 353 | User.search("admin") 354 | # ... WHERE users.username LIKE 'admin%' 355 | ``` 356 | 357 | Similarly, you can disable the right wildcard as well: 358 | 359 | ```ruby 360 | search_scope :search do 361 | attributes :username 362 | 363 | options :username, right_wildcard: false 364 | end 365 | ``` 366 | 367 | ## Default operator 368 | 369 | When you define multiple fields on a search scope, SearcCop will use 370 | by default the AND operator to concatenate the conditions, e.g: 371 | 372 | ```ruby 373 | class User < ActiveRecord::Base 374 | include SearchCop 375 | 376 | search_scope :search do 377 | attributes :username, :fullname 378 | end 379 | 380 | # ... 381 | end 382 | ``` 383 | 384 | So a search like `User.search("something")` will generate a query 385 | with the following conditions: 386 | 387 | ```sql 388 | ... WHERE username LIKE '%something%' AND fullname LIKE '%something%' 389 | ``` 390 | 391 | However, there are cases where using AND as the default operator is not desired, 392 | so SearchCop allows you to override it and use OR as the default operator instead. 393 | A query like `User.search("something", default_operator: :or)` will 394 | generate the query using OR to concatenate the conditions 395 | 396 | ```sql 397 | ... WHERE username LIKE '%something%' OR fullname LIKE '%something%' 398 | ``` 399 | 400 | Finally, please note that you can apply it to fulltext indices/queries as well. 401 | 402 | ## Associations 403 | 404 | If you specify searchable attributes from another model, like 405 | 406 | ```ruby 407 | class Book < ActiveRecord::Base 408 | # ... 409 | 410 | belongs_to :author 411 | 412 | search_scope :search do 413 | attributes author: "author.name" 414 | end 415 | 416 | # ... 417 | end 418 | ``` 419 | 420 | SearchCop will by default `eager_load` the referenced associations, when 421 | you perform `Book.search(...)`. If you don't want the automatic `eager_load` 422 | or need to perform special operations, specify a `scope`: 423 | 424 | ```ruby 425 | class Book < ActiveRecord::Base 426 | # ... 427 | 428 | search_scope :search do 429 | # ... 430 | 431 | scope { joins(:author).eager_load(:comments) } # etc. 432 | end 433 | 434 | # ... 435 | end 436 | ``` 437 | 438 | SearchCop will then skip any association auto loading and will use the scope 439 | instead. You can as well use `scope` together with `aliases` to perform 440 | arbitrarily complex joins and search in the joined models/tables: 441 | 442 | ```ruby 443 | class Book < ActiveRecord::Base 444 | # ... 445 | 446 | search_scope :search do 447 | attributes similar: ["similar_books.title", "similar_books.description"] 448 | 449 | scope do 450 | joins "left outer join books similar_books on ..." 451 | end 452 | 453 | aliases similar_books: Book # Tell SearchCop how to map SQL aliases to models 454 | end 455 | 456 | # ... 457 | end 458 | ``` 459 | 460 | Assocations of associations can as well be referenced and used: 461 | 462 | ```ruby 463 | class Book < ActiveRecord::Base 464 | # ... 465 | 466 | has_many :comments 467 | has_many :users, :through => :comments 468 | 469 | search_scope :search do 470 | attributes user: "users.username" 471 | end 472 | 473 | # ... 474 | end 475 | ``` 476 | 477 | ## Custom table names and associations 478 | 479 | SearchCop tries to infer a model's class name and SQL alias from the 480 | specified attributes to autodetect datatype definitions, etc. This usually 481 | works quite fine. In case you're using custom table names via `self.table_name 482 | = ...` or if a model is associated multiple times, SearchCop however can't 483 | infer the class and SQL alias names, e.g. 484 | 485 | ```ruby 486 | class Book < ActiveRecord::Base 487 | # ... 488 | 489 | has_many :users, :through => :comments 490 | belongs_to :user 491 | 492 | search_scope :search do 493 | attributes user: ["user.username", "users_books.username"] 494 | end 495 | 496 | # ... 497 | end 498 | ``` 499 | 500 | Here, for queries to work you have to use `users_books.username`, because 501 | ActiveRecord assigns a different SQL alias for users within its SQL queries, 502 | because the user model is associated multiple times. However, as SearchCop 503 | now can't infer the `User` model from `users_books`, you have to add: 504 | 505 | ```ruby 506 | class Book < ActiveRecord::Base 507 | # ... 508 | 509 | search_scope :search do 510 | # ... 511 | 512 | aliases :users_books => :users 513 | end 514 | 515 | # ... 516 | end 517 | ``` 518 | 519 | to tell SearchCop about the custom SQL alias and mapping. In addition, you can 520 | always do the joins yourself via a `scope {}` block plus `aliases` and use your 521 | own custom sql aliases to become independent of names auto-assigned by 522 | ActiveRecord. 523 | 524 | ## Supported operators 525 | 526 | Query string queries support `AND/and`, `OR/or`, `:`, `=`, `!=`, `<`, `<=`, 527 | `>`, `>=`, `NOT/not/-`, `()`, `"..."` and `'...'`. Default operators are `AND` 528 | and `matches`, `OR` has precedence over `AND`. `NOT` can only be used as infix 529 | operator regarding a single attribute. 530 | 531 | Hash based queries support `and: [...]` and `or: [...]`, which take an array 532 | of `not: {...}`, `matches: {...}`, `eq: {...}`, `not_eq: {...}`, 533 | `lt: {...}`, `lteq: {...}`, `gt: {...}`, `gteq: {...}` and `query: "..."` 534 | arguments. Moreover, `query: "..."` makes it possible to create sub-queries. 535 | The other rules for query string queries apply to hash based queries as well. 536 | 537 | ### Custom operators (Hash based queries) 538 | 539 | SearchCop also provides the ability to define custom operators by defining a 540 | `generator` in `search_scope`. They can then be used with the hash based query 541 | search. This is useful when you want to use database operators that are not 542 | supported by SearchCop. 543 | 544 | Please note, when using generators, you are responsible for sanitizing/quoting 545 | the values (see example below). Otherwise your generator will allow SQL 546 | injection. Thus, please only use generators if you know what you're doing. 547 | 548 | For example, if you wanted to perform a `LIKE` query where a book title starts 549 | with a string, you can define the search scope like so: 550 | 551 | ```ruby 552 | search_scope :search do 553 | attributes :title 554 | 555 | generator :starts_with do |column_name, raw_value| 556 | pattern = "#{raw_value}%" 557 | "#{column_name} LIKE #{quote pattern}" 558 | end 559 | end 560 | ``` 561 | 562 | When you want to perform the search you use it like this: 563 | 564 | ```ruby 565 | Book.search(title: { starts_with: "The Great" }) 566 | ``` 567 | 568 | Security Note: The query returned from the generator will be interpolated 569 | directly into the query that goes to your database. This opens up a potential 570 | SQL Injection point in your app. If you use this feature you'll want to make 571 | sure the query you're returning is safe to execute. 572 | 573 | ## Mapping 574 | 575 | When searching in boolean, datetime, timestamp, etc. fields, SearchCop 576 | performs some mapping. The following queries are equivalent: 577 | 578 | ```ruby 579 | Book.search("available:true") 580 | Book.search("available:1") 581 | Book.search("available:yes") 582 | ``` 583 | 584 | as well as 585 | 586 | ```ruby 587 | Book.search("available:false") 588 | Book.search("available:0") 589 | Book.search("available:no") 590 | ``` 591 | 592 | For datetime and timestamp fields, SearchCop expands certain values to 593 | ranges: 594 | 595 | ```ruby 596 | Book.search("created_at:2014") 597 | # ... WHERE created_at >= '2014-01-01 00:00:00' AND created_at <= '2014-12-31 23:59:59' 598 | 599 | Book.search("created_at:2014-06") 600 | # ... WHERE created_at >= '2014-06-01 00:00:00' AND created_at <= '2014-06-30 23:59:59' 601 | 602 | Book.search("created_at:2014-06-15") 603 | # ... WHERE created_at >= '2014-06-15 00:00:00' AND created_at <= '2014-06-15 23:59:59' 604 | ``` 605 | 606 | ## Chaining 607 | 608 | Chaining of searches is possible. However, chaining does currently not allow 609 | SearchCop to optimize the individual queries for fulltext indices. 610 | 611 | ```ruby 612 | Book.search("Harry").search("Potter") 613 | ``` 614 | 615 | will generate 616 | 617 | ```ruby 618 | # MySQL: ... WHERE MATCH(...) AGAINST('+Harry' IN BOOLEAN MODE) AND MATCH(...) AGAINST('+Potter' IN BOOLEAN MODE) 619 | # PostgreSQL: ... WHERE to_tsvector(...) @@ to_tsquery('simple', 'Harry') AND to_tsvector(...) @@ to_tsquery('simple', 'Potter') 620 | ``` 621 | 622 | instead of 623 | 624 | ```ruby 625 | # MySQL: ... WHERE MATCH(...) AGAINST('+Harry +Potter' IN BOOLEAN MODE) 626 | # PostgreSQL: ... WHERE to_tsvector(...) @@ to_tsquery('simple', 'Harry & Potter') 627 | ``` 628 | 629 | Thus, if you use fulltext indices, you better avoid chaining. 630 | 631 | ## Debugging 632 | 633 | When using `Model#search`, SearchCop conveniently prevents certain 634 | exceptions from being raised in case the query string passed to it is invalid 635 | (parse errors, incompatible datatype errors, etc). Instead, `Model#search` 636 | returns an empty relation. However, if you need to debug certain cases, use 637 | `Model#unsafe_search`, which will raise them. 638 | 639 | ```ruby 640 | Book.unsafe_search("stock: None") # => raise SearchCop::IncompatibleDatatype 641 | ``` 642 | 643 | ## Reflection 644 | 645 | SearchCop provides reflective methods, namely `#attributes`, 646 | `#default_attributes`, `#options` and `#aliases`. You can use these methods to 647 | e.g. provide an individual search help widget for your models, that lists the 648 | attributes to search in as well as the default ones, etc. 649 | 650 | ```ruby 651 | class Product < ActiveRecord::Base 652 | include SearchCop 653 | 654 | search_scope :search do 655 | attributes :title, :description 656 | 657 | options :title, default: true 658 | end 659 | end 660 | 661 | Product.search_reflection(:search).attributes 662 | # {"title" => ["products.title"], "description" => ["products.description"]} 663 | 664 | Product.search_reflection(:search).default_attributes 665 | # {"title" => ["products.title"]} 666 | 667 | # ... 668 | ``` 669 | 670 | ## Semantic Versioning 671 | 672 | Starting with version 1.0.0, SearchCop uses Semantic Versioning: 673 | [SemVer](http://semver.org/) 674 | 675 | ## Contributing 676 | 677 | 1. Fork it 678 | 2. Create your feature branch (`git checkout -b my-new-feature`) 679 | 3. Commit your changes (`git commit -am 'Add some feature'`) 680 | 4. Push to the branch (`git push origin my-new-feature`) 681 | 5. Create new Pull Request 682 | 683 | --------------------------------------------------------------------------------