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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5f1e9e130f36eb2a7481db8481a510606e0f250d
4
- data.tar.gz: 9add0798376d6e52412102eb0ba76c542f43ca88
3
+ metadata.gz: 44d740566f9aa77034514ebcfa50c3233527ac24
4
+ data.tar.gz: 5cf05354fa6244b95e971062233830e1e6f68d6d
5
5
  SHA512:
6
- metadata.gz: d299c1e7b9fac6aeb2d17ccaaeb4ecacf3afcb42e71b009c9b2da5b9f6fd1b42238e4d33c3338ed1efddea1d776f92df352ad620e68dcbafa96f83fc630305ef
7
- data.tar.gz: 8851f6d29a00c39bdce63e8381603a6b2970e8b0804b9549d22d2510203caaef71b04530d04deab7b799da9d240ebb4235429cdedda0e1beca53346eb3ea6bb5
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 chain queries together onto a single object.
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
- class Task < ActiveRecord::Base
40
- end
38
+ ```ruby
39
+ class Task < ActiveRecord::Base
40
+ end
41
41
 
42
- class TaskSearch < SearchLingo::AbstractSearch
43
- def default_parse(token)
44
- [:where, 'tasks.name LIKE ?', "%#{token}%"]
45
- end
46
- end
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
- TaskSearch.new('foo bar', Task).results
49
- # => Task.where('tasks.name LIKE ?', '%foo%').where('tasks.name LIKE ?', '%bar%')
50
- TaskSearch.new('"foo bar"', Task).results
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
- class Category < ActiveRecord::Base
56
- has_many :tasks
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
- class Task < ActiveRecord::Base
60
- belongs_to :category
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
- class TaskSearch < SearchLingo::AbstractSearch
64
- parser do |token|
65
- token.match /\Acategory:\s*"?(.*?)"?\z/ do |m}
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
- TaskSearch.new('category: "foo bar" baz', Task).results
80
- # => Task.includes(:category).references(:category).where(categories: { name: 'foo bar' }).where('tasks.name LIKE ?', '%baz%')
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 <code>#results</code> method to compile
88
- the search and return the results.
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, the process immediately terminates and advances to the next
98
- token. If none of the declared parsers succeeds, and the token is compound --
99
- that is, the token is composed of an operator and a term (e.g., "foo: bar"),
100
- the token is simplified and then processed by the declared parsers again. If
101
- the second pass also fails, then the (now simplified) token falls through to
102
- the <code>#default_parse</code> method defined by the search class. (It is
103
- important that this method be implemented in such a way that it always
104
- succeeds.)
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, and they must
109
- provide their own implementation of <code>#default_parse</code>. Optionally, a
110
- search class may also use the parse class method to add specialized parsers for
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 <code>#default_parse</code> method. See the
114
- section "Parsing" for more information on how parsers work and how they should
115
- be structured.
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 <code>foo</code> and
123
- <code>"foo bar baz"</code> are both single terms. An operator is one or more
124
- alphanumeric characters followed by a colon and zero or more spaces.
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
- * <code>foo</code>
134
- * <code>"foo bar"</code>
135
- * <code>foo: bar</code>
136
- * <code>foo: "bar baz"</code>
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 <code>foo:
140
- bar</code> would be interpreted a single compound token, <code>"foo:"
141
- bar</code> would be treated as two distinct simple tokens.)
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
- Tokens are passed to parsers as instances of the Token class. The Token class
144
- provides <code>#operator</code> and <code>#term</code> methods, but delegates
145
- all other behavior to the String class. Consequently, when writing parsers, you
146
- have the option of either interacting with the token as a raw String or making
147
- use of the extra functionality of Tokens.
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 <code>#call</code> method can be used as a
152
- parser. If the parser succeeds, it should return an Array of arguments that can
153
- be sent to the query object using <code>#public_send</code>, e.g.,
154
- <code>[:where, { id: 42 }]</code>. If the parser fails, it should return a
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
- parsing logic to the <code>parser</code> method as a block:
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
- class MySearch < SearchLingo::AbstractSearch
161
- parser do |token|
162
- token.match /\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
163
- [:where, { id: m[1] }]
164
- end
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
- Parsers can also be implemented as lambdas:
212
+ If you want to re-use a parser, you could implement it as a lambda:
169
213
 
170
- module Parsers
171
- ID_PARSER = lambda do |token|
172
- token.match h/\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
173
- [:where, { id: m[1] }]
174
- end
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
- class MySearch < SearchLingo::AbstractSearch
179
- parser Parsers::ID_PARSER
180
- end
223
+ class MySearch < SearchLingo::AbstractSearch
224
+ parser Parsers::ID_PARSER
225
+ end
181
226
 
182
- class MyOtherSearch < SearchLingo::AbstractSearch
183
- parser Parsers::ID_PARSER
184
- end
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
- module Parsers
190
- class IdParser
191
- def initialize(table, operator = nil)
192
- @table = table
193
- @prefix = /#{operator}:\s*/ if operator
194
- end
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
- class EventSearch < SearchLingo::AbstractSearch
205
- parser Parsers::IdParser.new :events # => match "42"
206
- parser Parsers::IdParser.new :categories, 'category' # => match "category: 42"
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
- class CategorySearch < SearchLingo::AbstractSearch
210
- parser Parsers::IdParser.new :categories
211
- end
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, :scope
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
@@ -1,3 +1,3 @@
1
1
  module SearchLingo
2
- VERSION = '1.0.0.beta3'
2
+ VERSION = '1.0.0'
3
3
  end
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.beta3
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-08 00:00:00.000000000 Z
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: 1.3.1
131
+ version: '0'
132
132
  requirements: []
133
133
  rubyforge_project:
134
134
  rubygems_version: 2.4.7