search_lingo 1.0.0.beta2
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 +9 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +177 -0
- data/Rakefile +9 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/examples/complex.rb +83 -0
- data/examples/simple.rb +31 -0
- data/lib/search_lingo/abstract_search.rb +63 -0
- data/lib/search_lingo/parsers/date_parser.rb +34 -0
- data/lib/search_lingo/parsers/date_range_parser.rb +15 -0
- data/lib/search_lingo/parsers/gte_date_parser.rb +25 -0
- data/lib/search_lingo/parsers/lte_date_parser.rb +25 -0
- data/lib/search_lingo/parsers/mdy.rb +30 -0
- data/lib/search_lingo/token.rb +23 -0
- data/lib/search_lingo/tokenizer.rb +44 -0
- data/lib/search_lingo/version.rb +3 -0
- data/lib/search_lingo.rb +7 -0
- data/search_lingo.gemspec +27 -0
- metadata +136 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3dfc725892e1b65db47327257cd8c082536dd74b
|
4
|
+
data.tar.gz: 78e4297906559efcb5cedc54b003cf2abfaf4ac1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5359eff62098245796f55f5abe4d3e663e0e56677b7925ba1267bf0e00dda71cfefa84d87c416a57a6b7f21699ca7b2b9914d96b74f37b02fa0597a35c32b931
|
7
|
+
data.tar.gz: aaa8f3414ebf996194ce415e0270fa2321290900ac98092ea34d2dcb43608e3f6df4e62146fea960814063f753ca7b05fd9fb8a3637399dd1459fe786c7700aa
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 John Parker
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
# SearchLingo
|
2
|
+
|
3
|
+
SearchLingo is a framework for defining simple, user-friendly query languages
|
4
|
+
and translating them into their underlying queries.
|
5
|
+
|
6
|
+
It was originally designed after I found myself implementing the same basic
|
7
|
+
query parsing over and over again across different projects. I wanted a way to
|
8
|
+
simplify the process without having to worry about application-specific aspects
|
9
|
+
of searching.
|
10
|
+
|
11
|
+
The way the searches themselves are performed lies outside the scope of this
|
12
|
+
project. Although originally designed to work with basic searching with
|
13
|
+
ActiveRecord models, it should be usable with other data stores provided they
|
14
|
+
let you chain queries together onto a single object.
|
15
|
+
|
16
|
+
## Installation
|
17
|
+
|
18
|
+
Add this line to your application's Gemfile:
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
gem 'search_lingo'
|
22
|
+
```
|
23
|
+
|
24
|
+
And then execute:
|
25
|
+
|
26
|
+
$ bundle
|
27
|
+
|
28
|
+
Or install it yourself as:
|
29
|
+
|
30
|
+
$ gem install search_lingo
|
31
|
+
|
32
|
+
## Usage
|
33
|
+
|
34
|
+
Create a class which inherits from SearchLingo::AbstractSearch. Provide an
|
35
|
+
implementation of <code>#default_parse</code> in that class. Register parsers
|
36
|
+
for specific types of search tokens using the <code>parser</code> class method.
|
37
|
+
|
38
|
+
Instantiate your search class by passing in the query string and the scope on
|
39
|
+
which to perform the search. Use the <code>#results</code> method to compile
|
40
|
+
the search and return the results.
|
41
|
+
|
42
|
+
Take a look at the examples/ directory for more concrete examples.
|
43
|
+
|
44
|
+
## How It Works
|
45
|
+
|
46
|
+
A search is instantiated with a query string and a search scope (commonly an
|
47
|
+
ActiveRecord model). The search breaks the query string down into a series of
|
48
|
+
tokens, and each token is processed by a declared series of parsers. If a
|
49
|
+
parser succeeds, the process immediately terminates and advances to the next
|
50
|
+
token. If none of the declared parsers succeeds, and the token is compound --
|
51
|
+
that is, the token is composed of an operator and a term (e.g., "foo: bar"),
|
52
|
+
the token is simplified and then processed by the declared parsers again. If
|
53
|
+
the second pass also fails, then the (now simplified) token falls through to
|
54
|
+
the <code>#default_parse</code> method defined by the search class. (It is
|
55
|
+
important that this method be implemented in such a way that it always
|
56
|
+
succeeds.)
|
57
|
+
|
58
|
+
## Search Classes
|
59
|
+
|
60
|
+
Search classes should inherit from SearchLingo::AbstractSearch and they should
|
61
|
+
override the <code>#default_parse</code> instance method. It is important that
|
62
|
+
this method be defined in such a way that it always succeeds, as the results
|
63
|
+
will be sent to the query object via <code>#public_send</code>. In addtion, the
|
64
|
+
class method <code>parser</code> can be used to declare additional parsers that
|
65
|
+
should be used by the search class. (See the section "Parsing" for more
|
66
|
+
information on what makes a suitable parser.)
|
67
|
+
|
68
|
+
## Parsers
|
69
|
+
|
70
|
+
Any object that can respond to the <code>#call</code> method can be used as a
|
71
|
+
parser. If the parser succeeds, it should return an Array of arguments that can
|
72
|
+
be sent to the query object using <code>#public_send</code>, e.g.,
|
73
|
+
<code>[:where, { id: 42 }]</code>. If the parser fails, it should return a
|
74
|
+
falsey value (typically nil).
|
75
|
+
|
76
|
+
For very simple parsers which need not be reusable, you can pass the
|
77
|
+
parsing logic to the <code>parser</code> method as a block:
|
78
|
+
|
79
|
+
class MySearch < SearchLingo::AbstractSearch
|
80
|
+
parser do |token|
|
81
|
+
token.match /\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
|
82
|
+
[:where, { id: m[1] }]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
Parsers can also be implemented as lambdas:
|
88
|
+
|
89
|
+
module Parsers
|
90
|
+
ID_PARSER = lambda do |token|
|
91
|
+
token.match h/\Aid:[[:space:]]*([[:digit:]]+)\z/ do |m|
|
92
|
+
[:where, { id: m[1] }]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
class MySearch < SearchLingo::AbstractSearch
|
98
|
+
parser Parsers::ID_PARSER
|
99
|
+
end
|
100
|
+
|
101
|
+
class MyOtherSearch < SearchLingo::AbstractSearch
|
102
|
+
parser Parsers::ID_PARSER
|
103
|
+
end
|
104
|
+
|
105
|
+
Finally, for the most complicated cases, you could implement parsers as
|
106
|
+
classes:
|
107
|
+
|
108
|
+
module Parsers
|
109
|
+
class IdParser
|
110
|
+
def initialize(table, operator = nil)
|
111
|
+
@table = table
|
112
|
+
@prefix = /#{operator}:\s*/ if operator
|
113
|
+
end
|
114
|
+
|
115
|
+
def call(token)
|
116
|
+
token.match /\A#{@prefix}([[:digit:]]+)\z/ do |m|
|
117
|
+
[:where, { @table => { id: m[1] } }]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
class EventSearch < SearchLingo::AbstractSearch
|
124
|
+
parser Parsers::IdParser.new :events # => match "42"
|
125
|
+
parser Parsers::IdParser.new :categories, 'category' # => match "category: 42"
|
126
|
+
end
|
127
|
+
|
128
|
+
class CategorySearch < SearchLingo::AbstractSearch
|
129
|
+
parser Parsers::IdParser.new :categories
|
130
|
+
end
|
131
|
+
|
132
|
+
## Tokenization
|
133
|
+
|
134
|
+
Queries are comprised of one or more tokens separated by spaces. A simple token
|
135
|
+
is a term which can be a single word (or date, number, etc.) or multiple terms
|
136
|
+
within a pair of double quotes. A compound token is a simple token preceded by
|
137
|
+
an operator followed by zero or more spaces.
|
138
|
+
|
139
|
+
QUERY := TOKEN*
|
140
|
+
TOKEN := COMPOUND_TOKEN | TERM
|
141
|
+
COMPOUND_TOKEN := OPERATOR TERM
|
142
|
+
OPERATOR := [[:graph:]]+:
|
143
|
+
TERM := "[^"]*" | [[:graph:]]+
|
144
|
+
|
145
|
+
Terms can be things like:
|
146
|
+
|
147
|
+
* foo
|
148
|
+
* "foo bar"
|
149
|
+
* 6/14/15
|
150
|
+
* 1000.00
|
151
|
+
|
152
|
+
Operators can be things like:
|
153
|
+
|
154
|
+
* foo:
|
155
|
+
* bar_baz:
|
156
|
+
|
157
|
+
(If you want to perform a query with a term that could potentially be parsed as
|
158
|
+
an operator, you would place the term in quotes, i.e., "foo:".)
|
159
|
+
|
160
|
+
## Development
|
161
|
+
|
162
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
163
|
+
`bin/console` for an interactive prompt that will allow you to experiment.
|
164
|
+
|
165
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
166
|
+
release a new version, update the version number in `version.rb`, and then run
|
167
|
+
`bundle exec rake release` to create a git tag for the version, push git
|
168
|
+
commits and tags, and push the `.gem` file to
|
169
|
+
[rubygems.org](https://rubygems.org).
|
170
|
+
|
171
|
+
## Contributing
|
172
|
+
|
173
|
+
1. Fork it ( https://github.com/jparker/search_lingo/fork )
|
174
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
175
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
176
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
177
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "search_lingo"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
require "pry"
|
11
|
+
Pry.start
|
12
|
+
|
13
|
+
# require "irb"
|
14
|
+
# IRB.start
|
data/bin/setup
ADDED
data/examples/complex.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
class Job < ActiveRecord::Base
|
2
|
+
# Assume this model has attributes: :id, :date, :name
|
3
|
+
end
|
4
|
+
|
5
|
+
class Receipt < ActiveRecord::Base
|
6
|
+
# Assume this model has attributes: :id, :check_no, :check_date, :post_date, :amount
|
7
|
+
end
|
8
|
+
|
9
|
+
module Parsers
|
10
|
+
class IdParser
|
11
|
+
def initialize(table)
|
12
|
+
@table = table
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(token)
|
16
|
+
token.match /\Aid:\s*([[:digit:]]+)\z/ do |m|
|
17
|
+
[:where, { @table => { id: m[1] } }]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class JobSearch < AbstractSearch
|
24
|
+
parser Parsers::IdParser.new Job.table_name
|
25
|
+
|
26
|
+
parser SearchLingo::Parsers::DateParser.new Job.table_name,
|
27
|
+
:date
|
28
|
+
parser SearchLingo::Parsers::DateRangeParser.new Job.table_name,
|
29
|
+
:date
|
30
|
+
parser SearchLingo::Parsers::LTEDateParser.new Job.table_name,
|
31
|
+
:date, connection: Job.connection
|
32
|
+
parser SearchLingo::Parsers::GTEDateParser.new Job.table_name,
|
33
|
+
:date, connection: Job.connection
|
34
|
+
|
35
|
+
def default_parse(token)
|
36
|
+
[:where, 'jobs.name LIKE ?', "%#{token}%"]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class ReceiptSearch < AbstractSearch
|
41
|
+
parser Parsers::IdParser.new Receipt.table_name
|
42
|
+
|
43
|
+
parser SearchLingo::Parsers::DateParser.new Receipt.table_name,
|
44
|
+
:check_date
|
45
|
+
parser SearchLingo::Parsers::DateRangeParser.new Receipt.table_name,
|
46
|
+
:check_date
|
47
|
+
parser SearchLingo::Parsers::LTEDateParser.new Receipt.table_name,
|
48
|
+
:check_date, connection: Receipt.connection
|
49
|
+
parser SearchLingo::Parsers::GTEDateParser.new Receipt.table_name,
|
50
|
+
:check_date, connection: Receipt.connection
|
51
|
+
|
52
|
+
parser SearchLingo::Parsers::DateParser.new Receipt.table_name,
|
53
|
+
:post_date, 'posted'
|
54
|
+
parser SearchLingo::Parsers::DateRangeParser.new Receipt.table_name,
|
55
|
+
:post_date, 'posted'
|
56
|
+
parser SearchLingo::Parsers::LTEDateParser.new Receipt.table_name,
|
57
|
+
:post_date, 'posted', connection: Receipt.connection
|
58
|
+
parser SearchLingo::Parsers::GTEDateParser.new Receipt.table_name,
|
59
|
+
:post_date, 'posted', connection: Receipt.connection
|
60
|
+
|
61
|
+
parser do |token|
|
62
|
+
token.match /\Aamount: (\d+(?:\.\d+)?)\z/ do |m|
|
63
|
+
[:where, { receipts: { amount: m[1] } }]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def default_parse(token)
|
68
|
+
[:where, 'receipts.check_no LIKE ?', token]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
search = JobSearch.new('6/4/15-6/5/15 id: 42 "foo bar"')
|
73
|
+
search.results # => Job
|
74
|
+
# .where('jobs' => { date: Date.new(2015, 6, 4)..Date.new(2015, 6, 5) })
|
75
|
+
# .where('jobs' => { id: '42' })
|
76
|
+
# .where('jobs.name LIKE ?', '%foo bar%')
|
77
|
+
|
78
|
+
search = ReceiptSearch.new('-6/4/15 posted: 6/5/15- amount: 1000 123')
|
79
|
+
search.results # => Receipt
|
80
|
+
# .where('"receipts"."check_date" <= ?', Date.new(2015, 6, 4))
|
81
|
+
# .where('"receipts"."post_date" >= ?', Date.new(2015, 6, 5))
|
82
|
+
# .where(receipts: { amount: '1000' })
|
83
|
+
# .where('receipts.check_no LIKE ?', 123)
|
data/examples/simple.rb
ADDED
@@ -0,0 +1,31 @@
|
|
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%')
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'search_lingo/tokenizer'
|
2
|
+
|
3
|
+
module SearchLingo
|
4
|
+
class AbstractSearch
|
5
|
+
def initialize(query, scope, tokenizer: Tokenizer)
|
6
|
+
@query = query || ''
|
7
|
+
@scope = scope
|
8
|
+
@tokenizer = tokenizer.new @query
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :query, :scope, :tokenizer
|
12
|
+
|
13
|
+
def self.parsers
|
14
|
+
@parsers ||= []
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.parser(callable = nil, &block)
|
18
|
+
unless callable || block_given?
|
19
|
+
raise ArgumentError, '.parse must be called with callable or block'
|
20
|
+
end
|
21
|
+
if callable && block_given?
|
22
|
+
warn "WARNING: parse called with callable and block (#{caller.first}"
|
23
|
+
end
|
24
|
+
|
25
|
+
parsers << (callable || block)
|
26
|
+
end
|
27
|
+
|
28
|
+
def parsers
|
29
|
+
self.class.parsers
|
30
|
+
end
|
31
|
+
|
32
|
+
def results
|
33
|
+
@results ||= conditions.inject(scope) do |query, condition|
|
34
|
+
query.public_send(*condition)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def conditions
|
39
|
+
tokenizer.inject([]) do |conditions, token|
|
40
|
+
conditions << catch(:match) do
|
41
|
+
parse token
|
42
|
+
if token.compound?
|
43
|
+
token = tokenizer.simplify
|
44
|
+
parse token
|
45
|
+
end
|
46
|
+
default_parse token
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def parse(token)
|
52
|
+
parsers.each do |parser|
|
53
|
+
result = parser.call token
|
54
|
+
throw :match, result if result
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def default_parse(token)
|
59
|
+
raise NotImplementedError,
|
60
|
+
"#default_parse must be implemented by #{self.class}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'search_lingo/parsers/mdy'
|
2
|
+
|
3
|
+
module SearchLingo
|
4
|
+
module Parsers
|
5
|
+
class DateParser
|
6
|
+
include MDY
|
7
|
+
|
8
|
+
def initialize(table, column, operator = nil, **options)
|
9
|
+
@table = table
|
10
|
+
@column = column
|
11
|
+
@prefix = %r{#{operator}:\s*} if operator
|
12
|
+
|
13
|
+
post_initialize **options
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_reader :table, :column, :prefix
|
17
|
+
|
18
|
+
def call(token)
|
19
|
+
token.match /\A#{prefix}(?<date>#{US_DATE})\z/ do |m|
|
20
|
+
date = parse m[:date]
|
21
|
+
[:where, { table => { column => date } }] if date
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def post_initialize(**)
|
26
|
+
end
|
27
|
+
|
28
|
+
def inspect
|
29
|
+
'#<%s:0x%x @table=%s @column=%s @prefix=%s>' %
|
30
|
+
[self.class, object_id << 1, table.inspect, column.inspect, prefix.inspect]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'search_lingo/parsers/date_parser'
|
2
|
+
|
3
|
+
module SearchLingo
|
4
|
+
module Parsers
|
5
|
+
class DateRangeParser < DateParser
|
6
|
+
def call(token)
|
7
|
+
token.match /\A#{prefix}(?<min>#{US_DATE})-(?<max>#{US_DATE})\z/ do |m|
|
8
|
+
min = parse m[:min]
|
9
|
+
max = parse m[:max], relative_to: min.next_year if min
|
10
|
+
[:where, { table => { column => min..max } }] if min && max
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'search_lingo/parsers/date_parser'
|
2
|
+
require 'forwardable'
|
3
|
+
|
4
|
+
module SearchLingo
|
5
|
+
module Parsers
|
6
|
+
class GTEDateParser < DateParser
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def call(token)
|
10
|
+
token.match /\A#{prefix}(?<date>#{US_DATE})-\z/ do |m|
|
11
|
+
date = parse m[:date]
|
12
|
+
if date
|
13
|
+
[:where, "#{quote_table_name table}.#{quote_column_name column} >= ?", date]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def post_initialize(connection:, **)
|
19
|
+
@connection = connection
|
20
|
+
end
|
21
|
+
|
22
|
+
def_delegators :@connection, :quote_column_name, :quote_table_name
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'search_lingo/parsers/date_parser'
|
2
|
+
require 'forwardable'
|
3
|
+
|
4
|
+
module SearchLingo
|
5
|
+
module Parsers
|
6
|
+
class LTEDateParser < DateParser
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def call(token)
|
10
|
+
token.match /\A#{prefix}-(?<date>#{US_DATE})\z/ do |m|
|
11
|
+
date = parse m[:date]
|
12
|
+
if date
|
13
|
+
[:where, "#{quote_table_name table}.#{quote_column_name column} <= ?", date]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def post_initialize(connection:, **)
|
19
|
+
@connection = connection
|
20
|
+
end
|
21
|
+
|
22
|
+
def_delegators :@connection, :quote_column_name, :quote_table_name
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module SearchLingo
|
4
|
+
module Parsers
|
5
|
+
module MDY
|
6
|
+
US_DATE = %r{(?<m>\d{1,2})/(?<d>\d{1,2})(?:/(?<y>\d{2}\d{2}?))?}
|
7
|
+
|
8
|
+
def parse(term, relative_to: Date.today)
|
9
|
+
term.match /\A#{US_DATE}\z/ do |m|
|
10
|
+
return Date.parse "#{m[:y]}/#{m[:m]}/#{m[:d]}" if m[:y]
|
11
|
+
|
12
|
+
day = Integer(m[:d])
|
13
|
+
month = Integer(m[:m])
|
14
|
+
year = begin
|
15
|
+
if month < relative_to.month || month == relative_to.month && day <= relative_to.day
|
16
|
+
relative_to.year
|
17
|
+
else
|
18
|
+
relative_to.year - 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
Date.new year, month, day
|
23
|
+
end
|
24
|
+
rescue ArgumentError
|
25
|
+
end
|
26
|
+
|
27
|
+
module_function :parse
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
|
3
|
+
module SearchLingo
|
4
|
+
class Token < DelegateClass(String)
|
5
|
+
FORMAT = %r{\A(?:(\S+):\s*)?"?(.+?)"?\z}
|
6
|
+
|
7
|
+
def operator
|
8
|
+
self[FORMAT, 1]
|
9
|
+
end
|
10
|
+
|
11
|
+
def term
|
12
|
+
self[FORMAT, 2]
|
13
|
+
end
|
14
|
+
|
15
|
+
def compound?
|
16
|
+
!!operator
|
17
|
+
end
|
18
|
+
|
19
|
+
def inspect
|
20
|
+
'#<%s %s operator=%s term=%s>' % [self.class, super, operator.inspect, term.inspect]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'strscan'
|
3
|
+
require 'search_lingo/token'
|
4
|
+
|
5
|
+
module SearchLingo
|
6
|
+
class Tokenizer
|
7
|
+
include Enumerable
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
SIMPLE = %r{"[^"]*"|[[:graph:]]+}
|
11
|
+
COMPOUND = %r{(?:[[:graph:]]+:[[:space:]]*)?#{SIMPLE}}
|
12
|
+
DELIMITER = %r{[[:space:]]*}
|
13
|
+
|
14
|
+
def initialize(query)
|
15
|
+
@scanner = StringScanner.new query.strip
|
16
|
+
end
|
17
|
+
|
18
|
+
def enum
|
19
|
+
Enumerator.new do |yielder|
|
20
|
+
until scanner.eos?
|
21
|
+
token = scanner.scan COMPOUND
|
22
|
+
if token
|
23
|
+
yielder << Token.new(token)
|
24
|
+
end
|
25
|
+
scanner.skip DELIMITER
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def_delegator :scanner, :reset
|
31
|
+
def_delegators :enum, :each, :next
|
32
|
+
|
33
|
+
def simplify
|
34
|
+
scanner.unscan
|
35
|
+
Token.new(scanner.scan(SIMPLE)).tap do
|
36
|
+
scanner.skip DELIMITER
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
attr_reader :scanner
|
43
|
+
end
|
44
|
+
end
|
data/lib/search_lingo.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'search_lingo/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "search_lingo"
|
8
|
+
spec.version = SearchLingo::VERSION
|
9
|
+
spec.authors = ["John Parker"]
|
10
|
+
spec.email = ["jparker@urgetopunt.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Framework for building simple query languages}
|
13
|
+
spec.description = %q{Framework for building simple query language for converting user queries into database queries.}
|
14
|
+
spec.homepage = "https://github.com/jparker/search_lingo"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_development_dependency "bundler", "~> 1.9"
|
23
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
24
|
+
spec.add_development_dependency 'minitest'
|
25
|
+
spec.add_development_dependency 'minitest-focus'
|
26
|
+
spec.add_development_dependency 'pry'
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: search_lingo
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.beta2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- John Parker
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-06-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.9'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.9'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest-focus
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry
|
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
|
+
description: Framework for building simple query language for converting user queries
|
84
|
+
into database queries.
|
85
|
+
email:
|
86
|
+
- jparker@urgetopunt.com
|
87
|
+
executables: []
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- ".gitignore"
|
92
|
+
- ".travis.yml"
|
93
|
+
- Gemfile
|
94
|
+
- LICENSE.txt
|
95
|
+
- README.md
|
96
|
+
- Rakefile
|
97
|
+
- bin/console
|
98
|
+
- bin/setup
|
99
|
+
- examples/complex.rb
|
100
|
+
- examples/simple.rb
|
101
|
+
- lib/search_lingo.rb
|
102
|
+
- lib/search_lingo/abstract_search.rb
|
103
|
+
- lib/search_lingo/parsers/date_parser.rb
|
104
|
+
- lib/search_lingo/parsers/date_range_parser.rb
|
105
|
+
- lib/search_lingo/parsers/gte_date_parser.rb
|
106
|
+
- lib/search_lingo/parsers/lte_date_parser.rb
|
107
|
+
- lib/search_lingo/parsers/mdy.rb
|
108
|
+
- lib/search_lingo/token.rb
|
109
|
+
- lib/search_lingo/tokenizer.rb
|
110
|
+
- lib/search_lingo/version.rb
|
111
|
+
- search_lingo.gemspec
|
112
|
+
homepage: https://github.com/jparker/search_lingo
|
113
|
+
licenses:
|
114
|
+
- MIT
|
115
|
+
metadata: {}
|
116
|
+
post_install_message:
|
117
|
+
rdoc_options: []
|
118
|
+
require_paths:
|
119
|
+
- lib
|
120
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
126
|
+
requirements:
|
127
|
+
- - ">"
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: 1.3.1
|
130
|
+
requirements: []
|
131
|
+
rubyforge_project:
|
132
|
+
rubygems_version: 2.4.7
|
133
|
+
signing_key:
|
134
|
+
specification_version: 4
|
135
|
+
summary: Framework for building simple query languages
|
136
|
+
test_files: []
|