search_lingo 1.0.0.beta3 → 1.0.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 +4 -4
- data/README.md +184 -115
- data/lib/search_lingo/abstract_search.rb +5 -1
- data/lib/search_lingo/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 44d740566f9aa77034514ebcfa50c3233527ac24
|
4
|
+
data.tar.gz: 5cf05354fa6244b95e971062233830e1e6f68d6d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f1629acbe2b30ee2d2355ce615ab92b497921dcdfcaf20018075818ba96bde07ff7e62a013cc28befa4e455e1e70f7544104ea23ddfc7dfdabb162db8ab96c8e
|
7
|
+
data.tar.gz: fc856b51c4bdba1a5ec41eafdf77c2401cc36ea4dc814d34131c70f65134b9c2e4000a0efd22d66bfdeba49e439404044f422d1c2375c547b863e948ec600180
|
data/README.md
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# [](http://badge.fury.io/rb/search_lingo)
|
2
|
+
|
1
3
|
# SearchLingo
|
2
4
|
|
3
5
|
SearchLingo is a framework for defining simple, user-friendly query languages
|
@@ -11,10 +13,7 @@ of searching.
|
|
11
13
|
The way the searches themselves are performed lies outside the scope of this
|
12
14
|
project. Although originally designed to work with basic searching with
|
13
15
|
ActiveRecord models, it should be usable with other data stores provided they
|
14
|
-
let you
|
15
|
-
|
16
|
-
Be advised this software is still in beta release, and some of the internals
|
17
|
-
are still subject to significant change.
|
16
|
+
let you build complex queries by chaining together simpler queries.
|
18
17
|
|
19
18
|
## Installation
|
20
19
|
|
@@ -36,56 +35,89 @@ Or install it yourself as:
|
|
36
35
|
|
37
36
|
Here is a simple example.
|
38
37
|
|
39
|
-
|
40
|
-
|
38
|
+
```ruby
|
39
|
+
class Task < ActiveRecord::Base
|
40
|
+
end
|
41
41
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
42
|
+
class TaskSearch < SearchLingo::AbstractSearch
|
43
|
+
def default_parse(token)
|
44
|
+
[:where, 'tasks.name LIKE ?', "%#{token}%"]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
TaskSearch.new('foo bar', Task).results
|
49
|
+
# => Task.where('tasks.name LIKE ?', '%foo%')
|
50
|
+
# -> .where('tasks.name LIKE ?', '%bar%')
|
47
51
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
# => Task.where('tasks.name LIKE ?', '%foo bar%')
|
52
|
+
TaskSearch.new('"foo bar"', Task).results
|
53
|
+
# => Task.where('tasks.name LIKE ?', '%foo bar%')
|
54
|
+
```
|
52
55
|
|
53
56
|
And here is a more complex example.
|
54
57
|
|
55
|
-
|
56
|
-
|
58
|
+
```ruby
|
59
|
+
class User < ActiveRecord::Base
|
60
|
+
has_many :tasks
|
61
|
+
end
|
62
|
+
|
63
|
+
class Category < ActiveRecord::Base
|
64
|
+
has_many :tasks
|
65
|
+
end
|
66
|
+
|
67
|
+
class Task < ActiveRecord::Base
|
68
|
+
belongs_to :category
|
69
|
+
belongs_to :user
|
70
|
+
enum state: [:incomplete, :complete]
|
71
|
+
end
|
72
|
+
|
73
|
+
class TaskSearch < SearchLingo::AbstractSearch
|
74
|
+
parser do |token|
|
75
|
+
token.match /\Acategory:\s*"?(.*?)"?\z/ do |m|
|
76
|
+
[:where, { categories: { name: m[1] } }]
|
57
77
|
end
|
78
|
+
end
|
58
79
|
|
59
|
-
|
60
|
-
|
80
|
+
parser do |token|
|
81
|
+
token.match /\Ais:\s*(?<state>(?:in)?complete)\z/ do |m|
|
82
|
+
[m[:state].to_sym]
|
61
83
|
end
|
84
|
+
end
|
62
85
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
[:where, { categories: { name: m[1] } }]
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
def default_parse(token)
|
71
|
-
[:where, 'tasks.name LIKE ?', "%#{token}%"]
|
72
|
-
end
|
73
|
-
|
74
|
-
def scope
|
75
|
-
@scope.includes(:category).references(:category)
|
76
|
-
end
|
86
|
+
parser do |token|
|
87
|
+
token.match /\A([<>])([[:digit:]]+)\z/ do |m|
|
88
|
+
[:where, 'tasks.priority #{m[1]} ?', m[2]]
|
77
89
|
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def default_parse(token)
|
93
|
+
[:where, 'tasks.name LIKE ?', "%#{token}%"]
|
94
|
+
end
|
95
|
+
|
96
|
+
def scope
|
97
|
+
@scope.includes(:category).references(:category)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
TaskSearch.new('category: "foo bar" <2 baz is: incomplete', Task).results
|
102
|
+
# => Task.includes(:category).references(:category)
|
103
|
+
# -> .where(categories: { name: 'foo bar' })
|
104
|
+
# -> .where('tasks.priority < ?', 2)
|
105
|
+
# -> .where('tasks.name LIKE ?', '%baz%')
|
106
|
+
# -> .incomplete
|
107
|
+
|
108
|
+
TaskSearch.new('category: "foo bar"', User.find(42).tasks).results
|
109
|
+
# => Task.includes(:category).references(:category)
|
110
|
+
# -> .where(user_id: 42)
|
111
|
+
# -> .where(categories: { name: 'foo bar' })
|
112
|
+
```
|
78
113
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
Create a class which inherits from SearchLingo::AbstractSearch. Provide an
|
83
|
-
implementation of <code>#default_parse</code> in that class. Register parsers
|
84
|
-
for specific types of search tokens using the <code>parser</code> class method.
|
114
|
+
Create a class which inherits from `SearchLingo::AbstractSearch`. Provide an
|
115
|
+
implementation of `#default_parse` in that class. Register parsers for specific
|
116
|
+
types of search tokens using the parser class method.
|
85
117
|
|
86
118
|
Instantiate your search class by passing in the query string and the scope on
|
87
|
-
which to perform the search. Use the
|
88
|
-
|
119
|
+
which to perform the search. Use the `#results` method to compile the search
|
120
|
+
and return the results.
|
89
121
|
|
90
122
|
Take a look at the examples/ directory for more concrete examples.
|
91
123
|
|
@@ -94,34 +126,34 @@ Take a look at the examples/ directory for more concrete examples.
|
|
94
126
|
A search is instantiated with a query string and a search scope (commonly an
|
95
127
|
ActiveRecord model). The search breaks the query string down into a series of
|
96
128
|
tokens, and each token is processed by a declared series of parsers. If a
|
97
|
-
parser succeeds,
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
129
|
+
parser succeeds, processing immediately advances to the next token. If none of
|
130
|
+
the declared parsers succeeds, and the token is compound — that is, the token
|
131
|
+
is composed of an operator and a term (e.g., `foo: bar`), the token is
|
132
|
+
simplified and then processed by the declared parsers again. If the second pass
|
133
|
+
also fails, then the (now simplified) token falls through to the
|
134
|
+
`#default_parse` method defined by the search class. This method should be
|
135
|
+
implemented in such a way that it always "succeeds" — always returning a Symbol
|
136
|
+
or an Array that can be splatted and sent to the search scope.
|
105
137
|
|
106
138
|
## Search Classes
|
107
139
|
|
108
|
-
Search classes should inherit from SearchLogic::AbstractSearch
|
109
|
-
provide their own implementation of
|
110
|
-
|
140
|
+
Search classes should inherit from `SearchLogic::AbstractSearch`, and they must
|
141
|
+
provide their own implementation of `#default_parse`. Optionally, a search
|
142
|
+
class may also use the parse class method to add specialized parsers for
|
111
143
|
handling tokens that match specific patterns. As each token is processed, the
|
112
144
|
search class will first run through the specialized parsers. If none of them
|
113
|
-
succeed, it will fall back on the
|
114
|
-
|
115
|
-
|
145
|
+
succeed, it will fall back on the `#default_parse` method. See the section
|
146
|
+
"Parsing" for more information on how parsers work and how they should be
|
147
|
+
structured.
|
116
148
|
|
117
149
|
## Tokenization
|
118
150
|
|
119
151
|
Queries are comprised of zero or more tokens separated by white space. A token
|
120
152
|
has a term and an optional operator. (A simple token has no operator; a
|
121
153
|
compound token does.) A term can be a single word or multiple words joined by
|
122
|
-
spaces and contained within double quotes. For example
|
123
|
-
|
124
|
-
|
154
|
+
spaces and contained within double quotes. For example `foo` and `"foo bar
|
155
|
+
baz"` are both single terms. An operator is one or more alphanumeric characters
|
156
|
+
followed by a colon and zero or more spaces.
|
125
157
|
|
126
158
|
QUERY := TOKEN*
|
127
159
|
TOKEN := (OPERATOR ':' [[:space:]]*)? TERM
|
@@ -130,85 +162,122 @@ alphanumeric characters followed by a colon and zero or more spaces.
|
|
130
162
|
|
131
163
|
The following are all examples of tokens:
|
132
164
|
|
133
|
-
*
|
134
|
-
*
|
135
|
-
*
|
136
|
-
*
|
165
|
+
* `foo`
|
166
|
+
* `"foo bar"`
|
167
|
+
* `foo: bar`
|
168
|
+
* `foo: "bar baz"`
|
137
169
|
|
138
170
|
(If you need a term to equal something that might otherwise be interpreted as
|
139
|
-
an operator, you can enclose the term in double quotes, e.g., while
|
140
|
-
|
141
|
-
bar
|
171
|
+
an operator, you can enclose the term in double quotes, e.g., while `foo: bar`
|
172
|
+
would be interpreted a single compound token, `"foo:" bar` would be treated as
|
173
|
+
two distinct simple tokens, and `"foo: bar"` would be treated as a single
|
174
|
+
simple token.)
|
175
|
+
|
176
|
+
Tokens are passed to parsers as instances of the SearchLingo::Token class.
|
177
|
+
SearchLingo::Token provides `#operator` and `#term` methods, but delegates all
|
178
|
+
other behavior to the String class. Consequently, when writing parsers, you
|
179
|
+
have the option of either interacting with examining the operator and term
|
180
|
+
individually or treating the entire token as a String and processing it
|
181
|
+
yourself. The following would produce identical results:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
token = SearchLingo::Token.new('foo: "bar baz"')
|
142
185
|
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
186
|
+
if token.operator == 'foo' then token.term end # => 'bar baz'
|
187
|
+
token.match(/\Afoo:\s*"?(.+?)"?\z/) { |m| m[1] } # => 'bar baz'
|
188
|
+
```
|
189
|
+
|
190
|
+
(Note that `#term` takes care of stripping away quotes from the term.)
|
148
191
|
|
149
192
|
## Parsers
|
150
193
|
|
151
|
-
Any object that can respond to the
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
falsey value.
|
194
|
+
Any object that can respond to the `#call` method can be used as a parser. If
|
195
|
+
the parser succeeds, it should return an Array of arguments that can be sent to
|
196
|
+
the query object using `#public_send`, e.g., `[:where, { id: 42 }]`. If the
|
197
|
+
parser fails, it should return a falsey value.
|
156
198
|
|
157
|
-
For very simple parsers which need not be reusable, you can pass the
|
158
|
-
|
199
|
+
For very simple parsers which need not be reusable, you can pass the parsing
|
200
|
+
logic to the parser method as a block:
|
159
201
|
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
end
|
202
|
+
```ruby
|
203
|
+
class MySearch < SearchLingo::AbstractSearch
|
204
|
+
parser do |token|
|
205
|
+
token.match /\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
|
206
|
+
[:where, { id: m[1] }]
|
166
207
|
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
```
|
167
211
|
|
168
|
-
|
212
|
+
If you want to re-use a parser, you could implement it as a lambda:
|
169
213
|
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
end
|
214
|
+
```ruby
|
215
|
+
module Parsers
|
216
|
+
ID_PARSER = lambda do |token|
|
217
|
+
token.match h/\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
|
218
|
+
[:where, { id: m[1] }]
|
176
219
|
end
|
220
|
+
end
|
221
|
+
end
|
177
222
|
|
178
|
-
|
179
|
-
|
180
|
-
|
223
|
+
class MySearch < SearchLingo::AbstractSearch
|
224
|
+
parser Parsers::ID_PARSER
|
225
|
+
end
|
181
226
|
|
182
|
-
|
183
|
-
|
184
|
-
|
227
|
+
class MyOtherSearch < SearchLingo::AbstractSearch
|
228
|
+
parser Parsers::ID_PARSER
|
229
|
+
end
|
230
|
+
```
|
185
231
|
|
186
232
|
Finally, for the most complicated cases, you could implement parsers as
|
187
233
|
classes:
|
188
234
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
def call(token)
|
197
|
-
token.match /\A#{@prefix}([[:digit:]]+)\z/ do |m|
|
198
|
-
[:where, { @table => { id: m[1] } }]
|
199
|
-
end
|
200
|
-
end
|
201
|
-
end
|
235
|
+
```ruby
|
236
|
+
module Parsers
|
237
|
+
class IdParser
|
238
|
+
def initialize(table, operator = nil)
|
239
|
+
@table = table
|
240
|
+
@prefix = /#{operator}:\s*/ if operator
|
202
241
|
end
|
203
242
|
|
204
|
-
|
205
|
-
|
206
|
-
|
243
|
+
def call(token)
|
244
|
+
token.match /\A#{@prefix}([[:digit:]]+)\z/ do |m|
|
245
|
+
[:where, { @table => { id: m[1] } }]
|
246
|
+
end
|
207
247
|
end
|
248
|
+
end
|
249
|
+
end
|
208
250
|
|
209
|
-
|
210
|
-
|
211
|
-
|
251
|
+
class EventSearch < SearchLingo::AbstractSearch
|
252
|
+
# matches "42" and adds events.id=42 as a condition
|
253
|
+
parser Parsers::IdParser.new Event.table_name
|
254
|
+
|
255
|
+
# matches "category: 42" and adds categories.id as a condition
|
256
|
+
parser Parsers::IdParser.new Category.table_name, 'category'
|
257
|
+
end
|
258
|
+
|
259
|
+
class CategorySearch < SearchLingo::AbstractSearch
|
260
|
+
parser Parsers::IdParser.new :categories
|
261
|
+
end
|
262
|
+
```
|
263
|
+
|
264
|
+
### Date Parsers
|
265
|
+
|
266
|
+
One of the non-trivial parsing tasks I found myself constantly reimplementing
|
267
|
+
was searching for records matching a date or a date range. To provide examples
|
268
|
+
of moderately complex parsers and avoid having to think about this parsing
|
269
|
+
problem again, I've included several parsers for handling US-formatted dates
|
270
|
+
and date ranges. They will handle dates formatted as M/D/YYYY, M/D/YY, and M/D.
|
271
|
+
(For M/D, the year is inferred based on the current year and with the
|
272
|
+
assumption that the date should always be in the past, i.e., if the current
|
273
|
+
date is 10 June 2015, `6/9` and `6/10` will be parsed as 9 June 2015 and 10
|
274
|
+
June 2015, respectively, but `6/11` will be parsed as 11 June *2014*.)
|
275
|
+
Additionally, there are parsers for handling closed date ranges (e.g.,
|
276
|
+
`1/1/15-6/30/15`) as well as open-ended date ranges (e.g., `1/1/15-` and
|
277
|
+
`12/31/15`). Look at the files in `lib/search_lingo/parsers` for more details.
|
278
|
+
|
279
|
+
As implemented, the date parsers are US-centric. I would like to work on making
|
280
|
+
them more flexible when time permits.
|
212
281
|
|
213
282
|
## Development
|
214
283
|
|
@@ -7,7 +7,7 @@ module SearchLingo
|
|
7
7
|
@scope = scope
|
8
8
|
end
|
9
9
|
|
10
|
-
attr_reader :query
|
10
|
+
attr_reader :query
|
11
11
|
|
12
12
|
def self.parsers
|
13
13
|
@parsers ||= []
|
@@ -62,5 +62,9 @@ module SearchLingo
|
|
62
62
|
raise NotImplementedError,
|
63
63
|
"#default_parse must be implemented by #{self.class}"
|
64
64
|
end
|
65
|
+
|
66
|
+
def scope
|
67
|
+
@scope
|
68
|
+
end
|
65
69
|
end
|
66
70
|
end
|
data/lib/search_lingo/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: search_lingo
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.0
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- John Parker
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-06-
|
11
|
+
date: 2015-06-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -126,9 +126,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
126
126
|
version: '2.1'
|
127
127
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
128
128
|
requirements:
|
129
|
-
- - "
|
129
|
+
- - ">="
|
130
130
|
- !ruby/object:Gem::Version
|
131
|
-
version:
|
131
|
+
version: '0'
|
132
132
|
requirements: []
|
133
133
|
rubyforge_project:
|
134
134
|
rubygems_version: 2.4.7
|