├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | [](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 | 
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 |
138 |
--------------------------------------------------------------------------------
/media/logo.dark.text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
108 |
--------------------------------------------------------------------------------
/media/logo.light.shapes.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
137 |
--------------------------------------------------------------------------------
/media/logo.light.text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
107 |
--------------------------------------------------------------------------------
/media/makandra-with-bottom-margin.dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/media/makandra-with-bottom-margin.light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------