search_lingo 1.0.1 → 1.0.2
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/.travis.yml +1 -0
- data/README.md +25 -22
- data/examples/complex.rb +6 -6
- data/examples/sequel_example.rb +64 -0
- data/lib/search_lingo/abstract_search.rb +70 -2
- data/lib/search_lingo/constants.rb +7 -2
- data/lib/search_lingo/parsers/date_parser.rb +9 -5
- data/lib/search_lingo/parsers/date_range_parser.rb +1 -1
- data/lib/search_lingo/parsers/gte_date_parser.rb +2 -2
- data/lib/search_lingo/parsers/lte_date_parser.rb +2 -2
- data/lib/search_lingo/parsers/mdy.rb +22 -7
- data/lib/search_lingo/parsers/open_date_range_parser.rb +24 -16
- data/lib/search_lingo/token.rb +28 -6
- data/lib/search_lingo/tokenizer.rb +19 -2
- data/lib/search_lingo/version.rb +1 -1
- data/search_lingo.gemspec +4 -1
- metadata +25 -7
- data/examples/simple.rb +0 -31
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b4c58f28e8b25ab5ec35bf08089b12ccae7a0f88
|
4
|
+
data.tar.gz: 81bfba3fafebdadbe909ff5a6a4506e2fac965cc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8c453f5e4bd238077618415c6fd93840c3583358279793791ac94936d45152b2c3d07ed895a80f17a9076d064775c6e03042e8940cfcacd9c63ffb5a51322d1
|
7
|
+
data.tar.gz: 23077beb4f9a66b6a0f78f84a95939c1abb987d53adddef1650790a7069075fea6b92745a6b3fc44b3aab09699c461fc7eb12098ffa0f9eec99e8f8226f75258
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# [](http://badge.fury.io/rb/search_lingo)
|
1
|
+
# [](http://badge.fury.io/rb/search_lingo) [](https://travis-ci.org/jparker/search_lingo)
|
2
2
|
|
3
3
|
# SearchLingo
|
4
4
|
|
@@ -33,7 +33,8 @@ Or install it yourself as:
|
|
33
33
|
|
34
34
|
## Usage
|
35
35
|
|
36
|
-
|
36
|
+
Concrete examples of how to use this gem are provided in `examples/` and
|
37
|
+
`test/examples/`, but here is a simple example.
|
37
38
|
|
38
39
|
```ruby
|
39
40
|
class Task < ActiveRecord::Base
|
@@ -116,10 +117,8 @@ implementation of `#default_parse` in that class. Register parsers for specific
|
|
116
117
|
types of search tokens using the parser class method.
|
117
118
|
|
118
119
|
Instantiate your search class by passing in the query string and the scope on
|
119
|
-
which to perform the search. Use the `#results` method to compile
|
120
|
-
and return the results.
|
121
|
-
|
122
|
-
Take a look at the examples/ directory for more concrete examples.
|
120
|
+
which to perform the search. Use the `#results` method to compile and execute
|
121
|
+
the search and return the results.
|
123
122
|
|
124
123
|
## How It Works
|
125
124
|
|
@@ -128,7 +127,7 @@ ActiveRecord model). The search breaks the query string down into a series of
|
|
128
127
|
tokens, and each token is processed by a declared series of parsers. If a
|
129
128
|
parser succeeds, processing immediately advances to the next token. If none of
|
130
129
|
the declared parsers succeeds, and the token is compound — that is, the token
|
131
|
-
is composed of
|
130
|
+
is composed of a modifier and a term (e.g., `foo: bar`), the token is
|
132
131
|
simplified and then processed by the declared parsers again. If the second pass
|
133
132
|
also fails, then the (now simplified) token falls through to the
|
134
133
|
`#default_parse` method defined by the search class. This method should be
|
@@ -137,7 +136,7 @@ or an Array that can be splatted and sent to the search scope.
|
|
137
136
|
|
138
137
|
## Search Classes
|
139
138
|
|
140
|
-
Search classes should inherit from `
|
139
|
+
Search classes should inherit from `SearchLingo::AbstractSearch`, and they must
|
141
140
|
provide their own implementation of `#default_parse`. Optionally, a search
|
142
141
|
class may also use the parse class method to add specialized parsers for
|
143
142
|
handling tokens that match specific patterns. As each token is processed, the
|
@@ -149,15 +148,15 @@ structured.
|
|
149
148
|
## Tokenization
|
150
149
|
|
151
150
|
Queries are comprised of zero or more tokens separated by white space. A token
|
152
|
-
has a term and an optional
|
151
|
+
has a term and an optional modifier. (A simple token has no modifier; a
|
153
152
|
compound token does.) A term can be a single word or multiple words joined by
|
154
153
|
spaces and contained within double quotes. For example `foo` and `"foo bar
|
155
|
-
baz"` are both single terms.
|
154
|
+
baz"` are both single terms. A modifier is one or more alphanumeric characters
|
156
155
|
followed by a colon and zero or more spaces.
|
157
156
|
|
158
157
|
QUERY := TOKEN*
|
159
|
-
TOKEN := (
|
160
|
-
|
158
|
+
TOKEN := (MODIFIER ':' [[:space:]]*)? TERM
|
159
|
+
MODIFIER := [[:alnum:]]+
|
161
160
|
TERM := '"' [^"]* '"' | [[:graph:]]+
|
162
161
|
|
163
162
|
The following are all examples of tokens:
|
@@ -168,22 +167,22 @@ The following are all examples of tokens:
|
|
168
167
|
* `foo: "bar baz"`
|
169
168
|
|
170
169
|
(If you need a term to equal something that might otherwise be interpreted as
|
171
|
-
|
170
|
+
a modifier, you can enclose the term in double quotes, e.g., while `foo: bar`
|
172
171
|
would be interpreted a single compound token, `"foo:" bar` would be treated as
|
173
172
|
two distinct simple tokens, and `"foo: bar"` would be treated as a single
|
174
173
|
simple token.)
|
175
174
|
|
176
|
-
Tokens are passed to parsers as instances of the SearchLingo::Token class.
|
177
|
-
SearchLingo::Token provides `#
|
178
|
-
other behavior to the String class. Consequently, when writing parsers, you
|
179
|
-
have the option of either interacting with examining the
|
175
|
+
Tokens are passed to parsers as instances of the `SearchLingo::Token` class.
|
176
|
+
`SearchLingo::Token` provides `#modifier` and `#term` methods, but delegates
|
177
|
+
all other behavior to the String class. Consequently, when writing parsers, you
|
178
|
+
have the option of either interacting with examining the modifier and term
|
180
179
|
individually or treating the entire token as a String and processing it
|
181
180
|
yourself. The following would produce identical results:
|
182
181
|
|
183
182
|
```ruby
|
184
183
|
token = SearchLingo::Token.new('foo: "bar baz"')
|
185
184
|
|
186
|
-
if token.
|
185
|
+
if token.modifier == 'foo' then token.term end # => 'bar baz'
|
187
186
|
token.match(/\Afoo:\s*"?(.+?)"?\z/) { |m| m[1] } # => 'bar baz'
|
188
187
|
```
|
189
188
|
|
@@ -235,9 +234,9 @@ classes:
|
|
235
234
|
```ruby
|
236
235
|
module Parsers
|
237
236
|
class IdParser
|
238
|
-
def initialize(table,
|
237
|
+
def initialize(table, modifier = nil)
|
239
238
|
@table = table
|
240
|
-
@prefix = /#{
|
239
|
+
@prefix = /#{modifier}:\s*/ if modifier
|
241
240
|
end
|
242
241
|
|
243
242
|
def call(token)
|
@@ -276,8 +275,12 @@ Additionally, there are parsers for handling closed date ranges (e.g.,
|
|
276
275
|
`1/1/15-6/30/15`) as well as open-ended date ranges (e.g., `1/1/15-` and
|
277
276
|
`12/31/15`). Look at the files in `lib/search_lingo/parsers` for more details.
|
278
277
|
|
279
|
-
|
280
|
-
them more flexible
|
278
|
+
The date parser are specifically designed to work with US-formatted dates. Time
|
279
|
+
permitting, I will work on making them more flexible. As implemented they are
|
280
|
+
also ActiveRecord-centric; this also needs to be reexamined reexamined, either
|
281
|
+
by finding a more agnostic implementation or renaming the parser classes to
|
282
|
+
indicate they are ActiveRecord-only implementations. (If going the latter
|
283
|
+
route, a Sequel-specific implementation should also be provided.)
|
281
284
|
|
282
285
|
## Development
|
283
286
|
|
data/examples/complex.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
|
-
class Job < ActiveRecord::Base
|
1
|
+
class Job < ActiveRecord::Base # :nodoc:
|
2
2
|
# Assume this model has attributes: :id, :date, :name
|
3
3
|
end
|
4
4
|
|
5
|
-
class Receipt < ActiveRecord::Base
|
5
|
+
class Receipt < ActiveRecord::Base # :nodoc:
|
6
6
|
# Assume this model has attributes: :id, :check_no, :check_date, :post_date, :amount
|
7
7
|
end
|
8
8
|
|
9
|
-
module Parsers
|
10
|
-
class IdParser
|
9
|
+
module Parsers # :nodoc:
|
10
|
+
class IdParser # :nodoc:
|
11
11
|
def initialize(table)
|
12
12
|
@table = table
|
13
13
|
end
|
@@ -20,7 +20,7 @@ module Parsers
|
|
20
20
|
end
|
21
21
|
end
|
22
22
|
|
23
|
-
class JobSearch < AbstractSearch
|
23
|
+
class JobSearch < SearchLingo::AbstractSearch # :nodoc:
|
24
24
|
parser Parsers::IdParser.new Job.table_name
|
25
25
|
|
26
26
|
parser SearchLingo::Parsers::DateParser.new Job.table_name,
|
@@ -35,7 +35,7 @@ class JobSearch < AbstractSearch
|
|
35
35
|
end
|
36
36
|
end
|
37
37
|
|
38
|
-
class ReceiptSearch < AbstractSearch
|
38
|
+
class ReceiptSearch < SearchLingo::AbstractSearch # :nodoc:
|
39
39
|
parser Parsers::IdParser.new Receipt.table_name
|
40
40
|
|
41
41
|
parser SearchLingo::Parsers::DateParser.new Receipt.table_name,
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
require 'sqlite3'
|
3
|
+
|
4
|
+
DB = Sequel.sqlite
|
5
|
+
|
6
|
+
if ENV['LOG_TO_STDOUT']
|
7
|
+
require 'logger'
|
8
|
+
DB.loggers << Logger.new(STDOUT)
|
9
|
+
end
|
10
|
+
|
11
|
+
DB.create_table :categories do
|
12
|
+
primary_key :id
|
13
|
+
String :name, null: false, unique: true
|
14
|
+
end
|
15
|
+
|
16
|
+
DB.create_table :tasks do
|
17
|
+
foreign_key :category_id, :categories
|
18
|
+
String :name, null: false, unique: true
|
19
|
+
Integer :priority, null: false
|
20
|
+
Date :due_date, null: false
|
21
|
+
end
|
22
|
+
|
23
|
+
class Category < Sequel::Model # :nodoc:
|
24
|
+
one_to_many :tasks
|
25
|
+
end
|
26
|
+
|
27
|
+
class Task < Sequel::Model # :nodoc:
|
28
|
+
many_to_one :category
|
29
|
+
end
|
30
|
+
|
31
|
+
class CategoryParser # :nodoc:
|
32
|
+
def call(token)
|
33
|
+
if token.modifier == 'cat'
|
34
|
+
[:where, { category__name: token.term }]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class TaskSearch < SearchLingo::AbstractSearch # :nodoc:
|
40
|
+
parser CategoryParser.new
|
41
|
+
|
42
|
+
parser do |token|
|
43
|
+
token.match /\A([<>])([[:digit:]]+)\z/ do |m|
|
44
|
+
[:where, ->{ priority.send m[1], m[2] }]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
parser do |token|
|
49
|
+
token.match %r{\A(?<m>\d{1,2})/(?<d>\d{1,2})/(?<y>\d{2}\d{2}?)\z} do |m|
|
50
|
+
begin
|
51
|
+
[:where, { due_date: Date.parse("#{m[:y]}/#{m[:m]}/#{m[:d]}") }]
|
52
|
+
rescue ArgumentError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def default_parse(token)
|
58
|
+
[:where, 'tasks.name LIKE ?', "%#{token.term}%"]
|
59
|
+
end
|
60
|
+
|
61
|
+
def scope
|
62
|
+
@scope.eager_graph(:category)
|
63
|
+
end
|
64
|
+
end
|
@@ -2,38 +2,86 @@ require 'search_lingo/tokenizer'
|
|
2
2
|
|
3
3
|
module SearchLingo
|
4
4
|
class AbstractSearch
|
5
|
+
attr_reader :query
|
6
|
+
|
7
|
+
##
|
8
|
+
# Instantiates a new search object. +query+ is the string that is to be
|
9
|
+
# parsed and compiled into an actual query. If +query+ is falsey, an empty
|
10
|
+
# string will be used. +scope+ is the object to which the compiled query
|
11
|
+
# should be sent, e.g., an +ActiveRecord+ model.
|
5
12
|
def initialize(query, scope)
|
6
13
|
@query = query || ''
|
7
14
|
@scope = scope
|
8
15
|
end
|
9
16
|
|
10
|
-
|
11
|
-
|
17
|
+
##
|
18
|
+
# Returns an list of parsers that have been added to this class.
|
12
19
|
def self.parsers
|
13
20
|
@parsers ||= []
|
14
21
|
end
|
15
22
|
|
23
|
+
##
|
24
|
+
# Adds a new parser to the list of parsers used by this class.
|
25
|
+
#
|
26
|
+
# The parser to be added can be passed in as an argument which responds to
|
27
|
+
# +#call+ or as a block. Raises +ArgumentError+ if neither a callable
|
28
|
+
# object nor a block is passed in. If both a callable argument and a block
|
29
|
+
# are passed in, a warning will be displayed, the callable argument will be
|
30
|
+
# used as the parser; the block will be ignored.
|
31
|
+
#
|
32
|
+
# class MyParser
|
33
|
+
# def call
|
34
|
+
# # return something
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# class MySearch < SearchLingo::AbstractSearch
|
39
|
+
# parser MyParser.new
|
40
|
+
# parser do
|
41
|
+
# # return something
|
42
|
+
# end
|
43
|
+
# end
|
16
44
|
def self.parser(callable = nil, &block)
|
17
45
|
unless callable || block_given?
|
18
46
|
raise ArgumentError, 'parse must be called with callable or block'
|
19
47
|
end
|
20
48
|
if callable && block_given?
|
49
|
+
# TODO: should this raise an error instead?
|
21
50
|
warn "WARNING: parse called with callable and block (#{caller.first}"
|
22
51
|
end
|
23
52
|
|
24
53
|
parsers << (callable || block)
|
25
54
|
end
|
26
55
|
|
56
|
+
##
|
57
|
+
# Delegates to SearchLingo::AbstractSearch.parsers.
|
27
58
|
def parsers
|
28
59
|
self.class.parsers
|
29
60
|
end
|
30
61
|
|
62
|
+
##
|
63
|
+
# Returns the results of executing the search.
|
31
64
|
def results
|
32
65
|
@results ||= conditions.inject(scope) do |query, condition|
|
33
66
|
query.public_send(*condition)
|
34
67
|
end
|
35
68
|
end
|
36
69
|
|
70
|
+
##
|
71
|
+
# Returns an +Array+ of compiled query parameters.
|
72
|
+
#
|
73
|
+
# @query is broken down into tokens, and each token is passed through the
|
74
|
+
# list of defined parsers. If a parser is successful, +:match+ is thrown,
|
75
|
+
# the compiled condition is saved, and processing moves on to the next
|
76
|
+
# token. If none of the parsers succeeds and the token is compound, that
|
77
|
+
# is, it has both a modifier and a term, the token is simplified, and
|
78
|
+
# reprocessed through the list of parsers. As during the first pass, if a
|
79
|
+
# parser succeeds, +:match+ is thrown, the compiled condition for the now
|
80
|
+
# simplified token is saved, and processing moves on to the next token (the
|
81
|
+
# remains of the original compound token). If none of the parsers succeeds
|
82
|
+
# during the second pass, the now simplified token is finally sent to
|
83
|
+
# +#default_parse+, and whatever it returns will be saved as the compiled
|
84
|
+
# condition.
|
37
85
|
def conditions
|
38
86
|
tokenizer.inject([]) do |conditions, token|
|
39
87
|
conditions << catch(:match) do
|
@@ -47,10 +95,19 @@ module SearchLingo
|
|
47
95
|
end
|
48
96
|
end
|
49
97
|
|
98
|
+
##
|
99
|
+
# Returns a +SearchLingo::Tokenizer+ for @query.
|
50
100
|
def tokenizer
|
51
101
|
@tokenizer ||= Tokenizer.new query
|
52
102
|
end
|
53
103
|
|
104
|
+
##
|
105
|
+
# Passes +token+ to each parser in turn. If a parser succeeds, throws
|
106
|
+
# +:match+ with the compiled result.
|
107
|
+
#
|
108
|
+
# A parser succeeds if +call+ returns a truthy value. The return value of a
|
109
|
+
# successful parser will be splatted and sent to @scope using
|
110
|
+
# +public_send+.
|
54
111
|
def parse(token)
|
55
112
|
parsers.each do |parser|
|
56
113
|
result = parser.call token
|
@@ -58,11 +115,22 @@ module SearchLingo
|
|
58
115
|
end
|
59
116
|
end
|
60
117
|
|
118
|
+
##
|
119
|
+
# Raises +NotImplementedError+. Classes which inherit from
|
120
|
+
# SearchLingo::AbstractSearch must provide their own implementation, and it
|
121
|
+
# should *always* succeed.
|
61
122
|
def default_parse(token)
|
62
123
|
raise NotImplementedError,
|
63
124
|
"#default_parse must be implemented by #{self.class}"
|
64
125
|
end
|
65
126
|
|
127
|
+
##
|
128
|
+
# Returns @scope.
|
129
|
+
#
|
130
|
+
# You may override this method in your search class if you want to ensure
|
131
|
+
# additional messages are sent to search scope before executing the query.
|
132
|
+
# For example, if @scope is an +ActiveRecord+ model, you might want to join
|
133
|
+
# additional tables.
|
66
134
|
def scope
|
67
135
|
@scope
|
68
136
|
end
|
@@ -1,20 +1,24 @@
|
|
1
1
|
require 'search_lingo/parsers/mdy'
|
2
2
|
|
3
3
|
module SearchLingo
|
4
|
-
module Parsers
|
4
|
+
module Parsers # :nodoc:
|
5
5
|
class DateParser
|
6
6
|
include MDY
|
7
7
|
|
8
|
-
def initialize(table, column,
|
8
|
+
def initialize(table, column, modifier = nil, **options)
|
9
9
|
@table = table
|
10
10
|
@column = column
|
11
|
-
@prefix = %r{#{
|
11
|
+
@prefix = %r{#{modifier}:\s*} if modifier
|
12
12
|
|
13
13
|
post_initialize **options
|
14
14
|
end
|
15
15
|
|
16
16
|
attr_reader :table, :column, :prefix
|
17
17
|
|
18
|
+
# This implementation is specific to ActiveRecord::Base#where semantics.
|
19
|
+
# Explore an agnostic implementation or rename the DateParser class (and
|
20
|
+
# its descendants) to indicate that it is ActiveRecord-centric. If going
|
21
|
+
# the latter route, provide a Sequel-specific implementation as well.
|
18
22
|
def call(token)
|
19
23
|
token.match /\A#{prefix}(?<date>#{US_DATE})\z/ do |m|
|
20
24
|
date = parse m[:date]
|
@@ -22,10 +26,10 @@ module SearchLingo
|
|
22
26
|
end
|
23
27
|
end
|
24
28
|
|
25
|
-
def post_initialize(**)
|
29
|
+
def post_initialize(**) # :nodoc:
|
26
30
|
end
|
27
31
|
|
28
|
-
def inspect
|
32
|
+
def inspect # :nodoc:
|
29
33
|
'#<%s:0x%x @table=%s @column=%s @prefix=%s>' %
|
30
34
|
[self.class, object_id << 1, table.inspect, column.inspect, prefix.inspect]
|
31
35
|
end
|
@@ -1,8 +1,8 @@
|
|
1
1
|
require 'search_lingo/parsers/open_date_range_parser'
|
2
2
|
|
3
3
|
module SearchLingo
|
4
|
-
module Parsers
|
5
|
-
class GTEDateParser < OpenDateRangeParser
|
4
|
+
module Parsers # :nodoc:
|
5
|
+
class GTEDateParser < OpenDateRangeParser # :nodoc:
|
6
6
|
def initialize(*)
|
7
7
|
warn "DEPRECATION WARNING: use SearchLingo::Parsers::OpenDateRangeParser " \
|
8
8
|
"instead of #{self.class} (from #{caller.first})"
|
@@ -1,8 +1,8 @@
|
|
1
1
|
require 'search_lingo/parsers/open_date_range_parser'
|
2
2
|
|
3
3
|
module SearchLingo
|
4
|
-
module Parsers
|
5
|
-
class LTEDateParser < OpenDateRangeParser
|
4
|
+
module Parsers # :nodoc:
|
5
|
+
class LTEDateParser < OpenDateRangeParser # :nodoc:
|
6
6
|
def initialize(*)
|
7
7
|
warn "DEPRECATION WARNING: use SearchLingo::Parsers::OpenDateRangeParser " \
|
8
8
|
"instead of #{self.class} (from #{caller.first})"
|
@@ -1,22 +1,37 @@
|
|
1
1
|
require 'date'
|
2
2
|
|
3
3
|
module SearchLingo
|
4
|
-
module Parsers
|
4
|
+
module Parsers # :nodoc:
|
5
5
|
module MDY
|
6
|
+
##
|
7
|
+
# Pattern for matching US-formatted date strings.
|
8
|
+
#
|
9
|
+
# The year may be two or four digits, or it may be omitted.
|
6
10
|
US_DATE = %r{(?<m>\d{1,2})/(?<d>\d{1,2})(?:/(?<y>\d{2}\d{2}?))?}
|
7
11
|
|
12
|
+
##
|
13
|
+
# Returns a +Date+ object for the date represented by +term+. Returns
|
14
|
+
# +nil+ if +term+ can not be parsed.
|
15
|
+
#
|
16
|
+
# If the year has two digits, it will be expanded into a four-digit by
|
17
|
+
# +Date.parse+.
|
18
|
+
#
|
19
|
+
# If the year is omitted, it will be inferred using +relative_to+ as a
|
20
|
+
# reference date. In this scenario, the resulting date will always be
|
21
|
+
# less than or equal to the reference date. If +relative_to+ omitted, it
|
22
|
+
# defaults to today's date.
|
23
|
+
#
|
24
|
+
# Available as both a class method and an instance method.
|
8
25
|
def parse(term, relative_to: Date.today)
|
9
26
|
term.match /\A#{US_DATE}\z/ do |m|
|
10
27
|
return Date.parse "#{m[:y]}/#{m[:m]}/#{m[:d]}" if m[:y]
|
11
28
|
|
12
29
|
day = Integer(m[:d])
|
13
30
|
month = Integer(m[:m])
|
14
|
-
year =
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
relative_to.year - 1
|
19
|
-
end
|
31
|
+
year = if month < relative_to.month || month == relative_to.month && day <= relative_to.day
|
32
|
+
relative_to.year
|
33
|
+
else
|
34
|
+
relative_to.year - 1
|
20
35
|
end
|
21
36
|
|
22
37
|
Date.new year, month, day
|
@@ -2,37 +2,45 @@ require 'search_lingo/parsers/date_parser'
|
|
2
2
|
require 'forwardable'
|
3
3
|
|
4
4
|
module SearchLingo
|
5
|
-
module Parsers
|
5
|
+
module Parsers # :nodoc:
|
6
6
|
class OpenDateRangeParser < DateParser
|
7
7
|
extend Forwardable
|
8
8
|
|
9
|
-
DATE_RANGE = /(?:-(?<max>#{US_DATE})|(?<min>#{US_DATE})-)/
|
10
|
-
|
11
9
|
def call(token)
|
12
|
-
token
|
13
|
-
|
14
|
-
|
10
|
+
parse_lte(token) || parse_gte(token)
|
11
|
+
end
|
12
|
+
|
13
|
+
def post_initialize(connection:, **)
|
14
|
+
@connection = connection
|
15
|
+
end
|
16
|
+
|
17
|
+
def_delegators :@connection, :quote_column_name, :quote_table_name
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def parse_lte(token) # :nodoc:
|
22
|
+
token.match /\A#{prefix}-(?<date>#{US_DATE})\z/ do |m|
|
23
|
+
if date = parse(m[:date])
|
15
24
|
[
|
16
25
|
:where,
|
17
26
|
"#{quote_table_name table}.#{quote_column_name column} <= ?",
|
18
27
|
date
|
19
|
-
]
|
20
|
-
|
21
|
-
|
28
|
+
]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_gte(token) # :nodoc:
|
34
|
+
token.match /\A#{prefix}(?<date>#{US_DATE})-\z/ do |m|
|
35
|
+
if date = parse(m[:date])
|
22
36
|
[
|
23
37
|
:where,
|
24
38
|
"#{quote_table_name table}.#{quote_column_name column} >= ?",
|
25
39
|
date
|
26
|
-
]
|
40
|
+
]
|
27
41
|
end
|
28
42
|
end
|
29
43
|
end
|
30
|
-
|
31
|
-
def post_initialize(connection:, **)
|
32
|
-
@connection = connection
|
33
|
-
end
|
34
|
-
|
35
|
-
def_delegators :@connection, :quote_column_name, :quote_table_name
|
36
44
|
end
|
37
45
|
end
|
38
46
|
end
|
data/lib/search_lingo/token.rb
CHANGED
@@ -3,23 +3,45 @@ require 'search_lingo/constants'
|
|
3
3
|
|
4
4
|
module SearchLingo
|
5
5
|
class Token < DelegateClass(String)
|
6
|
-
|
6
|
+
##
|
7
|
+
# Pattern for decomposing a token into a modifier and a term.
|
8
|
+
STRUCTURE = /\A(?:(#{MODIFIER}):[[:space:]]*)?"?(.+?)"?\z/
|
7
9
|
|
8
|
-
|
10
|
+
##
|
11
|
+
# Returns the modifier portion of the token. Returns +nil+ if token does
|
12
|
+
# not have a modifier.
|
13
|
+
#
|
14
|
+
# Token.new('foo: bar').modifier # => 'foo'
|
15
|
+
# Token.new('bar').modifier # => nil
|
16
|
+
def modifier
|
9
17
|
self[STRUCTURE, 1]
|
10
18
|
end
|
11
19
|
|
20
|
+
alias_method :operator, :modifier
|
21
|
+
|
22
|
+
##
|
23
|
+
# Returns the term portion of the token. If the term is wrapped in quotes,
|
24
|
+
# they are removed.
|
25
|
+
#
|
26
|
+
# Token.new('foo: bar').term # => 'bar'
|
27
|
+
# Token.new('bar').term # => 'bar'
|
28
|
+
# Token.new('"bar baz"').term # => 'bar baz'
|
12
29
|
def term
|
13
30
|
self[STRUCTURE, 2]
|
14
31
|
end
|
15
32
|
|
33
|
+
##
|
34
|
+
# Returns +true+ if token has a modifier and +false+ otherwise.
|
35
|
+
#
|
36
|
+
# Token.new('foo: bar').compound? # => true
|
37
|
+
# Token.new('bar').compound? # => false
|
16
38
|
def compound?
|
17
|
-
!!
|
39
|
+
!!modifier
|
18
40
|
end
|
19
41
|
|
20
|
-
def inspect
|
21
|
-
'#<%s String(%s)
|
22
|
-
[self.class, super,
|
42
|
+
def inspect # :nodoc:
|
43
|
+
'#<%s String(%s) modifier=%s term=%s>' %
|
44
|
+
[self.class, super, modifier.inspect, term.inspect]
|
23
45
|
end
|
24
46
|
end
|
25
47
|
end
|
@@ -8,14 +8,25 @@ module SearchLingo
|
|
8
8
|
include Enumerable
|
9
9
|
extend Forwardable
|
10
10
|
|
11
|
+
##
|
12
|
+
# Pattern for matching a simple token (a term without a modifier).
|
11
13
|
SIMPLE_TOKEN = /#{TERM}/
|
12
|
-
|
14
|
+
|
15
|
+
##
|
16
|
+
# Pattern for matching a compound token (a term with an optional modifier).
|
17
|
+
COMPOUND_TOKEN = /(?:#{MODIFIER}:[[:space:]]*)?#{TERM}/
|
18
|
+
|
19
|
+
##
|
20
|
+
# Pattern for matching the delimiter between tokens.
|
13
21
|
DELIMITER = /[[:space:]]*/
|
14
22
|
|
15
|
-
def initialize(query)
|
23
|
+
def initialize(query) # :nodoc:
|
16
24
|
@scanner = StringScanner.new query.strip
|
17
25
|
end
|
18
26
|
|
27
|
+
##
|
28
|
+
# Iterates over the query string. If called with a block, it yields each
|
29
|
+
# token. If called without a block, it returns an +Enumerator+.
|
19
30
|
def each
|
20
31
|
return to_enum(__callee__) unless block_given?
|
21
32
|
|
@@ -24,6 +35,9 @@ module SearchLingo
|
|
24
35
|
end
|
25
36
|
end
|
26
37
|
|
38
|
+
##
|
39
|
+
# Returns a Token for the next token in the query string. When the end of
|
40
|
+
# the query string is reached raises +StopIteration+.
|
27
41
|
def next
|
28
42
|
scanner.skip DELIMITER
|
29
43
|
token = scanner.scan COMPOUND_TOKEN
|
@@ -33,6 +47,9 @@ module SearchLingo
|
|
33
47
|
|
34
48
|
def_delegator :scanner, :reset
|
35
49
|
|
50
|
+
##
|
51
|
+
# Rewinds the query string from the last returned token and returns a
|
52
|
+
# Token for the next simple token.
|
36
53
|
def simplify
|
37
54
|
scanner.unscan
|
38
55
|
Token.new scanner.scan SIMPLE_TOKEN
|
data/lib/search_lingo/version.rb
CHANGED
data/search_lingo.gemspec
CHANGED
@@ -19,11 +19,14 @@ Gem::Specification.new do |spec|
|
|
19
19
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
20
|
spec.require_paths = ["lib"]
|
21
21
|
|
22
|
+
spec.rdoc_options += ['-x', 'examples/', '-x', 'test/']
|
23
|
+
|
22
24
|
spec.required_ruby_version = '>= 2.1'
|
23
25
|
|
24
26
|
spec.add_development_dependency "bundler", "~> 1.9"
|
25
27
|
spec.add_development_dependency "rake", "~> 10.0"
|
26
28
|
spec.add_development_dependency 'minitest'
|
27
|
-
spec.add_development_dependency 'minitest-focus'
|
28
29
|
spec.add_development_dependency 'pry'
|
30
|
+
spec.add_development_dependency 'sequel'
|
31
|
+
spec.add_development_dependency 'sqlite3'
|
29
32
|
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.
|
4
|
+
version: 1.0.2
|
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-
|
11
|
+
date: 2015-07-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -53,7 +53,7 @@ dependencies:
|
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
56
|
+
name: pry
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
@@ -67,7 +67,21 @@ dependencies:
|
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '0'
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
70
|
+
name: sequel
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sqlite3
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
72
86
|
requirements:
|
73
87
|
- - ">="
|
@@ -97,7 +111,7 @@ files:
|
|
97
111
|
- bin/console
|
98
112
|
- bin/setup
|
99
113
|
- examples/complex.rb
|
100
|
-
- examples/
|
114
|
+
- examples/sequel_example.rb
|
101
115
|
- lib/search_lingo.rb
|
102
116
|
- lib/search_lingo/abstract_search.rb
|
103
117
|
- lib/search_lingo/constants.rb
|
@@ -117,7 +131,11 @@ licenses:
|
|
117
131
|
- MIT
|
118
132
|
metadata: {}
|
119
133
|
post_install_message:
|
120
|
-
rdoc_options:
|
134
|
+
rdoc_options:
|
135
|
+
- "-x"
|
136
|
+
- examples/
|
137
|
+
- "-x"
|
138
|
+
- test/
|
121
139
|
require_paths:
|
122
140
|
- lib
|
123
141
|
required_ruby_version: !ruby/object:Gem::Requirement
|
@@ -132,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
132
150
|
version: '0'
|
133
151
|
requirements: []
|
134
152
|
rubyforge_project:
|
135
|
-
rubygems_version: 2.4.
|
153
|
+
rubygems_version: 2.4.8
|
136
154
|
signing_key:
|
137
155
|
specification_version: 4
|
138
156
|
summary: Framework for building simple query languages
|
data/examples/simple.rb
DELETED
@@ -1,31 +0,0 @@
|
|
1
|
-
class Task < ActiveRecord::Base
|
2
|
-
# Assume this model has attributes: :id, :due_date, and :name
|
3
|
-
end
|
4
|
-
|
5
|
-
class TaskSearch < SearchLingo::AbstractSearch
|
6
|
-
parser SearchLingo::Parsers::DateParser.new :tasks,
|
7
|
-
:due_date
|
8
|
-
parser SearchLingo::Parsers::DateRangeParser.new :tasks,
|
9
|
-
:due_date
|
10
|
-
parser SearchLingo::Parsers::LTEDateParser.new :tasks,
|
11
|
-
:due_date, connection: ActiveRecord::Base.connection
|
12
|
-
parser SearchLingo::Parsers::GTEDateParser.new :tasks,
|
13
|
-
:due_date, connection: ActiveRecord::Base.connection
|
14
|
-
|
15
|
-
parser do |token|
|
16
|
-
token.match /\Aid:\s*([[:digit:]]+)\z/ do |m|
|
17
|
-
[:where, { tasks: { id: m[1] } }]
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
def default_parse(token)
|
22
|
-
[:where, 'tasks.name LIKE ?', "%#{token}%"]
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
search = TaskSearch.new('6/4/15 id: 42 foo "bar baz"', Task)
|
27
|
-
search.results # => Task
|
28
|
-
# .where(tasks: { due_date: Date.new(2015, 6, 4) })
|
29
|
-
# .where(tasks: { id: '42' })
|
30
|
-
# .where('tasks.name LIKE ?', '%foo%')
|
31
|
-
# .where('tasks.name LIKE ?', '%bar baz%')
|