├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.6.1.mysql2 ├── Gemfile.6.1.mysql2.lock ├── Gemfile.6.1.pg ├── Gemfile.6.1.pg.lock ├── Gemfile.7.0.mysql2 ├── Gemfile.7.0.mysql2.lock ├── Gemfile.7.0.pg ├── Gemfile.7.0.pg.lock ├── Gemfile.7.1.mysql2 ├── Gemfile.7.1.mysql2.lock ├── Gemfile.7.1.pg ├── Gemfile.7.1.pg.lock ├── Gemfile.7.2.mysql2 ├── Gemfile.7.2.mysql2.lock ├── Gemfile.7.2.pg ├── Gemfile.7.2.pg.lock ├── Gemfile.8.0.mysql2 ├── Gemfile.8.0.mysql2.lock ├── Gemfile.8.0.pg ├── Gemfile.8.0.pg.lock ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── doc └── filtered_index_view.cropped.png ├── lib ├── minidusen.rb └── minidusen │ ├── active_record_ext.rb │ ├── filter.rb │ ├── parser.rb │ ├── query.rb │ ├── syntax.rb │ ├── token.rb │ ├── util.rb │ └── version.rb ├── media ├── logo.dark.shapes.svg ├── logo.dark.text.svg ├── logo.light.shapes.svg ├── logo.light.text.svg ├── makandra-with-bottom-margin.dark.svg └── makandra-with-bottom-margin.light.svg ├── minidusen.gemspec └── spec ├── minidusen ├── active_record_ext_spec.rb ├── filter_spec.rb ├── parser_spec.rb ├── query_spec.rb └── util_spec.rb ├── spec_helper.rb └── support ├── database.github.yml ├── database.rb ├── database.sample.yml └── models.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test_mysql: 14 | runs-on: ubuntu-24.04 15 | 16 | services: 17 | mysql: 18 | image: mysql:5.7 19 | env: 20 | MYSQL_ROOT_PASSWORD: password 21 | options: >- 22 | --health-cmd="mysqladmin ping" 23 | --health-interval=10s 24 | --health-timeout=5s 25 | --health-retries=5 26 | ports: 27 | - 3306:3306 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | include: 33 | - ruby: 2.7.8 34 | gemfile: Gemfile.6.1.mysql2 35 | - ruby: 3.2.0 36 | gemfile: Gemfile.6.1.mysql2 37 | - ruby: 3.2.0 38 | gemfile: Gemfile.7.0.mysql2 39 | - ruby: 3.2.0 40 | gemfile: Gemfile.7.1.mysql2 41 | - ruby: 3.3.4 42 | gemfile: Gemfile.7.2.mysql2 43 | - ruby: 3.3.4 44 | gemfile: Gemfile.8.0.mysql2 45 | 46 | env: 47 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Install ruby 52 | uses: ruby/setup-ruby@v1 53 | with: 54 | ruby-version: ${{ matrix.ruby }} 55 | bundler-cache: true 56 | - name: Setup database 57 | run: | 58 | mysql -e 'create database IF NOT EXISTS minidusen_test;' -u root --password=password -P 3306 -h 127.0.0.1 59 | - name: Run tests 60 | run: bundle exec rspec 61 | 62 | test_pg: 63 | runs-on: ubuntu-24.04 64 | 65 | services: 66 | postgres: 67 | image: postgres 68 | env: 69 | POSTGRES_PASSWORD: postgres 70 | POSTGRES_DB: minidusen_test 71 | options: >- 72 | --health-cmd pg_isready 73 | --health-interval 10s 74 | --health-timeout 5s 75 | --health-retries 5 76 | ports: 77 | - 5432:5432 78 | 79 | strategy: 80 | fail-fast: false 81 | matrix: 82 | include: 83 | - ruby: 2.7.8 84 | gemfile: Gemfile.6.1.pg 85 | - ruby: 3.2.0 86 | gemfile: Gemfile.6.1.pg 87 | - ruby: 3.2.0 88 | gemfile: Gemfile.7.0.pg 89 | - ruby: 3.2.0 90 | gemfile: Gemfile.7.1.pg 91 | - ruby: 3.3.4 92 | gemfile: Gemfile.7.2.pg 93 | - ruby: 3.3.4 94 | gemfile: Gemfile.8.0.pg 95 | 96 | env: 97 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 98 | 99 | steps: 100 | - uses: actions/checkout@v3 101 | - name: Install ruby 102 | uses: ruby/setup-ruby@v1 103 | with: 104 | ruby-version: ${{ matrix.ruby }} 105 | bundler-cache: true 106 | - name: Run tests 107 | run: bundle exec rspec 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | tags 3 | *.gem 4 | .idea 5 | tmp 6 | spec/support/database.yml 7 | .byebug_history 8 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | 7 | ## Unreleased 8 | 9 | ### Breaking changes 10 | 11 | ### Compatible changes 12 | 13 | ## 0.11.2 2024-10-31 14 | 15 | ### Compatible changes 16 | 17 | - Fix: Performance of queries using a negation ("-xxx") is improved by using an anti-join instead of a "NOT IN". 18 | 19 | ## 0.11.1 2024-08-22 20 | 21 | ### Compatible changes 22 | 23 | - Fix: Use ActiveSupport.on_load for extending ActiveRecord 24 | 25 | ## 0.11.0 2024-03-18 26 | 27 | ### Breaking changes 28 | 29 | - only parse strings with single colons as fields 30 | 31 | ## 0.10.1 2022-03-16 32 | 33 | ### Compatible changes 34 | 35 | - Add Rails 7.0 and Ruby 3.0 to test matrix 36 | 37 | ## 0.10.0 2021-08-11 38 | 39 | ### Compatible changes 40 | 41 | - Remove Rails 3.2 and Ruby < 2.5 from test matrix 42 | - Add Rails 6.1 and Ruby 3.0 to test matrix 43 | 44 | ## 0.9.0 2019-06-12 45 | 46 | ### Compatible changes 47 | 48 | - CHANGELOG to satisfy [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) format. 49 | - Use Rails 5.2 for tests instead of Rails 5.0 50 | - Added Ruby 2.5.3 to test matrix 51 | - Added support for Rails 6 RC1 52 | 53 | ## 0.8.0 2017-08-21 54 | 55 | ### Added 56 | - Filters are now run in the context of the filter instance, not the filter class. This allows using private methods or instance variables. 57 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | Gemfile.7.2.pg -------------------------------------------------------------------------------- /Gemfile.6.1.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>6.1.0' 5 | gem 'mysql2', '~>0.5' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.6.1.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (6.1.7.2) 13 | activesupport (= 6.1.7.2) 14 | activerecord (6.1.7.2) 15 | activemodel (= 6.1.7.2) 16 | activesupport (= 6.1.7.2) 17 | activesupport (6.1.7.2) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (>= 1.6, < 2) 20 | minitest (>= 5.1) 21 | tzinfo (~> 2.0) 22 | zeitwerk (~> 2.3) 23 | byebug (11.1.3) 24 | concurrent-ruby (1.1.8) 25 | database_cleaner (2.0.1) 26 | database_cleaner-active_record (~> 2.0.0) 27 | database_cleaner-active_record (2.0.0) 28 | activerecord (>= 5.a) 29 | database_cleaner-core (~> 2.0.0) 30 | database_cleaner-core (2.0.1) 31 | diff-lcs (1.4.4) 32 | edge_rider (2.1.1) 33 | activerecord (>= 3.2) 34 | gemika (0.8.1) 35 | i18n (1.8.9) 36 | concurrent-ruby (~> 1.0) 37 | minitest (5.14.4) 38 | mysql2 (0.5.5) 39 | rake (13.0.6) 40 | rspec (3.10.0) 41 | rspec-core (~> 3.10.0) 42 | rspec-expectations (~> 3.10.0) 43 | rspec-mocks (~> 3.10.0) 44 | rspec-core (3.10.1) 45 | rspec-support (~> 3.10.0) 46 | rspec-expectations (3.10.1) 47 | diff-lcs (>= 1.2.0, < 2.0) 48 | rspec-support (~> 3.10.0) 49 | rspec-mocks (3.10.2) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.10.0) 52 | rspec-support (3.10.2) 53 | tzinfo (2.0.4) 54 | concurrent-ruby (~> 1.0) 55 | zeitwerk (2.4.2) 56 | 57 | PLATFORMS 58 | x86_64-linux 59 | 60 | DEPENDENCIES 61 | activerecord (~> 6.1.0) 62 | byebug 63 | database_cleaner 64 | gemika 65 | minidusen! 66 | mysql2 (~> 0.5) 67 | rake 68 | rspec (~> 3.5) 69 | 70 | BUNDLED WITH 71 | 2.2.24 72 | -------------------------------------------------------------------------------- /Gemfile.6.1.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>6.1.0' 5 | gem 'pg', '~> 1.4.0' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.6.1.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (6.1.7.2) 13 | activesupport (= 6.1.7.2) 14 | activerecord (6.1.7.2) 15 | activemodel (= 6.1.7.2) 16 | activesupport (= 6.1.7.2) 17 | activesupport (6.1.7.2) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (>= 1.6, < 2) 20 | minitest (>= 5.1) 21 | tzinfo (~> 2.0) 22 | zeitwerk (~> 2.3) 23 | byebug (11.1.3) 24 | concurrent-ruby (1.1.8) 25 | database_cleaner (2.0.1) 26 | database_cleaner-active_record (~> 2.0.0) 27 | database_cleaner-active_record (2.0.0) 28 | activerecord (>= 5.a) 29 | database_cleaner-core (~> 2.0.0) 30 | database_cleaner-core (2.0.1) 31 | diff-lcs (1.4.4) 32 | edge_rider (2.1.1) 33 | activerecord (>= 3.2) 34 | gemika (0.8.1) 35 | i18n (1.8.9) 36 | concurrent-ruby (~> 1.0) 37 | minitest (5.14.4) 38 | pg (1.4.5) 39 | rake (13.0.6) 40 | rspec (3.10.0) 41 | rspec-core (~> 3.10.0) 42 | rspec-expectations (~> 3.10.0) 43 | rspec-mocks (~> 3.10.0) 44 | rspec-core (3.10.1) 45 | rspec-support (~> 3.10.0) 46 | rspec-expectations (3.10.1) 47 | diff-lcs (>= 1.2.0, < 2.0) 48 | rspec-support (~> 3.10.0) 49 | rspec-mocks (3.10.2) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.10.0) 52 | rspec-support (3.10.2) 53 | tzinfo (2.0.4) 54 | concurrent-ruby (~> 1.0) 55 | zeitwerk (2.4.2) 56 | 57 | PLATFORMS 58 | x86_64-linux 59 | 60 | DEPENDENCIES 61 | activerecord (~> 6.1.0) 62 | byebug 63 | database_cleaner 64 | gemika 65 | minidusen! 66 | pg (~> 1.4.0) 67 | rake 68 | rspec (~> 3.5) 69 | 70 | BUNDLED WITH 71 | 2.2.24 72 | -------------------------------------------------------------------------------- /Gemfile.7.0.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.0.4.2' 5 | gem 'mysql2', '~>0.5' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.0.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.0.4.2) 13 | activesupport (= 7.0.4.2) 14 | activerecord (7.0.4.2) 15 | activemodel (= 7.0.4.2) 16 | activesupport (= 7.0.4.2) 17 | activesupport (7.0.4.2) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (>= 1.6, < 2) 20 | minitest (>= 5.1) 21 | tzinfo (~> 2.0) 22 | byebug (11.1.3) 23 | concurrent-ruby (1.1.9) 24 | database_cleaner (2.0.1) 25 | database_cleaner-active_record (~> 2.0.0) 26 | database_cleaner-active_record (2.0.1) 27 | activerecord (>= 5.a) 28 | database_cleaner-core (~> 2.0.0) 29 | database_cleaner-core (2.0.1) 30 | diff-lcs (1.5.0) 31 | edge_rider (2.1.1) 32 | activerecord (>= 3.2) 33 | gemika (0.8.1) 34 | i18n (1.8.11) 35 | concurrent-ruby (~> 1.0) 36 | minitest (5.15.0) 37 | mysql2 (0.5.5) 38 | rake (13.0.6) 39 | rspec (3.10.0) 40 | rspec-core (~> 3.10.0) 41 | rspec-expectations (~> 3.10.0) 42 | rspec-mocks (~> 3.10.0) 43 | rspec-core (3.10.1) 44 | rspec-support (~> 3.10.0) 45 | rspec-expectations (3.10.1) 46 | diff-lcs (>= 1.2.0, < 2.0) 47 | rspec-support (~> 3.10.0) 48 | rspec-mocks (3.10.2) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.10.0) 51 | rspec-support (3.10.3) 52 | tzinfo (2.0.4) 53 | concurrent-ruby (~> 1.0) 54 | 55 | PLATFORMS 56 | x86_64-linux 57 | 58 | DEPENDENCIES 59 | activerecord (~> 7.0.4.2) 60 | byebug 61 | database_cleaner 62 | gemika 63 | minidusen! 64 | mysql2 (~> 0.5) 65 | rake 66 | rspec (~> 3.5) 67 | 68 | BUNDLED WITH 69 | 2.2.3 70 | -------------------------------------------------------------------------------- /Gemfile.7.0.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.0.4.2' 5 | gem 'pg', '~> 1.4.0' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.0.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.0.4.2) 13 | activesupport (= 7.0.4.2) 14 | activerecord (7.0.4.2) 15 | activemodel (= 7.0.4.2) 16 | activesupport (= 7.0.4.2) 17 | activesupport (7.0.4.2) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (>= 1.6, < 2) 20 | minitest (>= 5.1) 21 | tzinfo (~> 2.0) 22 | byebug (11.1.3) 23 | concurrent-ruby (1.1.9) 24 | database_cleaner (2.0.1) 25 | database_cleaner-active_record (~> 2.0.0) 26 | database_cleaner-active_record (2.0.1) 27 | activerecord (>= 5.a) 28 | database_cleaner-core (~> 2.0.0) 29 | database_cleaner-core (2.0.1) 30 | diff-lcs (1.5.0) 31 | edge_rider (2.1.1) 32 | activerecord (>= 3.2) 33 | gemika (0.8.1) 34 | i18n (1.8.11) 35 | concurrent-ruby (~> 1.0) 36 | minitest (5.15.0) 37 | pg (1.4.5) 38 | rake (13.0.6) 39 | rspec (3.10.0) 40 | rspec-core (~> 3.10.0) 41 | rspec-expectations (~> 3.10.0) 42 | rspec-mocks (~> 3.10.0) 43 | rspec-core (3.10.1) 44 | rspec-support (~> 3.10.0) 45 | rspec-expectations (3.10.1) 46 | diff-lcs (>= 1.2.0, < 2.0) 47 | rspec-support (~> 3.10.0) 48 | rspec-mocks (3.10.2) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.10.0) 51 | rspec-support (3.10.3) 52 | tzinfo (2.0.4) 53 | concurrent-ruby (~> 1.0) 54 | 55 | PLATFORMS 56 | x86_64-linux 57 | 58 | DEPENDENCIES 59 | activerecord (~> 7.0.4.2) 60 | byebug 61 | database_cleaner 62 | gemika 63 | minidusen! 64 | pg (~> 1.4.0) 65 | rake 66 | rspec (~> 3.5) 67 | 68 | BUNDLED WITH 69 | 2.2.3 70 | -------------------------------------------------------------------------------- /Gemfile.7.1.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.1.4' 5 | gem 'mysql2', '~>0.5' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.1.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.1.4) 13 | activesupport (= 7.1.4) 14 | activerecord (7.1.4) 15 | activemodel (= 7.1.4) 16 | activesupport (= 7.1.4) 17 | timeout (>= 0.4.0) 18 | activesupport (7.1.4) 19 | base64 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.0.2) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | minitest (>= 5.1) 26 | mutex_m 27 | tzinfo (~> 2.0) 28 | base64 (0.2.0) 29 | bigdecimal (3.1.8) 30 | byebug (11.1.3) 31 | concurrent-ruby (1.3.4) 32 | connection_pool (2.4.1) 33 | database_cleaner (2.0.2) 34 | database_cleaner-active_record (>= 2, < 3) 35 | database_cleaner-active_record (2.2.0) 36 | activerecord (>= 5.a) 37 | database_cleaner-core (~> 2.0.0) 38 | database_cleaner-core (2.0.1) 39 | diff-lcs (1.5.1) 40 | drb (2.2.1) 41 | edge_rider (2.3.0) 42 | activerecord (>= 3.2) 43 | gemika (0.8.3) 44 | i18n (1.14.5) 45 | concurrent-ruby (~> 1.0) 46 | minitest (5.25.1) 47 | mutex_m (0.2.0) 48 | mysql2 (0.5.6) 49 | rake (13.2.1) 50 | rspec (3.13.0) 51 | rspec-core (~> 3.13.0) 52 | rspec-expectations (~> 3.13.0) 53 | rspec-mocks (~> 3.13.0) 54 | rspec-core (3.13.1) 55 | rspec-support (~> 3.13.0) 56 | rspec-expectations (3.13.2) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.13.0) 59 | rspec-mocks (3.13.1) 60 | diff-lcs (>= 1.2.0, < 2.0) 61 | rspec-support (~> 3.13.0) 62 | rspec-support (3.13.1) 63 | timeout (0.4.1) 64 | tzinfo (2.0.6) 65 | concurrent-ruby (~> 1.0) 66 | 67 | PLATFORMS 68 | x86_64-linux 69 | 70 | DEPENDENCIES 71 | activerecord (~> 7.1.4) 72 | byebug 73 | database_cleaner 74 | gemika 75 | minidusen! 76 | mysql2 (~> 0.5) 77 | rake 78 | rspec (~> 3.5) 79 | 80 | BUNDLED WITH 81 | 2.5.6 82 | -------------------------------------------------------------------------------- /Gemfile.7.1.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.1.4' 5 | gem 'pg', '~> 1.4.0' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.1.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.1.4) 13 | activesupport (= 7.1.4) 14 | activerecord (7.1.4) 15 | activemodel (= 7.1.4) 16 | activesupport (= 7.1.4) 17 | timeout (>= 0.4.0) 18 | activesupport (7.1.4) 19 | base64 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.0.2) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | minitest (>= 5.1) 26 | mutex_m 27 | tzinfo (~> 2.0) 28 | base64 (0.2.0) 29 | bigdecimal (3.1.8) 30 | byebug (11.1.3) 31 | concurrent-ruby (1.3.4) 32 | connection_pool (2.4.1) 33 | database_cleaner (2.0.2) 34 | database_cleaner-active_record (>= 2, < 3) 35 | database_cleaner-active_record (2.2.0) 36 | activerecord (>= 5.a) 37 | database_cleaner-core (~> 2.0.0) 38 | database_cleaner-core (2.0.1) 39 | diff-lcs (1.5.1) 40 | drb (2.2.1) 41 | edge_rider (2.3.0) 42 | activerecord (>= 3.2) 43 | gemika (0.8.3) 44 | i18n (1.14.5) 45 | concurrent-ruby (~> 1.0) 46 | minitest (5.25.1) 47 | mutex_m (0.2.0) 48 | pg (1.4.6) 49 | rake (13.2.1) 50 | rspec (3.13.0) 51 | rspec-core (~> 3.13.0) 52 | rspec-expectations (~> 3.13.0) 53 | rspec-mocks (~> 3.13.0) 54 | rspec-core (3.13.1) 55 | rspec-support (~> 3.13.0) 56 | rspec-expectations (3.13.2) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.13.0) 59 | rspec-mocks (3.13.1) 60 | diff-lcs (>= 1.2.0, < 2.0) 61 | rspec-support (~> 3.13.0) 62 | rspec-support (3.13.1) 63 | timeout (0.4.1) 64 | tzinfo (2.0.6) 65 | concurrent-ruby (~> 1.0) 66 | 67 | PLATFORMS 68 | x86_64-linux 69 | 70 | DEPENDENCIES 71 | activerecord (~> 7.1.4) 72 | byebug 73 | database_cleaner 74 | gemika 75 | minidusen! 76 | pg (~> 1.4.0) 77 | rake 78 | rspec (~> 3.5) 79 | 80 | BUNDLED WITH 81 | 2.5.6 82 | -------------------------------------------------------------------------------- /Gemfile.7.2.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.2.1' 5 | gem 'mysql2', '~>0.5' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.2.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.2.1) 13 | activesupport (= 7.2.1) 14 | activerecord (7.2.1) 15 | activemodel (= 7.2.1) 16 | activesupport (= 7.2.1) 17 | timeout (>= 0.4.0) 18 | activesupport (7.2.1) 19 | base64 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.3.1) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | logger (>= 1.4.2) 26 | minitest (>= 5.1) 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0, >= 2.0.5) 29 | base64 (0.2.0) 30 | bigdecimal (3.1.8) 31 | byebug (11.1.3) 32 | concurrent-ruby (1.3.4) 33 | connection_pool (2.4.1) 34 | database_cleaner (2.0.2) 35 | database_cleaner-active_record (>= 2, < 3) 36 | database_cleaner-active_record (2.2.0) 37 | activerecord (>= 5.a) 38 | database_cleaner-core (~> 2.0.0) 39 | database_cleaner-core (2.0.1) 40 | diff-lcs (1.5.1) 41 | drb (2.2.1) 42 | edge_rider (2.3.0) 43 | activerecord (>= 3.2) 44 | gemika (0.8.3) 45 | i18n (1.14.5) 46 | concurrent-ruby (~> 1.0) 47 | logger (1.6.1) 48 | minitest (5.25.1) 49 | mysql2 (0.5.6) 50 | rake (13.2.1) 51 | rspec (3.13.0) 52 | rspec-core (~> 3.13.0) 53 | rspec-expectations (~> 3.13.0) 54 | rspec-mocks (~> 3.13.0) 55 | rspec-core (3.13.1) 56 | rspec-support (~> 3.13.0) 57 | rspec-expectations (3.13.2) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.13.0) 60 | rspec-mocks (3.13.1) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.13.0) 63 | rspec-support (3.13.1) 64 | securerandom (0.3.1) 65 | timeout (0.4.1) 66 | tzinfo (2.0.6) 67 | concurrent-ruby (~> 1.0) 68 | 69 | PLATFORMS 70 | x86_64-linux 71 | 72 | DEPENDENCIES 73 | activerecord (~> 7.2.1) 74 | byebug 75 | database_cleaner 76 | gemika 77 | minidusen! 78 | mysql2 (~> 0.5) 79 | rake 80 | rspec (~> 3.5) 81 | 82 | BUNDLED WITH 83 | 2.5.6 84 | -------------------------------------------------------------------------------- /Gemfile.7.2.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>7.2.1' 5 | gem 'pg', '~> 1.4.0' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.7.2.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.2.1) 13 | activesupport (= 7.2.1) 14 | activerecord (7.2.1) 15 | activemodel (= 7.2.1) 16 | activesupport (= 7.2.1) 17 | timeout (>= 0.4.0) 18 | activesupport (7.2.1) 19 | base64 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.3.1) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | logger (>= 1.4.2) 26 | minitest (>= 5.1) 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0, >= 2.0.5) 29 | base64 (0.2.0) 30 | bigdecimal (3.1.8) 31 | byebug (11.1.3) 32 | concurrent-ruby (1.3.4) 33 | connection_pool (2.4.1) 34 | database_cleaner (2.0.2) 35 | database_cleaner-active_record (>= 2, < 3) 36 | database_cleaner-active_record (2.2.0) 37 | activerecord (>= 5.a) 38 | database_cleaner-core (~> 2.0.0) 39 | database_cleaner-core (2.0.1) 40 | diff-lcs (1.5.1) 41 | drb (2.2.1) 42 | edge_rider (2.3.0) 43 | activerecord (>= 3.2) 44 | gemika (0.8.3) 45 | i18n (1.14.5) 46 | concurrent-ruby (~> 1.0) 47 | logger (1.6.1) 48 | minitest (5.25.1) 49 | pg (1.4.6) 50 | rake (13.2.1) 51 | rspec (3.13.0) 52 | rspec-core (~> 3.13.0) 53 | rspec-expectations (~> 3.13.0) 54 | rspec-mocks (~> 3.13.0) 55 | rspec-core (3.13.1) 56 | rspec-support (~> 3.13.0) 57 | rspec-expectations (3.13.2) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.13.0) 60 | rspec-mocks (3.13.1) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.13.0) 63 | rspec-support (3.13.1) 64 | securerandom (0.3.1) 65 | timeout (0.4.1) 66 | tzinfo (2.0.6) 67 | concurrent-ruby (~> 1.0) 68 | 69 | PLATFORMS 70 | x86_64-linux 71 | 72 | DEPENDENCIES 73 | activerecord (~> 7.2.1) 74 | byebug 75 | database_cleaner 76 | gemika 77 | minidusen! 78 | pg (~> 1.4.0) 79 | rake 80 | rspec (~> 3.5) 81 | 82 | BUNDLED WITH 83 | 2.5.6 84 | -------------------------------------------------------------------------------- /Gemfile.8.0.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>8.0.1' 5 | gem 'mysql2', '~>0.5' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.8.0.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (8.0.1) 13 | activesupport (= 8.0.1) 14 | activerecord (8.0.1) 15 | activemodel (= 8.0.1) 16 | activesupport (= 8.0.1) 17 | timeout (>= 0.4.0) 18 | activesupport (8.0.1) 19 | base64 20 | benchmark (>= 0.3) 21 | bigdecimal 22 | concurrent-ruby (~> 1.0, >= 1.3.1) 23 | connection_pool (>= 2.2.5) 24 | drb 25 | i18n (>= 1.6, < 2) 26 | logger (>= 1.4.2) 27 | minitest (>= 5.1) 28 | securerandom (>= 0.3) 29 | tzinfo (~> 2.0, >= 2.0.5) 30 | uri (>= 0.13.1) 31 | base64 (0.2.0) 32 | benchmark (0.4.0) 33 | bigdecimal (3.1.8) 34 | byebug (11.1.3) 35 | concurrent-ruby (1.3.4) 36 | connection_pool (2.4.1) 37 | database_cleaner (2.0.2) 38 | database_cleaner-active_record (>= 2, < 3) 39 | database_cleaner-active_record (2.2.0) 40 | activerecord (>= 5.a) 41 | database_cleaner-core (~> 2.0.0) 42 | database_cleaner-core (2.0.1) 43 | diff-lcs (1.5.1) 44 | drb (2.2.1) 45 | edge_rider (2.3.0) 46 | activerecord (>= 3.2) 47 | gemika (0.8.3) 48 | i18n (1.14.5) 49 | concurrent-ruby (~> 1.0) 50 | logger (1.6.1) 51 | minitest (5.25.1) 52 | mysql2 (0.5.6) 53 | rake (13.2.1) 54 | rspec (3.13.0) 55 | rspec-core (~> 3.13.0) 56 | rspec-expectations (~> 3.13.0) 57 | rspec-mocks (~> 3.13.0) 58 | rspec-core (3.13.1) 59 | rspec-support (~> 3.13.0) 60 | rspec-expectations (3.13.2) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.13.0) 63 | rspec-mocks (3.13.1) 64 | diff-lcs (>= 1.2.0, < 2.0) 65 | rspec-support (~> 3.13.0) 66 | rspec-support (3.13.1) 67 | securerandom (0.3.1) 68 | timeout (0.4.3) 69 | tzinfo (2.0.6) 70 | concurrent-ruby (~> 1.0) 71 | uri (1.0.2) 72 | 73 | PLATFORMS 74 | x86_64-linux 75 | 76 | DEPENDENCIES 77 | activerecord (~> 8.0.1) 78 | byebug 79 | database_cleaner 80 | gemika 81 | minidusen! 82 | mysql2 (~> 0.5) 83 | rake 84 | rspec (~> 3.5) 85 | 86 | BUNDLED WITH 87 | 2.5.6 88 | -------------------------------------------------------------------------------- /Gemfile.8.0.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Runtime dependencies 4 | gem 'activerecord', '~>8.0.1' 5 | gem 'pg', '~> 1.4.0' 6 | 7 | # Development dependencies 8 | gem 'rake' 9 | gem 'database_cleaner' 10 | gem 'rspec', '~>3.5' 11 | gem 'byebug' 12 | gem 'gemika' 13 | 14 | # Gem under test 15 | gem 'minidusen', :path => '.' 16 | -------------------------------------------------------------------------------- /Gemfile.8.0.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minidusen (0.11.2) 5 | activerecord (>= 3.2) 6 | activesupport (>= 3.2) 7 | edge_rider (>= 0.2.5) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (8.0.1) 13 | activesupport (= 8.0.1) 14 | activerecord (8.0.1) 15 | activemodel (= 8.0.1) 16 | activesupport (= 8.0.1) 17 | timeout (>= 0.4.0) 18 | activesupport (8.0.1) 19 | base64 20 | benchmark (>= 0.3) 21 | bigdecimal 22 | concurrent-ruby (~> 1.0, >= 1.3.1) 23 | connection_pool (>= 2.2.5) 24 | drb 25 | i18n (>= 1.6, < 2) 26 | logger (>= 1.4.2) 27 | minitest (>= 5.1) 28 | securerandom (>= 0.3) 29 | tzinfo (~> 2.0, >= 2.0.5) 30 | uri (>= 0.13.1) 31 | base64 (0.2.0) 32 | benchmark (0.4.0) 33 | bigdecimal (3.1.8) 34 | byebug (11.1.3) 35 | concurrent-ruby (1.3.4) 36 | connection_pool (2.4.1) 37 | database_cleaner (2.0.2) 38 | database_cleaner-active_record (>= 2, < 3) 39 | database_cleaner-active_record (2.2.0) 40 | activerecord (>= 5.a) 41 | database_cleaner-core (~> 2.0.0) 42 | database_cleaner-core (2.0.1) 43 | diff-lcs (1.5.1) 44 | drb (2.2.1) 45 | edge_rider (2.3.0) 46 | activerecord (>= 3.2) 47 | gemika (0.8.3) 48 | i18n (1.14.5) 49 | concurrent-ruby (~> 1.0) 50 | logger (1.6.1) 51 | minitest (5.25.1) 52 | pg (1.4.6) 53 | rake (13.2.1) 54 | rspec (3.13.0) 55 | rspec-core (~> 3.13.0) 56 | rspec-expectations (~> 3.13.0) 57 | rspec-mocks (~> 3.13.0) 58 | rspec-core (3.13.1) 59 | rspec-support (~> 3.13.0) 60 | rspec-expectations (3.13.2) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.13.0) 63 | rspec-mocks (3.13.1) 64 | diff-lcs (>= 1.2.0, < 2.0) 65 | rspec-support (~> 3.13.0) 66 | rspec-support (3.13.1) 67 | securerandom (0.3.1) 68 | timeout (0.4.3) 69 | tzinfo (2.0.6) 70 | concurrent-ruby (~> 1.0) 71 | uri (1.0.2) 72 | 73 | PLATFORMS 74 | ruby 75 | 76 | DEPENDENCIES 77 | activerecord (~> 8.0.1) 78 | byebug 79 | database_cleaner 80 | gemika 81 | minidusen! 82 | pg (~> 1.4.0) 83 | rake 84 | rspec (~> 3.5) 85 | 86 | BUNDLED WITH 87 | 2.5.6 88 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | Gemfile.7.2.pg.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Henning Koch 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | makandra 7 | 8 | 9 | 10 | 11 | 12 | 13 | Minidusen 14 | 15 |

16 | 17 | [![Tests](https://github.com/makandra/minidusen/workflows/Tests/badge.svg)](https://github.com/makandra/minidusen/actions) 18 | 19 | Low-tech search solution for ActiveRecord with MySQL or PostgreSQL 20 | ------------------------------------------------------------------ 21 | 22 | Minidusen lets you filter ActiveRecord models with a single query string. 23 | It works with your existing MySQL or PostgreSQL schema by mostly relying on simple `LIKE` queries. No additional indexes, tables or indexing databases are required. 24 | 25 | This makes Minidusen a quick way to implement find-as-you-type filters for index views: 26 | 27 | ![A list of records filtered by a query](https://raw.githubusercontent.com/makandra/minidusen/main/doc/filtered_index_view.cropped.png) 28 | 29 | 30 | ### Supported queries 31 | 32 | Minidusen accepts a single, Google-like query string and converts it into `WHERE` conditions for [an ActiveRecord scope](http://guides.rubyonrails.org/active_record_querying.html#conditions). 33 | 34 | The following type of queries are supported: 35 | 36 | - `foo` (case-insensitive search for `foo` in developer-defined columns) 37 | - `foo bar` (rows must include both `foo` and `bar`) 38 | - `"foo bar"` (rows must include the phrase `"foo bar"`) 39 | - `-bar` (rows must not include the word `bar`) 40 | - `filetype:pdf` (developer-defined filter for file type) 41 | - `some words 'a phrase' filetype:pdf -excluded -'excluded phrase' -filetype:pdf` (combination of the above) 42 | 43 | 44 | ### Limitations 45 | 46 | Since Minidusen doesn't use an index, it scales linearly with the amount of of text that needs to be searched. Yet `LIKE` queries are pretty fast and we have found this low-tech approach to scale well for many thousand records. 47 | 48 | It's probably not a good idea to use Minidusen for hundreds of thousands of records, or for very long text columns. For this we recommend to use PostgreSQL with [pg_search](https://github.com/Casecommons/pg_search) or full-text databases like [Solr](https://github.com/sunspot/sunspot). 49 | 50 | Another limitation of Minidusen is that it only *filters*, but does not *rank*. A record either matches or not. Minidusen won't tell you if one record matches *better* than another record. 51 | 52 | 53 | Installation 54 | ------------ 55 | 56 | In your `Gemfile` say: 57 | 58 | ```ruby 59 | gem 'minidusen' 60 | ``` 61 | 62 | Now run `bundle install` and restart your server. 63 | 64 | 65 | Basic Usage 66 | ----------- 67 | 68 | Our example will be a simple address book: 69 | 70 | ```ruby 71 | class Contact < ApplicationRecord 72 | validates_presence_of :name, :street, :city, :email 73 | end 74 | ``` 75 | 76 | We create a new class `ContactFilter` that will describe the searchable columns: 77 | 78 | ```ruby 79 | class ContactFilter 80 | include Minidusen::Filter 81 | 82 | filter :text do |scope, phrases| 83 | columns = [:name, :email] 84 | scope.where_like(columns => phrases) 85 | end 86 | end 87 | ``` 88 | 89 | We can now use `ContactFilter` to filter a scope of `Contact` records: 90 | 91 | ```ruby 92 | # We start by building a scope of all contacts. 93 | # No SQL query is made. 94 | all_contacts = Contact.all 95 | # => ActiveRecord::Relation 96 | 97 | # Now we filter the scope to only contain contacts with "gmail" in either :name or :email column. 98 | # Again, no SQL query is made. 99 | gmail_contacts = ContactFilter.new.filter(all_contacts, 'gmail') 100 | # => ActiveRecord::Relation 101 | 102 | # Inspect the filtered scope. 103 | gmail_contacts.to_sql 104 | # => "SELECT * FROM contacts WHERE name LIKE '%gmail%' OR email LIKE '%gmail%'" 105 | 106 | # Finally we load the scope to produce an array of Contact records. 107 | gmail_contacts.to_a 108 | # => Array 109 | ``` 110 | 111 | ### Filtering scopes with existing conditions 112 | 113 | Note that you can also pass a scope with existing conditions to `ContactFilter#filter`. The returned scope will contain both the existing conditions and the conditions from the filter: 114 | 115 | ```ruby 116 | published_contacts = Contact.where(published: true) 117 | # => ActiveRecord::Relation 118 | 119 | published_contacts.to_sql 120 | # => "SELECT * FROM contacts WHERE (published = 1)" 121 | 122 | gmail_contacts = ContactFilter.new.filter(published_contacts, 'gmail') 123 | # => ActiveRecord::Relation 124 | 125 | gmail_contacts.to_sql 126 | # => "SELECT * FROM contacts WHERE (published = 1) AND (name LIKE '%gmail%' OR email LIKE '%gmail%')" 127 | ``` 128 | 129 | ### How `where_like` works 130 | 131 | The example above uses `where_like`. You can call `where_like` on any scope to produce a new scope where the given array of column names must contain all of the given phrases. 132 | 133 | Let's say we call `ContactFilter.new.filter(Contact.published, 'foo "bar baz" bam')`. This will call the block `filter :text do |scope, phrases|` with the following arguments: 134 | 135 | ```ruby 136 | scope == Contact.published 137 | phrases == ['foo', 'bar baz', 'bam'] 138 | ``` 139 | 140 | The scope `scope.where_like(columns => phrases)` will now represent the following SQL query: 141 | 142 | ```ruby 143 | SELECT * FROM contacts 144 | WHERE (name LIKE "%foo%" OR email LIKE "%foo") AND (email LIKE "%foo%" OR email LIKE "%foo") 145 | ``` 146 | 147 | You can also use `where_like` to find all the records *not* matching some phrases, using the `:negate` option: 148 | 149 | ```ruby 150 | Contact.where_like(name: 'foo', negate: true) 151 | ``` 152 | 153 | Filtering associated records 154 | ---------------------------- 155 | 156 | Minidusen lets you find text in associated records. 157 | 158 | Assume the following model where a `Contact` record may be associated with a `Group` record: 159 | 160 | ```ruby 161 | class Contact < ApplicationRecord 162 | belongs_to :group 163 | 164 | validates_presence_of :name, :street, :city, :email 165 | end 166 | 167 | class Group < ApplicationRecord 168 | has_many :contacts 169 | 170 | validates_presence_of :name 171 | end 172 | ``` 173 | 174 | We can filter contacts by their group name by joining the `groups` table and filtering on a joined column. 175 | Note how the joined column is qualified as `groups.name` (rather than just `name`): 176 | 177 | ```ruby 178 | class ContactFilter 179 | include Minidusen::Filter 180 | 181 | filter :text do |scope, phrases| 182 | columns = [:name, :email, 'groups.name'] 183 | scope.includes(:group).where_like(columns => phrases) 184 | end 185 | end 186 | ``` 187 | 188 | 189 | 190 | Supporting qualified field syntax 191 | --------------------------------- 192 | 193 | Google supports queries like `filetype:pdf` that filters records by some criteria without performing a full text search. Minidusen gives you a simple way to support such search syntax. 194 | 195 | Let's support a query like `email:foo@bar.com` to explictly search for a contact's email address, without filtering against other columns. 196 | 197 | We can learn this syntax by adding a `filter:email` instruction 198 | to our `ContactFilter` class: 199 | 200 | ```ruby 201 | class ContactFilter 202 | include Minidusen::Filter 203 | 204 | filter :email do |scope, email| 205 | scope.where(email: email) 206 | end 207 | 208 | filter :text do |scope, phrases| 209 | columns = [:name, :email] 210 | scope.where_like(columns => phrases) 211 | end 212 | 213 | end 214 | ``` 215 | 216 | We can now explicitly search for a user's e-mail address: 217 | 218 | ```ruby 219 | ContactFilter.new.filter(Contact, 'email:foo@bar.com').to_sql 220 | # => "SELECT * FROM contacts WHERE email='foo@bar.com'" 221 | ``` 222 | 223 | ### Caveat 224 | 225 | If you search for a phrase containing a colon (e.g. `deploy:rollback`), Minidusen will mistake the first part as a – nonexistent – qualifier and return an empty set. 226 | 227 | To prevent that, search for a phrase: 228 | 229 | "deploy:rollback" 230 | 231 | 232 | Supported Rails versions 233 | ------------------------ 234 | 235 | Minidusen is tested on: 236 | 237 | - Rails 6.1 238 | - Rails 7.0 239 | - Rails 7.1 240 | - Rails 7.2 241 | - MySQL 5.6 242 | - PostgreSQL 243 | 244 | If you need support for platforms not listed above, please submit a PR! 245 | 246 | 247 | Development 248 | ----------- 249 | 250 | - There are tests in `spec`. We only accept PRs with tests. 251 | - We currently develop using the Ruby version in `.ruby-version`. It is required to change the Ruby Version to cover all Rails version or just use Gitlab CI. 252 | - Put your database credentials into `spec/support/database.yml`. There's a `database.sample.yml` you can use as a template. 253 | - Create a database `minidusen_test` in both MySQL and PostgreSQL. 254 | - There are gem bundles in the project root for each combination of ActiveRecord version and database type that we support. 255 | - You can bundle all test applications by saying `bundle exec rake matrix:install` 256 | - You can run specs from the project root by saying `bundle exec rake matrix:spec`. This will run all gemfiles compatible with your current Ruby. 257 | 258 | If you would like to contribute: 259 | 260 | - Fork the repository. 261 | - Push your changes **with passing specs**. 262 | - Send me a pull request. 263 | 264 | Note that we're very eager to keep this gem lightweight. If you're unsure whether a change would make it into the gem, [open an issue](https://github.com/makandra/minidusen/issues/new). 265 | 266 | 267 | Credits 268 | ------- 269 | 270 | Henning Koch from [makandra](http://makandra.com/) 271 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'bundler/gem_tasks' 3 | begin 4 | require 'gemika/tasks' 5 | rescue LoadError 6 | puts 'Run `gem install gemika` for additional tasks' 7 | end 8 | 9 | task :default => 'matrix:spec' 10 | -------------------------------------------------------------------------------- /doc/filtered_index_view.cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makandra/minidusen/a8dedbc5d5bde68edc27e575c12784e3091adbcb/doc/filtered_index_view.cropped.png -------------------------------------------------------------------------------- /lib/minidusen.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_record' 3 | require 'edge_rider' 4 | 5 | require 'minidusen/version' 6 | require 'minidusen/util' 7 | require 'minidusen/token' 8 | require 'minidusen/parser' 9 | require 'minidusen/query' 10 | require 'minidusen/syntax' 11 | require 'minidusen/filter' 12 | require 'minidusen/active_record_ext' 13 | -------------------------------------------------------------------------------- /lib/minidusen/active_record_ext.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | module ActiveRecordExtensions 3 | module ClassMethods 4 | 5 | def where_like(conditions, options = {}) 6 | scope = scoped 7 | 8 | ilike_operator = Util.ilike_operator(scope) 9 | 10 | if options[:negate] 11 | match_operator = "NOT #{ilike_operator}" 12 | join_operator = 'AND' 13 | else 14 | match_operator = ilike_operator 15 | join_operator = 'OR' 16 | end 17 | 18 | conditions.each do |field_or_fields, query| 19 | fields = Array(field_or_fields).collect do |field| 20 | Util.qualify_column_name(scope, field) 21 | end 22 | Array.wrap(query).each do |phrase| 23 | phrase_with_placeholders = fields.collect { |field| 24 | "#{field} #{match_operator} ?" 25 | }.join(" #{join_operator} ") 26 | like_expression = Minidusen::Util.like_expression(phrase) 27 | bindings = [like_expression] * fields.size 28 | conditions = [ phrase_with_placeholders, *bindings ] 29 | scope = scope.where(conditions) 30 | end 31 | end 32 | scope 33 | end 34 | 35 | end 36 | end 37 | end 38 | 39 | ActiveSupport.on_load(:active_record) do 40 | ActiveRecord::Base.send(:extend, Minidusen::ActiveRecordExtensions::ClassMethods) 41 | end 42 | -------------------------------------------------------------------------------- /lib/minidusen/filter.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | module Filter 3 | module ClassMethods 4 | 5 | private 6 | 7 | attr_accessor :minidusen_syntax 8 | 9 | def filter(field, &block) 10 | minidusen_syntax.learn_field(field, &block) 11 | end 12 | 13 | end 14 | 15 | def self.included(base) 16 | base.extend(ClassMethods) 17 | base.send(:minidusen_syntax=, Syntax.new) 18 | end 19 | 20 | def filter(scope, query) 21 | minidusen_syntax.search(self, scope, query) 22 | end 23 | 24 | private 25 | 26 | def minidusen_syntax 27 | self.class.send(:minidusen_syntax) 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/minidusen/parser.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | class Parser 3 | 4 | class CannotParse < StandardError; end 5 | 6 | TEXT_QUERY = /(?:(-)?"([^"]+)"|(-)?(\S+))/ 7 | FIELD_QUERY = /(?:\s|^|(-))(\w+):(?!:)#{TEXT_QUERY}/ 8 | 9 | class << self 10 | 11 | def parse(object) 12 | case object 13 | when Query 14 | object 15 | when String 16 | parse_string(object) 17 | when Array 18 | parse_array(object) 19 | else 20 | raise CannotParse, "Cannot parse #{object.inspect}" 21 | end 22 | end 23 | 24 | private 25 | 26 | def parse_string(string) 27 | string = string.dup # we are going to delete substrings in-place 28 | string = string.encode('UTF-8', invalid: :replace, undef: :replace, replace: '') 29 | query = Query.new 30 | extract_field_query_tokens(string, query) 31 | extract_text_query_tokens(string, query) 32 | query 33 | end 34 | 35 | def parse_array(array) 36 | tokens = array.map { |string| 37 | string.is_a?(String) or raise CannotParse, "Cannot parse an array of #{string.class}" 38 | Token.new(:field => 'text', :value => string) 39 | } 40 | Query.new(tokens) 41 | end 42 | 43 | def extract_text_query_tokens(query_string, query) 44 | while query_string.sub!(TEXT_QUERY, '') 45 | value = "#{$2}#{$4}" 46 | exclude = "#{$1}#{$3}" == "-" 47 | options = { :field => 'text', :value => value, :exclude => exclude } 48 | query << Token.new(options) 49 | end 50 | end 51 | 52 | def extract_field_query_tokens(query_string, query) 53 | while query_string.sub!(FIELD_QUERY, '') 54 | field = $2 55 | value = "#{$4}#{$6}" 56 | exclude = "#{$1}" == "-" 57 | options = { :field => field, :value => value, :exclude => exclude } 58 | query << Token.new(options) 59 | end 60 | end 61 | 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/minidusen/query.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | class Query 3 | 4 | include Enumerable 5 | 6 | attr_reader :tokens 7 | 8 | def initialize(initial_tokens = []) 9 | @tokens = initial_tokens 10 | end 11 | 12 | def <<(token) 13 | tokens << token 14 | end 15 | 16 | def [](index) 17 | tokens[index] 18 | end 19 | 20 | def size 21 | tokens.size 22 | end 23 | 24 | def to_s 25 | collect(&:to_s).join(" + ") 26 | end 27 | 28 | def each(&block) 29 | tokens.each(&block) 30 | end 31 | 32 | def condensed 33 | include_texts = include.select(&:text?).collect(&:value) 34 | exclude_texts = exclude.select(&:text?).collect(&:value) 35 | field_tokens = tokens.reject(&:text?) 36 | 37 | condensed_tokens = field_tokens 38 | if include_texts.present? 39 | options = { :field => 'text', :value => include_texts, :exclude => false } 40 | condensed_tokens << Token.new(options) 41 | end 42 | if exclude_texts.present? 43 | options = { :field => 'text', :value => exclude_texts, :exclude => true } 44 | condensed_tokens << Token.new(options) 45 | end 46 | self.class.new(condensed_tokens) 47 | end 48 | 49 | def include 50 | self.class.new tokens.reject(&:exclude?) 51 | end 52 | 53 | def exclude 54 | self.class.new tokens.select(&:exclude?) 55 | end 56 | 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/minidusen/syntax.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | class Syntax 3 | 4 | def initialize 5 | @scopers = {} 6 | @alias_count = 0 7 | end 8 | 9 | def learn_field(field, &scoper) 10 | field = field.to_s 11 | @scopers[field] = scoper 12 | end 13 | 14 | def search(instance, root_scope, query) 15 | query = parse(query) 16 | query = query.condensed 17 | matches = apply_query(instance, root_scope, query.include) 18 | if query.exclude.any? 19 | matches = append_excludes(instance, matches, query.exclude) 20 | end 21 | matches 22 | end 23 | 24 | def fields 25 | @scopers 26 | end 27 | 28 | def parse(query) 29 | Parser.parse(query) 30 | end 31 | 32 | private 33 | 34 | NONE = lambda do |scope, *args| 35 | scope.where('1=2') 36 | end 37 | 38 | def apply_query(instance, root_scope, query) 39 | scope = root_scope 40 | query.each do |token| 41 | scoper = @scopers[token.field] || NONE 42 | scope = instance.instance_exec(scope, token.value, &scoper) 43 | end 44 | scope 45 | end 46 | 47 | def append_excludes(instance, matches, exclude_query) 48 | excluded_records = apply_query(instance, matches.origin_class, exclude_query) 49 | primary_key = excluded_records.primary_key 50 | join_alias = "exclude_#{@alias_count += 1}" 51 | # due to performance reasons on big tables this needs to be implemented as an anti-join 52 | # will generate SQL like 53 | # LEFT JOIN (SELECT "users"."id" FROM "users" WHERE $condition) excluded 54 | # ON "users"."id" = "excluded"."id" 55 | # WHERE "excluded"."id" IS NULL 56 | matches 57 | .joins(<<~SQL) 58 | LEFT JOIN (#{excluded_records.select(primary_key).to_sql}) #{join_alias} 59 | ON #{Util.qualify_column_name(excluded_records, primary_key)} = #{Util.qualify_column_name(excluded_records, primary_key, table_name: join_alias)} 60 | SQL 61 | .where(join_alias => { primary_key => nil }) 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/minidusen/token.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | class Token 3 | 4 | attr_reader :field, :value, :exclude 5 | 6 | def initialize(options) 7 | @value = options.fetch(:value) 8 | @exclude = options.fetch(:exclude, false) 9 | @field = options.fetch(:field).to_s 10 | end 11 | 12 | def to_s 13 | value 14 | end 15 | 16 | def text? 17 | field == 'text' 18 | end 19 | 20 | def exclude? 21 | exclude 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/minidusen/util.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | module Util 3 | extend self 4 | 5 | def postgresql?(scope) 6 | adapter_name = scope.connection.class.name 7 | adapter_name =~ /postgres/i 8 | end 9 | 10 | def like_expression(phrase) 11 | "%#{escape_for_like_query(phrase)}%" 12 | end 13 | 14 | def ilike_operator(scope) 15 | if postgresql?(scope) 16 | 'ILIKE' 17 | else 18 | 'LIKE' 19 | end 20 | end 21 | 22 | def regexp_operator(scope) 23 | if postgresql?(scope) 24 | '~' 25 | else 26 | 'REGEXP' 27 | end 28 | end 29 | 30 | def escape_with_backslash(phrase, characters) 31 | characters << '\\' 32 | pattern = /[#{characters.collect(&Regexp.method(:quote)).join('')}]/ 33 | # debugger 34 | phrase.gsub(pattern) do |match| 35 | "\\#{match}" 36 | end 37 | end 38 | 39 | def escape_for_like_query(phrase) 40 | # phrase.gsub("%", "\\%").gsub("_", "\\_") 41 | escape_with_backslash(phrase, ['%', '_']) 42 | end 43 | 44 | def qualify_column_name(model, column_name, table_name: model.table_name) 45 | column_name = column_name.to_s 46 | unless column_name.include?('.') 47 | quoted_table_name = model.connection.quote_table_name(table_name) 48 | quoted_column_name = model.connection.quote_column_name(column_name) 49 | column_name = "#{quoted_table_name}.#{quoted_column_name}" 50 | end 51 | column_name 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/minidusen/version.rb: -------------------------------------------------------------------------------- 1 | module Minidusen 2 | VERSION = '0.11.2' 3 | end 4 | -------------------------------------------------------------------------------- /media/logo.dark.shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 47 | 48 | 71 | 73 | 74 | 76 | image/svg+xml 77 | 79 | 80 | 81 | 82 | 83 | 88 | 92 | 96 | 100 | 104 | 108 | 112 | 116 | 120 | 124 | 128 | 129 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /media/logo.dark.text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 47 | 48 | 71 | 73 | 74 | 76 | image/svg+xml 77 | 79 | 80 | 81 | 82 | 83 | 88 | MINIDUSEN 99 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /media/logo.light.shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 47 | 48 | 70 | 72 | 73 | 75 | image/svg+xml 76 | 78 | 79 | 80 | 81 | 82 | 87 | 91 | 95 | 99 | 103 | 107 | 111 | 115 | 119 | 123 | 127 | 128 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /media/logo.light.text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 47 | 48 | 70 | 72 | 73 | 75 | image/svg+xml 76 | 78 | 79 | 80 | 81 | 82 | 87 | MINIDUSEN 98 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /media/makandra-with-bottom-margin.dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 50 | 56 | 59 | 64 | 70 | 76 | 82 | 88 | 89 | 98 | 99 | 108 | 109 | 118 | 119 | 128 | 129 | 138 | 139 | 148 | 150 | 155 | 160 | 161 | 167 | 173 | 179 | 180 | -------------------------------------------------------------------------------- /media/makandra-with-bottom-margin.light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 50 | 56 | 59 | 64 | 70 | 76 | 82 | 88 | 89 | 98 | 99 | 108 | 109 | 118 | 119 | 128 | 129 | 138 | 139 | 148 | 150 | 155 | 160 | 161 | 167 | 173 | 179 | 180 | -------------------------------------------------------------------------------- /minidusen.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "minidusen/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'minidusen' 6 | s.version = Minidusen::VERSION 7 | s.authors = ["Henning Koch"] 8 | s.email = 'henning.koch@makandra.de' 9 | s.homepage = 'https://github.com/makandra/minidusen' 10 | s.summary = 'Low-tech search for ActiveRecord with MySQL or PostgreSQL' 11 | s.description = s.summary 12 | s.license = 'MIT' 13 | s.metadata = { 'rubygems_mfa_required' => 'true' } 14 | 15 | s.files = `git ls-files`.split("\n").reject { |path| File.lstat(path).symlink? } 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").reject { |path| File.lstat(path).symlink? } 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | 20 | s.add_dependency('activesupport', '>=3.2') 21 | s.add_dependency('activerecord', '>=3.2') 22 | s.add_dependency('edge_rider', '>=0.2.5') 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/minidusen/active_record_ext_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveRecord::Base do 2 | 3 | describe '.where_like' do 4 | 5 | it 'matches a record if a word appears in any of the given columns' do 6 | match1 = User.create!(:name => 'word', :city => 'XXXX') 7 | match2 = User.create!(:name => 'XXXX', :city => 'word') 8 | no_match = User.create!(:name => 'XXXX', :city => 'XXXX') 9 | User.where_like([:name, :city] => 'word').to_a.should =~ [match1, match2] 10 | end 11 | 12 | it 'matches a record if it contains all the given words' do 13 | match1 = User.create!(:city => 'word1 word2') 14 | match2 = User.create!(:city => 'word2 word1') 15 | no_match = User.create!(:city => 'word1') 16 | User.where_like(:city => ['word1', 'word2']).to_a.should =~ [match1, match2] 17 | end 18 | 19 | describe 'with :negate option' do 20 | 21 | it 'rejects a record if a word appears in any of the given columns' do 22 | no_match1 = User.create!(:name => 'word', :city => 'XXXX') 23 | no_match2 = User.create!(:name => 'XXXX', :city => 'word') 24 | match = User.create!(:name => 'XXXX', :city => 'XXXX') 25 | User.where_like({ [:name, :city] => 'word' }, :negate => true).to_a.should =~ [match] 26 | end 27 | 28 | it 'rejects a record if it matches at least one of the given words' do 29 | no_match1 = User.create!(:city => 'word1') 30 | no_match2 = User.create!(:city => 'word2') 31 | match = User.create!(:city => 'word3') 32 | User.where_like({ :city => ['word1', 'word2'] }, :negate => true).to_a.should =~ [match] 33 | end 34 | 35 | it "doesn't match NULL values" do 36 | no_match = User.create!(:city => nil) 37 | match = User.create!(:city => 'word3') 38 | User.where_like({ :city => ['word1'] }, :negate => true).to_a.should =~ [match] 39 | end 40 | 41 | end 42 | 43 | end 44 | 45 | end -------------------------------------------------------------------------------- /spec/minidusen/filter_spec.rb: -------------------------------------------------------------------------------- 1 | describe Minidusen::Filter do 2 | 3 | let :user_filter do 4 | UserFilter.new 5 | end 6 | 7 | let :recipe_filter do 8 | RecipeFilter.new 9 | end 10 | 11 | describe '#filter' do 12 | 13 | it 'should find records by given words' do 14 | match = User.create!(:name => 'Abraham') 15 | no_match = User.create!(:name => 'Elizabath') 16 | user_filter.filter(User, 'Abraham').to_a.should == [match] 17 | end 18 | 19 | it 'should make a case-insensitive search' do 20 | match = User.create!(:name => 'Abraham') 21 | no_match = User.create!(:name => 'Elizabath') 22 | user_filter.filter(User, 'aBrAhAm').to_a.should == [match] 23 | end 24 | 25 | it 'should not find stale text after fields were updated (bugfix)' do 26 | match = User.create!(:name => 'Abraham') 27 | no_match = User.create!(:name => 'Elizabath') 28 | match.name = 'Johnny' 29 | match.save! 30 | 31 | user_filter.filter(User, 'Abraham').to_a.should be_empty 32 | user_filter.filter(User, 'Johnny').to_a.should == [match] 33 | end 34 | 35 | it 'should AND multiple words' do 36 | match = User.create!(:name => 'Abraham Lincoln') 37 | no_match = User.create!(:name => 'Abraham') 38 | user_filter.filter(User, 'Abraham Lincoln').to_a.should == [match] 39 | end 40 | 41 | it 'should find records by phrases' do 42 | match = User.create!(:name => 'Abraham Lincoln') 43 | no_match = User.create!(:name => 'Abraham John Lincoln') 44 | user_filter.filter(User, '"Abraham Lincoln"').to_a.should == [match] 45 | end 46 | 47 | it 'should find records by qualified fields' do 48 | match = User.create!(:name => 'foo@bar.com', :email => 'foo@bar.com') 49 | no_match = User.create!(:name => 'foo@bar.com', :email => 'bam@baz.com') 50 | user_filter.filter(User, 'email:foo@bar.com').to_a.should == [match] 51 | end 52 | 53 | it 'should find no records if a nonexistent qualifier is used' do 54 | User.create!(:name => 'someuser', :email => 'foo@bar.com') 55 | user_filter.filter(User, 'nonexistent_qualifier:someuser email:foo@bar.com').to_a.should == [] 56 | end 57 | 58 | it 'should allow phrases as values for qualified field queries' do 59 | match = User.create!(:name => 'Foo Bar', :city => 'Foo Bar') 60 | no_match = User.create!(:name => 'Foo Bar', :city => 'Bar Foo') 61 | user_filter.filter(User, 'city:"Foo Bar"').to_a.should == [match] 62 | end 63 | 64 | it 'should allow to mix multiple types of tokens in a single query' do 65 | match = User.create!(:name => 'Abraham', :city => 'Foohausen') 66 | no_match = User.create!(:name => 'Abraham', :city => 'Barhausen') 67 | user_filter.filter(User, 'Foo city:Foohausen').to_a.should == [match] 68 | end 69 | 70 | it 'should not find records from another model' do 71 | match = User.create!(:name => 'Abraham') 72 | Recipe.create!(:name => 'Abraham') 73 | user_filter.filter(User, 'Abraham').to_a.should == [match] 74 | end 75 | 76 | it 'should find words where one letter is separated from other letters by a period' do 77 | match = User.create!(:name => 'E.ONNNEN') 78 | user_filter.filter(User, 'E.ONNNEN').to_a.should == [match] 79 | end 80 | 81 | it 'should find words where one letter is separated from other letters by a semicolon' do 82 | match = User.create!(:name => 'E;ONNNEN') 83 | user_filter.filter(User, 'E;ONNNEN') 84 | user_filter.filter(User, 'E;ONNNEN').to_a.should == [match] 85 | end 86 | 87 | it 'should distinguish between "Baden" and "Baden-Baden" (bugfix)' do 88 | match = User.create!(:city => 'Baden-Baden') 89 | no_match = User.create!(:city => 'Baden') 90 | user_filter.filter(User, 'Baden-Baden').to_a.should == [match] 91 | end 92 | 93 | it 'should handle umlauts and special characters' do 94 | match = User.create!(:city => 'púlvérìsätëûr') 95 | user_filter.filter(User, 'púlvérìsätëûr').to_a.should == [match] 96 | end 97 | 98 | context 'with excludes' do 99 | 100 | it 'should exclude words with prefix - (minus)' do 101 | match = User.create!(:name => 'Sunny Flower') 102 | no_match = User.create!(:name => 'Sunny Power') 103 | no_match2 = User.create!(:name => 'Absolutly no match') 104 | user_filter.filter(User, 'Sunny -Power').to_a.should == [match] 105 | end 106 | 107 | it 'should exclude phrases with prefix - (minus)' do 108 | match = User.create!(:name => 'Buch Tastatur Schreibtisch') 109 | no_match = User.create!(:name => 'Buch Schreibtisch Tastatur') 110 | no_match2 = User.create!(:name => 'Absolutly no match') 111 | user_filter.filter(User, 'Buch -"Schreibtisch Tastatur"').to_a.should == [match] 112 | end 113 | 114 | it 'should exclude qualified fields with prefix - (minus)' do 115 | match = User.create!(:name => 'Abraham', :city => 'Foohausen') 116 | no_match = User.create!(:name => 'Abraham', :city => 'Barhausen') 117 | no_match2 = User.create!(:name => 'Absolutly no match') 118 | user_filter.filter(User, 'Abraham -city:Barhausen').to_a.should == [match] 119 | end 120 | 121 | it 'should work if the query only contains excluded words' do 122 | match = User.create!(:name => 'Sunny Flower') 123 | no_match = User.create!(:name => 'Sunny Power') 124 | user_filter.filter(User, '-Power').to_a.should == [match] 125 | end 126 | 127 | it 'should work if the query only contains excluded phrases' do 128 | match = User.create!(:name => 'Buch Tastatur Schreibtisch') 129 | no_match = User.create!(:name => 'Buch Schreibtisch Tastatur') 130 | user_filter.filter(User, '-"Schreibtisch Tastatur"').to_a.should == [match] 131 | end 132 | 133 | it 'should work if the query only contains excluded qualified fields' do 134 | match = User.create!(:name => 'Abraham', :city => 'Foohausen') 135 | no_match = User.create!(:name => 'Abraham', :city => 'Barhausen') 136 | user_filter.filter(User, '-city:Barhausen').to_a.should == [match] 137 | end 138 | 139 | it 'respects an existing scope chain when there are only excluded tokens (bugfix)' do 140 | match = User.create!(:name => 'Abraham', :city => 'Foohausen') 141 | no_match = User.create!(:name => 'Abraham', :city => 'Barhausen') 142 | also_no_match = User.create!(:name => 'Bebraham', :city => 'Foohausen') 143 | user_scope = User.scoped(:conditions => { :name => 'Abraham' }) 144 | user_filter.filter(user_scope, '-Barhausen').to_a.should == [match] 145 | end 146 | 147 | it 'should work if there are fields contained in the search that are NULL' do 148 | match = User.create!(:name => 'Sunny Flower', :city => nil, :email => nil) 149 | no_match = User.create!(:name => 'Sunny Power', :city => nil, :email => nil) 150 | no_match2 = User.create!(:name => 'Absolutly no match') 151 | user_filter.filter(User, 'Sunny -Power').to_a.should == [match] 152 | end 153 | 154 | it 'should work if search_by contains a join (bugfix)' do 155 | category1 = Recipe::Category.create!(:name => 'Rice') 156 | category2 = Recipe::Category.create!(:name => 'Barbecue') 157 | match = Recipe.create!(:name => 'Martini Chicken', :category => category1) 158 | no_match = Recipe.create!(:name => 'Barbecue Chicken', :category => category2) 159 | recipe_filter.filter(Recipe, 'Chicken -category:Barbecue').to_a.should == [match] 160 | end 161 | 162 | it 'should work when search_by uses SQL-Regexes which need to be "and"ed together by syntax#build_exclude_scope (bugfix)' do 163 | match = User.create!(:name => 'Sunny Flower', :city => "Flower") 164 | no_match = User.create!(:name => 'Sunny Power', :city => "Power") 165 | user_filter.filter(User, '-name_and_city_regex:Power').to_a.should == [match] 166 | end 167 | 168 | it 'can be filtered twice' do 169 | match = User.create!(:name => 'Sunny Flower', :city => "Flower") 170 | no_match = User.create!(:name => 'Sunny Power', :city => "Power") 171 | also_no_match = User.create!(:name => 'Sunny Forever', :city => "Forever") 172 | first_result = user_filter.filter(User, '-name_and_city_regex:Power') 173 | user_filter.filter(first_result, '-name_and_city_regex:Forever').to_a.should == [match] 174 | end 175 | 176 | end 177 | 178 | context 'when the given query is blank' do 179 | 180 | it 'returns all records' do 181 | match = User.create! 182 | user_filter.filter(User, '').scoped.to_a.should == [match] 183 | end 184 | 185 | it 'respects an existing scope chain' do 186 | match = User.create!(:name => 'Abraham') 187 | no_match = User.create!(:name => 'Elizabath') 188 | scope = User.scoped(:conditions => { :name => 'Abraham' }) 189 | user_filter.filter(scope, '').scoped.to_a.should == [match] 190 | end 191 | 192 | end 193 | 194 | it 'runs filter in the instance context' do 195 | filter_class = Class.new do 196 | include Minidusen::Filter 197 | 198 | def columns 199 | [:name, :email, :city] 200 | end 201 | 202 | filter :text do |scope, phrases| 203 | scope.report_instance(self) 204 | scope.where_like(columns => phrases) 205 | end 206 | end 207 | filter_instance = filter_class.new 208 | 209 | match = User.create!(:name => 'Abraham') 210 | no_match = User.create!(:name => 'Elizabath') 211 | expect(User).to receive(:report_instance).with(filter_instance) 212 | filter_instance.filter(User, 'Abra').to_a.should == [match] 213 | end 214 | 215 | end 216 | 217 | describe '#minidusen_syntax' do 218 | 219 | it "should return the model's syntax definition" do 220 | syntax = UserFilter.send(:minidusen_syntax) 221 | syntax.should be_a(Minidusen::Syntax) 222 | syntax.fields.keys.should =~ ['text', 'email', 'city', 'role', 'name_and_city_regex'] 223 | end 224 | 225 | end 226 | 227 | end 228 | -------------------------------------------------------------------------------- /spec/minidusen/parser_spec.rb: -------------------------------------------------------------------------------- 1 | describe Minidusen::Parser do 2 | 3 | describe '.parse' do 4 | 5 | describe 'when called with a String' do 6 | 7 | it 'parses the given string into tokens' do 8 | query = Minidusen::Parser.parse('fieldname:fieldvalue word "a phrase" "deploy:rollback" -"db:seed"') 9 | query.size.should == 5 10 | query[0].field.should == 'fieldname' 11 | query[0].value.should == 'fieldvalue' 12 | query[0].exclude.should == false 13 | query[1].field.should == 'text' 14 | query[1].value.should == 'word' 15 | query[1].exclude.should == false 16 | query[2].field.should == 'text' 17 | query[2].value.should == 'a phrase' 18 | query[2].exclude.should == false 19 | query[3].field.should == 'text' 20 | query[3].value.should == 'deploy:rollback' 21 | query[3].exclude.should == false 22 | query[4].field.should == 'text' 23 | query[4].value.should == 'db:seed' 24 | query[4].exclude.should == true 25 | end 26 | 27 | it 'should parse field tokens first, because they usually give maximum filtering at little cost' do 28 | query = Minidusen::Parser.parse('word1 field1:field1-value word2 field2:field2-value "search:word"') 29 | query.collect(&:value).should == ['field1-value', 'field2-value', 'word1', 'word2', 'search:word'] 30 | end 31 | 32 | it 'should not consider the dash to be a word boundary' do 33 | query = Minidusen::Parser.parse('Baden-Baden') 34 | query.collect(&:value).should == ['Baden-Baden'] 35 | end 36 | 37 | it 'should parse umlauts and accents' do 38 | query = Minidusen::Parser.parse('field:åöÙÔøüéíÁ "ÄüÊçñÆ ððÿáÒÉ" pulvérisateur pędzić') 39 | query.collect(&:value).should == ['åöÙÔøüéíÁ', 'ÄüÊçñÆ ððÿáÒÉ', 'pulvérisateur', 'pędzić'] 40 | end 41 | 42 | it 'should allow to search for a phrase containing a colon' do 43 | query = Minidusen::Parser.parse('"deploy:rollback"') 44 | query.size.should == 1 45 | query[0].field.should == 'text' 46 | query[0].value.should == 'deploy:rollback' 47 | query[0].exclude.should == false 48 | end 49 | 50 | it 'should parse a field token which is at the beginning of the search string' do 51 | query = Minidusen::Parser.parse('filetype:pdf word') 52 | query.size.should == 2 53 | query[0].field.should == 'filetype' 54 | query[0].value.should == 'pdf' 55 | query[0].exclude.should == false 56 | end 57 | 58 | it 'should parse an excluded field token which is at the beginning of the search string' do 59 | query = Minidusen::Parser.parse('-filetype:docx word') 60 | query.size.should == 2 61 | query[0].field.should == 'filetype' 62 | query[0].value.should == 'docx' 63 | query[0].exclude.should == true 64 | end 65 | 66 | it 'only parses single colons as fields' do 67 | query = Minidusen::Parser.parse('filetype:docx Namespaced::Klass') 68 | expect(query.size).to eq(2) 69 | expect(query[0].field).to eq('filetype') 70 | expect(query[0].value).to eq('docx') 71 | expect(query[1].field).to eq('text') 72 | expect(query[1].value).to eq('Namespaced::Klass') 73 | end 74 | 75 | it 'should ignore invalid utf-8 byte sequences' do 76 | term_with_invalid_byte_sequence = "word\255".force_encoding('UTF-8') 77 | 78 | query = Minidusen::Parser.parse(term_with_invalid_byte_sequence) 79 | query.size.should == 1 80 | query[0].value.should == 'word' 81 | end 82 | 83 | end 84 | 85 | describe 'when called with a Query' do 86 | 87 | it 'returns the query' do 88 | passed_query = Minidusen::Query.new 89 | parsed_query = Minidusen::Parser.parse(passed_query) 90 | parsed_query.should == passed_query 91 | end 92 | 93 | end 94 | 95 | describe 'when called with an array of strings' do 96 | 97 | it 'returns a query of text tokens' do 98 | query = Minidusen::Parser.parse(['word', 'a phrase']) 99 | query.size.should == 2 100 | query[0].field.should == 'text' 101 | query[0].value.should == 'word' 102 | query[1].field.should == 'text' 103 | query[1].value.should == 'a phrase' 104 | end 105 | 106 | end 107 | 108 | end 109 | 110 | end 111 | -------------------------------------------------------------------------------- /spec/minidusen/query_spec.rb: -------------------------------------------------------------------------------- 1 | describe Minidusen::Query do 2 | 3 | describe '#condensed' do 4 | 5 | it 'should return a version of the query where all text tokens have been collapsed into a single token with an Array value' do 6 | query = Minidusen::Parser.parse('field:value foo bar baz') 7 | query.tokens.size.should == 4 8 | condensed_query = query.condensed 9 | condensed_query.tokens.size.should == 2 10 | condensed_query[0].field.should == 'field' 11 | condensed_query[0].value.should == 'value' 12 | condensed_query[1].field.should == 'text' 13 | condensed_query[1].value.should == ['foo', 'bar', 'baz'] 14 | end 15 | 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /spec/minidusen/util_spec.rb: -------------------------------------------------------------------------------- 1 | describe Minidusen::Util do 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $: << File.join(File.dirname(__FILE__), "/../../lib" ) 2 | 3 | require 'minidusen' 4 | require 'byebug' 5 | require 'gemika' 6 | 7 | if ActiveRecord::VERSION::MAJOR >= 7 8 | ActiveRecord.default_timezone = :local 9 | else 10 | ActiveRecord::Base.default_timezone = :local 11 | end 12 | 13 | Dir["#{File.dirname(__FILE__)}/support/*.rb"].sort.each {|f| require f} 14 | Dir["#{File.dirname(__FILE__)}/shared_examples/*.rb"].sort.each {|f| require f} 15 | 16 | Gemika::RSpec.configure_clean_database_before_example 17 | 18 | Gemika::RSpec.configure do |config| 19 | config.expect_with(:rspec) { |c| c.syntax = [:should, :expect] } 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/database.github.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | database: minidusen_test 3 | username: root 4 | password: password 5 | host: 127.0.0.1 6 | port: 3306 7 | 8 | postgresql: 9 | database: minidusen_test 10 | host: localhost 11 | username: postgres 12 | password: postgres 13 | port: 5432 14 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | Gemika::Database.new.rewrite_schema! do 2 | 3 | create_table :users do |t| 4 | t.string :name 5 | t.string :email 6 | t.string :city 7 | end 8 | 9 | create_table :recipes do |t| 10 | t.string :name 11 | t.integer :category_id 12 | end 13 | 14 | create_table :recipe_ingredients do |t| 15 | t.string :name 16 | t.integer :recipe_id 17 | end 18 | 19 | create_table :recipe_categories do |t| 20 | t.string :name 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/database.sample.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | database: minidusen_test 3 | host: localhost 4 | username: root 5 | password: secret 6 | 7 | postgresql: 8 | database: minidusen_test 9 | user: 10 | password: 11 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | 3 | end 4 | 5 | 6 | class UserFilter 7 | include Minidusen::Filter 8 | 9 | filter :text do |scope, phrases| 10 | scope.where_like([:name, :email, :city] => phrases) 11 | end 12 | 13 | filter :city do |scope, city| 14 | # scope.scoped(:conditions => { :city => city }) 15 | # scope.scoped(:conditions => ['city = ?', city]) #:conditions => { :city => city }) 16 | scope.scoped(:conditions => { :city => city }) 17 | end 18 | 19 | filter :email do |scope, email| 20 | scope.scoped(:conditions => { :email => email }) 21 | end 22 | 23 | filter :role do |scope, role| 24 | scope.scoped(:conditions => { :role => role }) 25 | end 26 | 27 | filter :name_and_city_regex do |scope, regex| 28 | # Example for regexes that need to be and'ed together by syntax#build_exclude_scope 29 | regexp_operator = Minidusen::Util.regexp_operator(scope) 30 | first = scope.where("users.name #{regexp_operator} ?", regex) 31 | second = scope.where("users.city #{regexp_operator} ?", regex) 32 | first.merge(second) 33 | end 34 | 35 | end 36 | 37 | 38 | class Recipe < ActiveRecord::Base 39 | 40 | validates_presence_of :name 41 | 42 | has_many :ingredients, :class_name => 'Recipe::Ingredient', :inverse_of => :recipe 43 | belongs_to :category, :class_name => 'Recipe::Category', :inverse_of => :recipes 44 | 45 | end 46 | 47 | 48 | class RecipeFilter 49 | include Minidusen::Filter 50 | 51 | filter :text do |scope, text| 52 | scope.where_like(:name => text) 53 | end 54 | 55 | filter :category do |scope, category_name| 56 | scope.joins(:category).where('recipe_categories.name = ?', category_name) 57 | end 58 | 59 | end 60 | 61 | 62 | class Recipe::Category < ActiveRecord::Base 63 | 64 | self.table_name = 'recipe_categories' 65 | 66 | validates_presence_of :name 67 | 68 | has_many :recipes, :inverse_of => :category 69 | 70 | end 71 | 72 | 73 | class Recipe::Ingredient < ActiveRecord::Base 74 | 75 | self.table_name = 'recipe_ingredients' 76 | 77 | validates_presence_of :name 78 | 79 | belongs_to :recipe, :inverse_of => :ingredients 80 | 81 | end 82 | --------------------------------------------------------------------------------