pursuit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +26 -0
- data/.rbenv-gemsets +1 -0
- data/.rspec +2 -0
- data/.rubocop.yml +21 -0
- data/.ruby-version +1 -0
- data/.travis.yml +24 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +161 -0
- data/Guardfile +17 -0
- data/LICENSE +21 -0
- data/README.md +39 -0
- data/Rakefile +8 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/config.ru +9 -0
- data/lib/pursuit/active_record_dsl.rb +32 -0
- data/lib/pursuit/active_record_search.rb +165 -0
- data/lib/pursuit/constants.rb +7 -0
- data/lib/pursuit/railtie.rb +13 -0
- data/lib/pursuit/term_parser.rb +48 -0
- data/lib/pursuit.rb +6 -0
- data/pursuit.gemspec +36 -0
- data/spec/internal/app/models/product.rb +11 -0
- data/spec/internal/app/models/product_variation.rb +12 -0
- data/spec/internal/config/database.yml +3 -0
- data/spec/internal/db/schema.rb +26 -0
- data/spec/internal/log/.keep +0 -0
- data/spec/pursuit/active_record_dsl_spec.rb +7 -0
- data/spec/pursuit/active_record_search_spec.rb +442 -0
- data/spec/pursuit/constants_spec.rb +11 -0
- data/spec/pursuit/term_parser_spec.rb +32 -0
- data/spec/spec_helper.rb +95 -0
- data/travis/gemfiles/5.2.gemfile +8 -0
- data/travis/gemfiles/6.0.gemfile +8 -0
- data/travis/gemfiles/6.1.gemfile +8 -0
- metadata +255 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 4e63f3c017580251d11f1db0994e4e771f9689e63ed2ae8f4c1c98e14ba27e0b
|
4
|
+
data.tar.gz: 7956f93f4eea40de80878022796d823b57cbe95da8472a1784ab864fb6b17165
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7cfd6c4114d8e02181a8e74a8865f862cfbc904b56a64819b6993a788bf83f847b0859037baf7f43fa3425acb5b33e937875dfffbb0e57abe7dcb094c78e8dde
|
7
|
+
data.tar.gz: ca46b2fd3b10167df8b74419bd1e41ee200a943b3d73ce5842b52429f89b69fd111ab884b6941d5a84a402ecebab5831941838b10eee0454cb8df8d27c4caca0
|
data/.gitignore
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# OS Files
|
2
|
+
.DS_Store
|
3
|
+
.Trashes
|
4
|
+
ehthumbs.db
|
5
|
+
Thumbs.db
|
6
|
+
|
7
|
+
# Bundler
|
8
|
+
/.bundle
|
9
|
+
/vendor/bundle
|
10
|
+
|
11
|
+
# RSpec
|
12
|
+
/spec/examples.txt
|
13
|
+
|
14
|
+
# SQLite Database
|
15
|
+
/spec/internal/db/*.sqlite3
|
16
|
+
/spec/internal/db/*.sqlite3-journal
|
17
|
+
|
18
|
+
# Log & Temporary Files
|
19
|
+
/spec/internal/log/*
|
20
|
+
/spec/internal/tmp/*
|
21
|
+
!/spec/internal/log/.keep
|
22
|
+
!/spec/internal/tmp/.keep
|
23
|
+
|
24
|
+
# Environment Files
|
25
|
+
.env
|
26
|
+
.env.*
|
data/.rbenv-gemsets
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
pursuit
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
AllCops:
|
2
|
+
Exclude:
|
3
|
+
- 'bin/**/*'
|
4
|
+
|
5
|
+
Metrics/AbcSize:
|
6
|
+
Max: 30
|
7
|
+
|
8
|
+
Metrics/LineLength:
|
9
|
+
Max: 120
|
10
|
+
|
11
|
+
Metrics/BlockLength:
|
12
|
+
Enabled: false
|
13
|
+
|
14
|
+
Metrics/ClassLength:
|
15
|
+
Enabled: false
|
16
|
+
|
17
|
+
Metrics/MethodLength:
|
18
|
+
Enabled: false
|
19
|
+
|
20
|
+
Style/Documentation:
|
21
|
+
Enabled: false
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.0.2
|
data/.travis.yml
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
language: ruby
|
2
|
+
rvm:
|
3
|
+
- 3.0.2
|
4
|
+
gemfile:
|
5
|
+
- travis/gemfiles/5.2.gemfile
|
6
|
+
- travis/gemfiles/6.0.gemfile
|
7
|
+
- travis/gemfiles/6.1.gemfile
|
8
|
+
services:
|
9
|
+
- postgresql
|
10
|
+
before_install:
|
11
|
+
- gem update --system
|
12
|
+
- gem install bundler
|
13
|
+
before_script:
|
14
|
+
- psql -c 'CREATE DATABASE pursuit_test;' -U postgres
|
15
|
+
addons:
|
16
|
+
postgresql: 14
|
17
|
+
apt:
|
18
|
+
packages:
|
19
|
+
- postgresql-14
|
20
|
+
- postgresql-client-14
|
21
|
+
env:
|
22
|
+
global:
|
23
|
+
- DATABASE_URL="postgresql://127.0.0.1:5432/pursuit_test"
|
24
|
+
- RSPEC_DEFAULT_FORMATTER=doc
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
pursuit (0.1.0)
|
5
|
+
activerecord (>= 5.2.0, < 6.1.0)
|
6
|
+
activesupport (>= 5.2.0, < 6.1.0)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
actionpack (6.0.4.1)
|
12
|
+
actionview (= 6.0.4.1)
|
13
|
+
activesupport (= 6.0.4.1)
|
14
|
+
rack (~> 2.0, >= 2.0.8)
|
15
|
+
rack-test (>= 0.6.3)
|
16
|
+
rails-dom-testing (~> 2.0)
|
17
|
+
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
18
|
+
actionview (6.0.4.1)
|
19
|
+
activesupport (= 6.0.4.1)
|
20
|
+
builder (~> 3.1)
|
21
|
+
erubi (~> 1.4)
|
22
|
+
rails-dom-testing (~> 2.0)
|
23
|
+
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
24
|
+
activemodel (6.0.4.1)
|
25
|
+
activesupport (= 6.0.4.1)
|
26
|
+
activerecord (6.0.4.1)
|
27
|
+
activemodel (= 6.0.4.1)
|
28
|
+
activesupport (= 6.0.4.1)
|
29
|
+
activesupport (6.0.4.1)
|
30
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
31
|
+
i18n (>= 0.7, < 2)
|
32
|
+
minitest (~> 5.1)
|
33
|
+
tzinfo (~> 1.1)
|
34
|
+
zeitwerk (~> 2.2, >= 2.2.2)
|
35
|
+
ast (2.4.2)
|
36
|
+
builder (3.2.4)
|
37
|
+
coderay (1.1.3)
|
38
|
+
combustion (1.3.3)
|
39
|
+
activesupport (>= 3.0.0)
|
40
|
+
railties (>= 3.0.0)
|
41
|
+
thor (>= 0.14.6)
|
42
|
+
concurrent-ruby (1.1.9)
|
43
|
+
crass (1.0.6)
|
44
|
+
diff-lcs (1.4.4)
|
45
|
+
erubi (1.10.0)
|
46
|
+
ffi (1.15.4)
|
47
|
+
formatador (0.3.0)
|
48
|
+
guard (2.18.0)
|
49
|
+
formatador (>= 0.2.4)
|
50
|
+
listen (>= 2.7, < 4.0)
|
51
|
+
lumberjack (>= 1.0.12, < 2.0)
|
52
|
+
nenv (~> 0.1)
|
53
|
+
notiffany (~> 0.0)
|
54
|
+
pry (>= 0.13.0)
|
55
|
+
shellany (~> 0.0)
|
56
|
+
thor (>= 0.18.1)
|
57
|
+
guard-compat (1.2.1)
|
58
|
+
guard-rspec (4.7.3)
|
59
|
+
guard (~> 2.1)
|
60
|
+
guard-compat (~> 1.1)
|
61
|
+
rspec (>= 2.99.0, < 4.0)
|
62
|
+
i18n (1.8.11)
|
63
|
+
concurrent-ruby (~> 1.0)
|
64
|
+
jaro_winkler (1.5.4)
|
65
|
+
listen (3.7.0)
|
66
|
+
rb-fsevent (~> 0.10, >= 0.10.3)
|
67
|
+
rb-inotify (~> 0.9, >= 0.9.10)
|
68
|
+
loofah (2.12.0)
|
69
|
+
crass (~> 1.0.2)
|
70
|
+
nokogiri (>= 1.5.9)
|
71
|
+
lumberjack (1.2.8)
|
72
|
+
method_source (1.0.0)
|
73
|
+
minitest (5.14.4)
|
74
|
+
nenv (0.3.0)
|
75
|
+
nokogiri (1.12.5-x86_64-darwin)
|
76
|
+
racc (~> 1.4)
|
77
|
+
notiffany (0.1.3)
|
78
|
+
nenv (~> 0.1)
|
79
|
+
shellany (~> 0.0)
|
80
|
+
parallel (1.21.0)
|
81
|
+
parser (3.0.2.0)
|
82
|
+
ast (~> 2.4.1)
|
83
|
+
pry (0.14.1)
|
84
|
+
coderay (~> 1.1)
|
85
|
+
method_source (~> 1.0)
|
86
|
+
racc (1.6.0)
|
87
|
+
rack (2.2.3)
|
88
|
+
rack-test (1.1.0)
|
89
|
+
rack (>= 1.0, < 3)
|
90
|
+
rails-dom-testing (2.0.3)
|
91
|
+
activesupport (>= 4.2.0)
|
92
|
+
nokogiri (>= 1.6)
|
93
|
+
rails-html-sanitizer (1.4.2)
|
94
|
+
loofah (~> 2.3)
|
95
|
+
railties (6.0.4.1)
|
96
|
+
actionpack (= 6.0.4.1)
|
97
|
+
activesupport (= 6.0.4.1)
|
98
|
+
method_source
|
99
|
+
rake (>= 0.8.7)
|
100
|
+
thor (>= 0.20.3, < 2.0)
|
101
|
+
rainbow (3.0.0)
|
102
|
+
rake (13.0.6)
|
103
|
+
rb-fsevent (0.11.0)
|
104
|
+
rb-inotify (0.10.1)
|
105
|
+
ffi (~> 1.0)
|
106
|
+
rspec (3.9.0)
|
107
|
+
rspec-core (~> 3.9.0)
|
108
|
+
rspec-expectations (~> 3.9.0)
|
109
|
+
rspec-mocks (~> 3.9.0)
|
110
|
+
rspec-core (3.9.3)
|
111
|
+
rspec-support (~> 3.9.3)
|
112
|
+
rspec-expectations (3.9.4)
|
113
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
114
|
+
rspec-support (~> 3.9.0)
|
115
|
+
rspec-mocks (3.9.1)
|
116
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
117
|
+
rspec-support (~> 3.9.0)
|
118
|
+
rspec-rails (3.9.1)
|
119
|
+
actionpack (>= 3.0)
|
120
|
+
activesupport (>= 3.0)
|
121
|
+
railties (>= 3.0)
|
122
|
+
rspec-core (~> 3.9.0)
|
123
|
+
rspec-expectations (~> 3.9.0)
|
124
|
+
rspec-mocks (~> 3.9.0)
|
125
|
+
rspec-support (~> 3.9.0)
|
126
|
+
rspec-support (3.9.4)
|
127
|
+
rubocop (0.77.0)
|
128
|
+
jaro_winkler (~> 1.5.1)
|
129
|
+
parallel (~> 1.10)
|
130
|
+
parser (>= 2.6)
|
131
|
+
rainbow (>= 2.2.2, < 4.0)
|
132
|
+
ruby-progressbar (~> 1.7)
|
133
|
+
unicode-display_width (>= 1.4.0, < 1.7)
|
134
|
+
ruby-progressbar (1.11.0)
|
135
|
+
shellany (0.0.1)
|
136
|
+
sqlite3 (1.4.2)
|
137
|
+
thor (1.1.0)
|
138
|
+
thread_safe (0.3.6)
|
139
|
+
tzinfo (1.2.9)
|
140
|
+
thread_safe (~> 0.1)
|
141
|
+
unicode-display_width (1.6.1)
|
142
|
+
yard (0.9.26)
|
143
|
+
zeitwerk (2.5.1)
|
144
|
+
|
145
|
+
PLATFORMS
|
146
|
+
x86_64-darwin-21
|
147
|
+
|
148
|
+
DEPENDENCIES
|
149
|
+
bundler (~> 2.0)
|
150
|
+
combustion (~> 1.1)
|
151
|
+
guard-rspec (~> 4.7)
|
152
|
+
pursuit!
|
153
|
+
rake (~> 13.0)
|
154
|
+
rspec (~> 3.8)
|
155
|
+
rspec-rails (~> 3.8)
|
156
|
+
rubocop (~> 0.77.0)
|
157
|
+
sqlite3 (~> 1.4)
|
158
|
+
yard (~> 0.9.20)
|
159
|
+
|
160
|
+
BUNDLED WITH
|
161
|
+
2.2.22
|
data/Guardfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
guard :rspec, cmd: 'bundle exec rspec' do
|
4
|
+
require 'guard/rspec/dsl'
|
5
|
+
|
6
|
+
dsl = Guard::RSpec::Dsl.new(self)
|
7
|
+
|
8
|
+
# RSpec files
|
9
|
+
rspec = dsl.rspec
|
10
|
+
watch(rspec.spec_helper) { rspec.spec_dir }
|
11
|
+
watch(rspec.spec_support) { rspec.spec_dir }
|
12
|
+
watch(rspec.spec_files)
|
13
|
+
|
14
|
+
# Ruby files
|
15
|
+
ruby = dsl.ruby
|
16
|
+
dsl.watch_spec_files_for(ruby.lib_files)
|
17
|
+
end
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2019 Nialto Services
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# Pursuit
|
2
|
+
|
3
|
+
Advanced key-based searching for ActiveRecord objects.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
You can install **Pursuit** using the following command:
|
8
|
+
|
9
|
+
$ gem install pursuit
|
10
|
+
|
11
|
+
Or, by adding the following to your `Gemfile`:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'pursuit'
|
15
|
+
```
|
16
|
+
|
17
|
+
### Usage
|
18
|
+
|
19
|
+
You can use the convenient DSL syntax to declare which attributes and relationships are searchable:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class Product < ActiveRecord::Base
|
23
|
+
has_search relationships: { variations: %i[title] },
|
24
|
+
keyed_attributes: %i[title description rating],
|
25
|
+
unkeyed_attributes: %i[title description]
|
26
|
+
end
|
27
|
+
```
|
28
|
+
|
29
|
+
This creates a ```.search``` method on your record class which accepts a single query argument:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
Product.search('plain shirt rating>=3')
|
33
|
+
```
|
34
|
+
|
35
|
+
## Development
|
36
|
+
|
37
|
+
After checking out the repo, run `bundle exec rake spec` to run the tests.
|
38
|
+
|
39
|
+
To install this gem onto your machine, run `bundle exec rake install`.
|
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/config.ru
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Provides a DSL for the `ActiveRecord::Base` class.
|
5
|
+
#
|
6
|
+
module ActiveRecordDSL
|
7
|
+
def self.included(base)
|
8
|
+
base.extend ClassMethods
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def has_search(relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
|
13
|
+
raise 'The #search method has already been defined.' if respond_to?(:search)
|
14
|
+
|
15
|
+
# The value of `self` is a constant for the current `ActiveRecord::Base` subclass. We'll need to capture this
|
16
|
+
# in a custom variable to make it accessible from within the #define_method block.
|
17
|
+
klass = self
|
18
|
+
|
19
|
+
define_method(:search) do |query|
|
20
|
+
search = Pursuit::ActiveRecordSearch.new(
|
21
|
+
klass,
|
22
|
+
relationships: relationships,
|
23
|
+
keyed_attributes: keyed_attributes,
|
24
|
+
unkeyed_attributes: unkeyed_attributes
|
25
|
+
)
|
26
|
+
|
27
|
+
search.search(query)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class ActiveRecordSearch
|
5
|
+
# @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class being searched.
|
6
|
+
#
|
7
|
+
attr_reader :klass
|
8
|
+
|
9
|
+
# @return [Hash<Symbol, Array<Symbol>>] The attribute names for relationships which can be queried with a keyed
|
10
|
+
# term and the attributes in the relative's table which should be searched.
|
11
|
+
#
|
12
|
+
attr_accessor :relationships
|
13
|
+
|
14
|
+
# @return [Array<Symbol>] The attribute names which can be queried with a keyed term (e.g. 'last_name:*herb').
|
15
|
+
#
|
16
|
+
attr_accessor :keyed_attributes
|
17
|
+
|
18
|
+
# @return [Array<Symbol>] The attribute names which can be queried with an unkeyed term (e.g. 'herb').
|
19
|
+
#
|
20
|
+
attr_accessor :unkeyed_attributes
|
21
|
+
|
22
|
+
# Create a new instance to search a specific ActiveRecord record class.
|
23
|
+
#
|
24
|
+
# @param klass [Class<ActiveRecord::Base>]
|
25
|
+
# @param relationships [Hash<Symbol, Array<Symbol>>]
|
26
|
+
# @param keyed_attributes [Array<Symbol>]
|
27
|
+
# @param unkeyed_attributes [Array<Symbol>]
|
28
|
+
#
|
29
|
+
def initialize(klass, relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
|
30
|
+
@klass = klass
|
31
|
+
@relationships = relationships
|
32
|
+
@keyed_attributes = keyed_attributes
|
33
|
+
@unkeyed_attributes = unkeyed_attributes
|
34
|
+
end
|
35
|
+
|
36
|
+
# Search the record with the specified query.
|
37
|
+
#
|
38
|
+
# @param query [String] The query to transform into a SQL search.
|
39
|
+
# @return [ActiveRecord::Relation] The search results.
|
40
|
+
#
|
41
|
+
def search(query)
|
42
|
+
klass.where(build_arel(query))
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def build_arel(query)
|
48
|
+
parser = TermParser.new(query, keys: relationships.keys + keyed_attributes)
|
49
|
+
unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
|
50
|
+
keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)
|
51
|
+
|
52
|
+
if unkeyed_arel && keyed_arel
|
53
|
+
unkeyed_arel.and(keyed_arel)
|
54
|
+
else
|
55
|
+
unkeyed_arel || keyed_arel
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def build_arel_for_unkeyed_term(value)
|
60
|
+
return nil if value.blank?
|
61
|
+
|
62
|
+
sanitized_value = "%#{klass.sanitize_sql_like(value)}%"
|
63
|
+
unkeyed_attributes.reduce(nil) do |chain, attribute_name|
|
64
|
+
node = klass.arel_table[attribute_name].matches(sanitized_value)
|
65
|
+
chain ? chain.or(node) : node
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def build_arel_for_keyed_terms(terms)
|
70
|
+
return nil if terms.blank?
|
71
|
+
|
72
|
+
terms.reduce(nil) do |chain, term|
|
73
|
+
reflection = klass.reflections[term.key]
|
74
|
+
node = if reflection.present?
|
75
|
+
keys = relationships[term.key.to_sym].presence || []
|
76
|
+
build_arel_for_reflection(reflection, keys, term.operator, term.value)
|
77
|
+
else
|
78
|
+
build_arel_for_attribute(klass.arel_table[term.key], term.operator, term.value)
|
79
|
+
end
|
80
|
+
|
81
|
+
chain ? chain.and(node) : node
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def build_arel_for_attribute(attribute, operator, value)
|
86
|
+
sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
|
87
|
+
|
88
|
+
case operator
|
89
|
+
when '>' then attribute.gt(sanitized_value)
|
90
|
+
when '>=' then attribute.gteq(sanitized_value)
|
91
|
+
when '<' then attribute.lt(sanitized_value)
|
92
|
+
when '<=' then attribute.lteq(sanitized_value)
|
93
|
+
when '*=' then attribute.matches("%#{sanitized_value}%")
|
94
|
+
when '!*=' then attribute.does_not_match("%#{sanitized_value}%")
|
95
|
+
when '!=' then attribute.not_eq(sanitized_value)
|
96
|
+
when '=='
|
97
|
+
if value.present?
|
98
|
+
attribute.eq(sanitized_value)
|
99
|
+
else
|
100
|
+
attribute.eq(nil).or(attribute.eq(''))
|
101
|
+
end
|
102
|
+
else
|
103
|
+
raise "The operator '#{operator}' is not supported."
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def build_arel_for_reflection(reflection, relation_attributes, operator, value)
|
108
|
+
nodes = build_arel_for_reflection_join(reflection)
|
109
|
+
count_nodes = build_arel_for_relation_count(nodes, operator, value)
|
110
|
+
return count_nodes if count_nodes.present?
|
111
|
+
|
112
|
+
match_nodes = relation_attributes.reduce(nil) do |chain, attribute_name|
|
113
|
+
node = build_arel_for_attribute(reflection.klass.arel_table[attribute_name], operator, value)
|
114
|
+
chain ? chain.or(node) : node
|
115
|
+
end
|
116
|
+
|
117
|
+
return nil if match_nodes.blank?
|
118
|
+
|
119
|
+
nodes.where(match_nodes).project(1).exists
|
120
|
+
end
|
121
|
+
|
122
|
+
def build_arel_for_reflection_join(reflection)
|
123
|
+
reflection_table = reflection.klass.arel_table
|
124
|
+
reflection_through = reflection.through_reflection
|
125
|
+
|
126
|
+
if reflection_through.present?
|
127
|
+
# :has_one through / :has_many through
|
128
|
+
reflection_through_table = reflection_through.klass.arel_table
|
129
|
+
reflection_table.join(reflection_through_table).on(
|
130
|
+
reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
|
131
|
+
).where(
|
132
|
+
reflection_through_table[reflection_through.foreign_key].eq(klass.arel_table[klass.primary_key])
|
133
|
+
)
|
134
|
+
else
|
135
|
+
# :has_one / :has_many
|
136
|
+
reflection_table.where(
|
137
|
+
reflection_table[reflection.foreign_key].eq(klass.arel_table[klass.primary_key])
|
138
|
+
)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def build_arel_for_relation_count(nodes, operator, value)
|
143
|
+
build = proc do |klass|
|
144
|
+
count = ActiveRecord::Base.sanitize_sql_like(value).to_i
|
145
|
+
klass.new(nodes.project(Arel.star.count), count)
|
146
|
+
end
|
147
|
+
|
148
|
+
case operator
|
149
|
+
when '>' then build.call(Arel::Nodes::GreaterThan)
|
150
|
+
when '>=' then build.call(Arel::Nodes::GreaterThanOrEqual)
|
151
|
+
when '<' then build.call(Arel::Nodes::LessThan)
|
152
|
+
when '<=' then build.call(Arel::Nodes::LessThanOrEqual)
|
153
|
+
else
|
154
|
+
return nil unless value =~ /^([0-9]+)$/
|
155
|
+
|
156
|
+
case operator
|
157
|
+
when '==' then build.call(Arel::Nodes::Equality)
|
158
|
+
when '!=' then build.call(Arel::Nodes::NotEqual)
|
159
|
+
when '*=' then build.call(Arel::Nodes::Matches)
|
160
|
+
when '!*=' then build.call(Arel::Nodes::DoesNotMatch)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class Railtie < Rails::Railtie
|
5
|
+
initializer 'pursuit.active_record.inject_dsl' do
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
7
|
+
require 'pursuit/active_record_dsl'
|
8
|
+
|
9
|
+
ActiveRecord::Base.include(Pursuit::ActiveRecordDSL)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class TermParser
|
5
|
+
# @return [Struct] Represents a single keyed term extracted from a query.
|
6
|
+
#
|
7
|
+
KeyedTerm = Struct.new(:key, :operator, :value)
|
8
|
+
|
9
|
+
# @return [Array<Pursuit::TermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
|
10
|
+
#
|
11
|
+
attr_reader :keyed_terms
|
12
|
+
|
13
|
+
# @return [String] The unkeyed term.
|
14
|
+
#
|
15
|
+
attr_reader :unkeyed_term
|
16
|
+
|
17
|
+
# Create a new `TermParser` by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
|
18
|
+
#
|
19
|
+
# @param query [String] The query to parse.
|
20
|
+
# @param keys [Array<Symbol>] The keys which are permitted for use as keyed terms.
|
21
|
+
#
|
22
|
+
def initialize(query, keys: [])
|
23
|
+
keys = keys.map(&:to_s)
|
24
|
+
|
25
|
+
@keyed_terms = []
|
26
|
+
@unkeyed_term = query.gsub(/(\s+)?(\w+)(==|\*=|!=|!\*=|<=|>=|<|>)("([^"]+)?"|'([^']+)?'|[^\s]+)(\s+)?/) do |term|
|
27
|
+
key = Regexp.last_match(2)
|
28
|
+
next term unless keys.include?(key)
|
29
|
+
|
30
|
+
operator = Regexp.last_match(3)
|
31
|
+
value = Regexp.last_match(4)
|
32
|
+
value = value[1..-2] if value =~ /^(".*"|'.*')$/
|
33
|
+
|
34
|
+
@keyed_terms << KeyedTerm.new(key, operator, value)
|
35
|
+
|
36
|
+
# Both the starting and ending spaces surrounding the keyed term can be removed, so in this case we'll need to
|
37
|
+
# replace with a single space to ensure the unkeyed term's words are separated correctly.
|
38
|
+
if term =~ /^\s.*\s$/
|
39
|
+
' '
|
40
|
+
else
|
41
|
+
''
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
@unkeyed_term = @unkeyed_term.strip
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|