search_lingo 1.0.0.beta3 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
# [![Gem Version](https://badge.fury.io/rb/search_lingo.svg)](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
|