search_lingo 2.0.0.pre2 → 3.0.0.pre1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.gitlab-ci.yml +27 -0
- data/.rubocop.yml +8 -0
- data/.travis.yml +5 -2
- data/Gemfile +2 -0
- data/Rakefile +8 -2
- data/bin/console +4 -3
- data/examples/complex.rb +18 -6
- data/examples/sequel_example.rb +14 -12
- data/lib/search_lingo/abstract_search.rb +78 -32
- data/lib/search_lingo/constants.rb +18 -3
- data/lib/search_lingo/parsers/date_parser.rb +47 -24
- data/lib/search_lingo/parsers/mdy.rb +27 -16
- data/lib/search_lingo/parsers.rb +2 -0
- data/lib/search_lingo/token.rb +29 -14
- data/lib/search_lingo/tokenizer.rb +12 -17
- data/lib/search_lingo/version.rb +3 -1
- data/lib/search_lingo.rb +2 -0
- data/search_lingo.gemspec +24 -15
- metadata +50 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 06b21144b96415a618c188261eca38aab055ca218d294a966f96f543c41759a2
|
4
|
+
data.tar.gz: eff6897cd99f4abf444a9dd9c8c5d1d545b1da5277d507a8fb8b7a685756ca44
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fbd45adde84bf9a73d73c15cb91545e1e46b7ddbc2b0647506a087dcf77cf1aa7777ed29e57c8329a101b98b01b647308ed5657157fc60cc9858bd5281a25ca0
|
7
|
+
data.tar.gz: a05213f9fd31a2049f649bc935ac9d5edf0e4c8cd4bc390ee423bdc660180d78fb1a9802ffe7ff428b886b70911f93c98d469edb1dfb59b5c9e43c13c4eb1a74
|
data/.gitignore
CHANGED
data/.gitlab-ci.yml
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
before_script:
|
2
|
+
- gem install bundler --no-document -v '~> 2.3.11'
|
3
|
+
- bundle install --jobs="$(nproc)" --retry=3
|
4
|
+
|
5
|
+
build:rubocop:
|
6
|
+
stage: build
|
7
|
+
image: "ruby:3.1"
|
8
|
+
script:
|
9
|
+
- bundle exec rubocop
|
10
|
+
|
11
|
+
test:ruby-2.7:
|
12
|
+
stage: test
|
13
|
+
image: "ruby:2.7"
|
14
|
+
script:
|
15
|
+
- bundle exec rake test
|
16
|
+
|
17
|
+
test:ruby-3.0:
|
18
|
+
stage: test
|
19
|
+
image: "ruby:3.0"
|
20
|
+
script:
|
21
|
+
- bundle exec rake test
|
22
|
+
|
23
|
+
test:ruby-3.1:
|
24
|
+
stage: test
|
25
|
+
image: "ruby:3.1"
|
26
|
+
script:
|
27
|
+
- bundle exec rake test
|
data/.rubocop.yml
ADDED
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/Rakefile
CHANGED
@@ -1,9 +1,15 @@
|
|
1
|
-
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
2
4
|
require 'rake/testtask'
|
3
5
|
|
4
6
|
Rake::TestTask.new do |t|
|
5
7
|
t.libs << 'test'
|
6
|
-
t.pattern = 'test
|
8
|
+
t.pattern = 'test/**/*_test.rb'
|
7
9
|
end
|
8
10
|
|
9
11
|
task default: :test
|
12
|
+
|
13
|
+
task :rubocop do
|
14
|
+
system 'rubocop'
|
15
|
+
end
|
data/bin/console
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen-string-literal: true
|
2
3
|
|
3
|
-
require
|
4
|
-
require
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'search_lingo'
|
5
6
|
|
6
7
|
# You can add fixtures and/or initialization code here to make experimenting
|
7
8
|
# with your gem easier. You can also use a different console, if you like.
|
8
9
|
|
9
10
|
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
require
|
11
|
+
require 'pry'
|
11
12
|
Pry.start
|
12
13
|
|
13
14
|
# require "irb"
|
data/examples/complex.rb
CHANGED
@@ -1,9 +1,19 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
class Job < ActiveRecord::Base # :nodoc:
|
2
|
-
#
|
4
|
+
# Attributes:
|
5
|
+
# :id
|
6
|
+
# :date
|
7
|
+
# :name
|
3
8
|
end
|
4
9
|
|
5
10
|
class Receipt < ActiveRecord::Base # :nodoc:
|
6
|
-
#
|
11
|
+
# Attributes:
|
12
|
+
# :id
|
13
|
+
# :check_no
|
14
|
+
# :check_date
|
15
|
+
# :post_date
|
16
|
+
# :amount
|
7
17
|
end
|
8
18
|
|
9
19
|
module Parsers # :nodoc:
|
@@ -13,7 +23,7 @@ module Parsers # :nodoc:
|
|
13
23
|
end
|
14
24
|
|
15
25
|
def call(token, chain)
|
16
|
-
token.match
|
26
|
+
token.match(/\Aid:\s*([[:digit:]]+)\z/) do |m|
|
17
27
|
chain.where @table => { id: m[1] }
|
18
28
|
end
|
19
29
|
end
|
@@ -30,9 +40,11 @@ class JobSearch < SearchLingo::AbstractSearch # :nodoc:
|
|
30
40
|
end
|
31
41
|
|
32
42
|
class ReceiptSearch < SearchLingo::AbstractSearch # :nodoc:
|
33
|
-
|
34
|
-
|
35
|
-
|
43
|
+
# You might prefer to include SearchLingo::Parsers if you you are going to
|
44
|
+
# instantiate multiple DateParsers.
|
45
|
+
include SearchLingo::Parsers
|
46
|
+
parser DateParser.new Receipt.arel_table[:check_date]
|
47
|
+
parser DateParser.new Receipt.arel_table[:post_date], modifier: 'posted'
|
36
48
|
|
37
49
|
parser do |token, chain|
|
38
50
|
token.match(/\Aamount: (\d+(?:\.\d+)?)\z/) do |m|
|
data/examples/sequel_example.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'sequel'
|
2
4
|
require 'sqlite3'
|
3
5
|
|
@@ -17,12 +19,13 @@ require 'sqlite3'
|
|
17
19
|
|
18
20
|
class CategoryParser # :nodoc:
|
19
21
|
def call(token, chain)
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
22
|
+
return nil unless token.modifier == 'cat'
|
23
|
+
|
24
|
+
# This is not an ideal example. Sequel will join the categories table for
|
25
|
+
# each token that matches. I'm ignoring the problem since this is only an
|
26
|
+
# example.
|
27
|
+
category_name = Sequel.qualify :categories, :name
|
28
|
+
chain.join(:categories, id: :category_id).where category_name => token.term
|
26
29
|
end
|
27
30
|
end
|
28
31
|
|
@@ -53,12 +56,11 @@ class TaskSearch < SearchLingo::AbstractSearch # :nodoc:
|
|
53
56
|
# 7/4/17 => Tasks with due_date == Date.new(2017, 7, 4)
|
54
57
|
parser do |token, chain|
|
55
58
|
token.match %r{\A(?<m>\d{1,2})/(?<d>\d{1,2})/(?<y>\d{2}\d{2}?)\z} do |m|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
end
|
59
|
+
date = Date.parse "#{m[:y]}/#{m[:m]}/#{m[:d]}"
|
60
|
+
chain.where due_date: date
|
61
|
+
rescue ArgumentError
|
62
|
+
# Fail if Date.parse raises an ArgumentError
|
63
|
+
nil
|
62
64
|
end
|
63
65
|
end
|
64
66
|
|
@@ -1,17 +1,51 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'search_lingo/tokenizer'
|
2
4
|
|
3
5
|
module SearchLingo
|
6
|
+
##
|
7
|
+
# AbstractSearch is an abstract implementation from which search classes
|
8
|
+
# should inherit.
|
9
|
+
#
|
10
|
+
# Search classes are instantiated with a query string and a default scope on
|
11
|
+
# which to perform the search.
|
12
|
+
#
|
13
|
+
# Child classes must implement the #default_parse instance method, and they
|
14
|
+
# may optionally register one or more parsers.
|
15
|
+
#
|
16
|
+
# class MySearch < SearchLingo::AbstractSearch
|
17
|
+
# def default_parse(token, chain)
|
18
|
+
# chain.where attribute: token.term
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# class MyOtherSearch < SearchLingo::AbstractSearch
|
23
|
+
# parser SearchLingo::Parsers::DateParser.new Job.arel_table[:date]
|
24
|
+
#
|
25
|
+
# parser do |token, chain|
|
26
|
+
# token.match(/\Aid: [[:space:]]* (?<id>[[:digit:]]+)\z/x) do |m|
|
27
|
+
# chain.where id: m[:id]
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# def default_parse(token, chain)
|
32
|
+
# chain.where Job.arel_table[:name].matches "%#{token.term}%"
|
33
|
+
# end
|
34
|
+
# end
|
4
35
|
class AbstractSearch
|
5
|
-
attr_reader :query, :scope
|
36
|
+
attr_reader :query, :scope, :logger
|
6
37
|
|
7
38
|
##
|
8
39
|
# Instantiates a new search object. +query+ is the string that is to be
|
9
40
|
# parsed and compiled into an actual query. If +query+ is falsey, an empty
|
10
41
|
# string will be used. +scope+ is the object to which the compiled query
|
11
|
-
# should be sent, e.g., an +ActiveRecord
|
12
|
-
|
13
|
-
|
14
|
-
|
42
|
+
# should be sent, e.g., an +ActiveRecord::Relation+.
|
43
|
+
#
|
44
|
+
# MySearchClass.new 'foo bar: baz "froz quux"', Task.all
|
45
|
+
def initialize(query, scope, logger: nil)
|
46
|
+
@query = query || ''
|
47
|
+
@scope = scope
|
48
|
+
@logger = logger
|
15
49
|
end
|
16
50
|
|
17
51
|
##
|
@@ -27,8 +61,8 @@ module SearchLingo
|
|
27
61
|
# responds to +#call+. The parser will be send +#call+ with a single
|
28
62
|
# argument which will be a token from the query string.
|
29
63
|
#
|
30
|
-
#
|
31
|
-
#
|
64
|
+
# Raises +ArgumentError+ if +parser+ does not respond to +#call+ and no
|
65
|
+
# block is given.
|
32
66
|
#
|
33
67
|
# class MyParser
|
34
68
|
# def call(token)
|
@@ -43,10 +77,13 @@ module SearchLingo
|
|
43
77
|
# end
|
44
78
|
# end
|
45
79
|
def self.parser(parser = nil, &block)
|
46
|
-
|
47
|
-
|
80
|
+
if parser.respond_to? :call
|
81
|
+
parsers << parser
|
82
|
+
elsif block_given?
|
83
|
+
parsers << block
|
84
|
+
else
|
85
|
+
raise ArgumentError, 'parse must be called with block or callable object'
|
48
86
|
end
|
49
|
-
parsers << (parser || block)
|
50
87
|
end
|
51
88
|
|
52
89
|
##
|
@@ -64,26 +101,12 @@ module SearchLingo
|
|
64
101
|
##
|
65
102
|
# Load search results by composing query string tokens into a query chain.
|
66
103
|
#
|
67
|
-
# @query is
|
68
|
-
#
|
69
|
-
#
|
70
|
-
# the token is compound, the token is simplified and reprocessed as before.
|
71
|
-
# If still no parser succeeds, fall back on +#default_parse+.
|
104
|
+
# @query is broken down into tokens and parses each one in turn. The
|
105
|
+
# results of parsing each token are chained onto the end of +scope+ to
|
106
|
+
# compose the query.
|
72
107
|
def load_results
|
73
108
|
tokenizer.reduce(scope) do |chain, token|
|
74
|
-
|
75
|
-
# 1. Try each parser with token until :match is thrown.
|
76
|
-
parse token, chain
|
77
|
-
|
78
|
-
# 2. If :match not thrown and token is compund, simplify and retry.
|
79
|
-
if token.compound?
|
80
|
-
token = tokenizer.simplify
|
81
|
-
parse token, chain
|
82
|
-
end
|
83
|
-
|
84
|
-
# 3. If :match still not thrown, fall back on default parser.
|
85
|
-
default_parse token, chain
|
86
|
-
end
|
109
|
+
parse token, chain
|
87
110
|
end
|
88
111
|
end
|
89
112
|
|
@@ -93,6 +116,26 @@ module SearchLingo
|
|
93
116
|
@tokenizer ||= Tokenizer.new query
|
94
117
|
end
|
95
118
|
|
119
|
+
##
|
120
|
+
# Passes +token+ and +chain+ through the array of parsers until +:match+ is
|
121
|
+
# thrown. If none of the parsers match and the token is compound,
|
122
|
+
# simplifies the token and reruns the parsers. If no parsers match after
|
123
|
+
# the second pass or if the token was not compound, falls back on
|
124
|
+
# `#default_parse`.
|
125
|
+
def parse(token, chain)
|
126
|
+
catch(:match) do
|
127
|
+
run_parsers token, chain
|
128
|
+
|
129
|
+
if token.compound?
|
130
|
+
token = tokenizer.simplify
|
131
|
+
run_parsers token, chain
|
132
|
+
end
|
133
|
+
|
134
|
+
logger&.debug "default_parse token=#{token.inspect}"
|
135
|
+
default_parse token, chain
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
96
139
|
##
|
97
140
|
# Passes +token+ to each parser in turn. If a parser succeeds, throws
|
98
141
|
# +:match+ with the result.
|
@@ -100,10 +143,13 @@ module SearchLingo
|
|
100
143
|
# A parser succeeds if +call+ returns a truthy value. A successful parser
|
101
144
|
# will typically send something to +chain+ and return the result. In this
|
102
145
|
# way, the tokens of the search are reduced into a composed query.
|
103
|
-
def
|
146
|
+
def run_parsers(token, chain)
|
104
147
|
parsers.each do |parser|
|
105
148
|
result = parser.call token, chain
|
106
|
-
|
149
|
+
if result
|
150
|
+
logger&.debug "parser:#{parser.inspect} token=#{token.inspect}"
|
151
|
+
throw :match, result
|
152
|
+
end
|
107
153
|
end
|
108
154
|
nil
|
109
155
|
end
|
@@ -115,9 +161,9 @@ module SearchLingo
|
|
115
161
|
# This is a skeletal implementation that raises +NotImplementedError+.
|
116
162
|
# Child classes should provide their own implementation. At a minimum, that
|
117
163
|
# implementation should return +chain+. (Doing so would ignore +token+.)
|
118
|
-
def default_parse(
|
164
|
+
def default_parse(_token, _chain)
|
119
165
|
raise NotImplementedError,
|
120
|
-
|
166
|
+
"#default_parse must be implemented by #{self.class}"
|
121
167
|
end
|
122
168
|
end
|
123
169
|
end
|
@@ -1,9 +1,24 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
module SearchLingo
|
2
4
|
##
|
3
5
|
# Pattern for matching modifiers within a token.
|
4
|
-
MODIFIER = /[[:alnum:]]
|
6
|
+
MODIFIER = /[[:alnum:]]+/.freeze
|
7
|
+
|
8
|
+
##
|
9
|
+
# Pattern for matching a simple token.
|
10
|
+
SIMPLE_TOKEN = /"[^"]+"|[[:graph:]]+/.freeze
|
11
|
+
|
12
|
+
##
|
13
|
+
# Pattern for matching a simple or compound token.
|
14
|
+
SIMPLE_OR_COMPOUND_TOKEN = /(?:#{MODIFIER}:[[:space:]]*)?#{SIMPLE_TOKEN}/.freeze
|
15
|
+
|
16
|
+
##
|
17
|
+
# Pattern for matching a simple or compound token, with regex grouping to aid
|
18
|
+
# in decomposing the token into its modifier and term components.
|
19
|
+
SIMPLE_OR_COMPOUND_TOKEN_WITH_GROUPING = /\A(?:(#{MODIFIER}):[[:space:]]*)?(#{SIMPLE_TOKEN})\z/.freeze
|
5
20
|
|
6
21
|
##
|
7
|
-
# Pattern for matching
|
8
|
-
|
22
|
+
# Pattern for matching the delimiter between tokens.
|
23
|
+
DELIMITER = /[[:space:]]*/.freeze
|
9
24
|
end
|
@@ -1,36 +1,53 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'search_lingo/parsers/mdy'
|
2
4
|
|
3
5
|
module SearchLingo
|
4
6
|
module Parsers # :nodoc:
|
7
|
+
##
|
8
|
+
# DateParser is an example parser which handles dates that adhere to the
|
9
|
+
# MDY format used in the US. It uses `SearchLingo::Parsers::MDY.parse` to
|
10
|
+
# parse the date. It handles simple dates as well as closed and open-ended
|
11
|
+
# date ranges.
|
12
|
+
#
|
13
|
+
# Examples of single dates are 7/14, 7/14/17, and 7/14/2017.
|
14
|
+
# Examples of closed date ranges are 1/1-6/30 and 7/1/16-6/30/18.
|
15
|
+
# Examples of open date ranges are -6/30 and 7/1/17-.
|
5
16
|
class DateParser
|
6
17
|
include MDY
|
7
18
|
|
19
|
+
attr_reader :column, :prefix, :decorator
|
20
|
+
|
8
21
|
##
|
9
22
|
# Instantiates a new DateParser object.
|
10
23
|
#
|
11
24
|
# The required argument +column+ should be an Arel attribute.
|
12
25
|
#
|
13
26
|
# If present, the optional argument +modifier+ will be used as the
|
14
|
-
# operator which precedes the date term.
|
27
|
+
# token operator which precedes the date term.
|
15
28
|
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
|
29
|
+
# If a block is provided, it will be called with the filter chain. This
|
30
|
+
# is useful if you need to send additional messages to the filter chain
|
31
|
+
# which are independent of the content of the token, e.g., if you need to
|
32
|
+
# join another table.
|
33
|
+
#
|
34
|
+
# DateParser.new Model.arel_table[:date]
|
35
|
+
# DateParser.new Model.arel_table[:date], modifier: 'contract'
|
36
|
+
# DateParser.new Model.arel_table[:date] do |chain|
|
37
|
+
# chain.joins(:relation)
|
38
|
+
# end
|
39
|
+
def initialize(column, modifier: nil, &block)
|
19
40
|
@column = column
|
20
|
-
@prefix =
|
41
|
+
@prefix = /#{modifier}:[[:space:]]*/ if modifier
|
42
|
+
@decorator = block_given? ? block : ->(chain) { chain }
|
21
43
|
end
|
22
44
|
|
23
|
-
attr_reader :column, :prefix
|
24
|
-
|
25
45
|
##
|
26
|
-
# Attempts to parse
|
27
|
-
# range.
|
28
|
-
#
|
29
|
-
# Examples of single dates are 7/14, 7/14/17, and 7/14/2017.
|
30
|
-
# Examples of closed date ranges are 1/1-6/30 and 7/1/16-6/30/18.
|
31
|
-
# Examples of open date ranges are -6/30 and 7/1/17-.
|
46
|
+
# Attempts to parse +token+ as a single date, closed date range, or open
|
47
|
+
# date range. If parsing succeeds, the parser sends #where to +chain+
|
48
|
+
# with the appropriate Arel node and returns the result.
|
32
49
|
def call(token, chain)
|
33
|
-
catch :
|
50
|
+
catch :halt do
|
34
51
|
parse_single_date token, chain
|
35
52
|
parse_date_range token, chain
|
36
53
|
parse_lte_date token, chain
|
@@ -39,40 +56,46 @@ module SearchLingo
|
|
39
56
|
end
|
40
57
|
|
41
58
|
def inspect # :nodoc:
|
42
|
-
'
|
43
|
-
|
59
|
+
format '#<%<cls>s @prefix=%<prefix>s @column=%<column>s>',
|
60
|
+
cls: self.class,
|
61
|
+
prefix: prefix.inspect,
|
62
|
+
column: "#{column.relation.name}.#{column.name}".inspect
|
44
63
|
end
|
45
64
|
|
46
65
|
private
|
47
66
|
|
48
|
-
def parse_single_date(token, chain)
|
67
|
+
def parse_single_date(token, chain) # :nodoc:
|
49
68
|
token.match(/\A#{prefix}(?<date>#{US_DATE})\z/) do |m|
|
50
69
|
date = parse(m[:date]) or return nil
|
51
|
-
throw :
|
70
|
+
throw :halt, decorate(chain).where(column.eq(date))
|
52
71
|
end
|
53
72
|
end
|
54
73
|
|
55
|
-
def parse_date_range(token, chain)
|
74
|
+
def parse_date_range(token, chain) # :nodoc:
|
56
75
|
token.match(/\A#{prefix}(?<min>#{US_DATE})-(?<max>#{US_DATE})\z/) do |m|
|
57
76
|
min = parse(m[:min]) or return nil
|
58
77
|
max = parse(m[:max], relative_to: min.next_year) or return nil
|
59
|
-
throw :
|
78
|
+
throw :halt, decorate(chain).where(column.between(min..max))
|
60
79
|
end
|
61
80
|
end
|
62
81
|
|
63
|
-
def parse_lte_date(token, chain)
|
82
|
+
def parse_lte_date(token, chain) # :nodoc:
|
64
83
|
token.match(/\A#{prefix}-(?<date>#{US_DATE})\z/) do |m|
|
65
84
|
date = parse(m[:date]) or return nil
|
66
|
-
throw :
|
85
|
+
throw :halt, decorate(chain).where(column.lteq(date))
|
67
86
|
end
|
68
87
|
end
|
69
88
|
|
70
|
-
def parse_gte_date(token, chain)
|
89
|
+
def parse_gte_date(token, chain) # :nodoc:
|
71
90
|
token.match(/\A#{prefix}(?<date>#{US_DATE})-\z/) do |m|
|
72
91
|
date = parse(m[:date]) or return nil
|
73
|
-
throw :
|
92
|
+
throw :halt, decorate(chain).where(column.gteq(date))
|
74
93
|
end
|
75
94
|
end
|
95
|
+
|
96
|
+
def decorate(chain)
|
97
|
+
decorator.call chain
|
98
|
+
end
|
76
99
|
end
|
77
100
|
end
|
78
101
|
end
|
@@ -1,13 +1,20 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'date'
|
2
4
|
|
3
5
|
module SearchLingo
|
4
6
|
module Parsers # :nodoc:
|
7
|
+
##
|
8
|
+
# MDY provides a parser for dates that adhere to the M/D/Y format used in
|
9
|
+
# the US.
|
5
10
|
module MDY
|
6
11
|
##
|
7
12
|
# Pattern for matching US-formatted date strings.
|
8
13
|
#
|
9
14
|
# The year may be two or four digits, or it may be omitted.
|
10
|
-
US_DATE = %r{(?<m>\d{1,2})/(?<d>\d{1,2})(?:/(?<y>\d{2}\d{2}?))?}
|
15
|
+
US_DATE = %r{(?<m>\d{1,2})/(?<d>\d{1,2})(?:/(?<y>\d{2}\d{2}?))?}.freeze
|
16
|
+
|
17
|
+
module_function
|
11
18
|
|
12
19
|
##
|
13
20
|
# Returns a +Date+ object for the date represented by +term+. Returns
|
@@ -19,28 +26,32 @@ module SearchLingo
|
|
19
26
|
# If the year is omitted, it will be inferred using +relative_to+ as a
|
20
27
|
# reference date. In this scenario, the resulting date will always be
|
21
28
|
# less than or equal to the reference date. If +relative_to+ omitted, it
|
22
|
-
# defaults to today
|
29
|
+
# defaults to +Date.today+.
|
23
30
|
#
|
24
31
|
# Available as both a class method and an instance method.
|
25
32
|
def parse(term, relative_to: Date.today)
|
26
|
-
term.match
|
27
|
-
|
28
|
-
|
29
|
-
ref = relative_to
|
30
|
-
day = Integer(m[:d])
|
31
|
-
month = Integer(m[:m])
|
32
|
-
year = if month < ref.month || month == ref.month && day <= ref.day
|
33
|
-
ref.year
|
34
|
-
else
|
35
|
-
ref.year - 1
|
36
|
-
end
|
37
|
-
|
38
|
-
Date.new year, month, day
|
33
|
+
term.match(/\A#{US_DATE}\z/) do |m|
|
34
|
+
date = reformat_date m, relative_to
|
35
|
+
Date.parse date
|
39
36
|
end
|
40
37
|
rescue ArgumentError
|
38
|
+
# Fail if Date.parse or Date.new raise ArgumentError.
|
39
|
+
nil
|
41
40
|
end
|
42
41
|
|
43
|
-
|
42
|
+
def reformat_date(match, today) # :nodoc:
|
43
|
+
return match.values_at(:y, :m, :d).join('/') if match[:y]
|
44
|
+
|
45
|
+
month = Integer match[:m]
|
46
|
+
day = Integer match[:d]
|
47
|
+
year = if month < today.month || (month == today.month && day <= today.day)
|
48
|
+
today.year
|
49
|
+
else
|
50
|
+
today.year - 1
|
51
|
+
end
|
52
|
+
|
53
|
+
"#{year}/#{month}/#{day}"
|
54
|
+
end
|
44
55
|
end
|
45
56
|
end
|
46
57
|
end
|
data/lib/search_lingo/parsers.rb
CHANGED
data/lib/search_lingo/token.rb
CHANGED
@@ -1,33 +1,45 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'delegate'
|
2
4
|
require 'search_lingo/constants'
|
3
5
|
|
4
6
|
module SearchLingo
|
7
|
+
##
|
8
|
+
# Single token from a query string. A token consists of a term an an optional
|
9
|
+
# modifier. The term may be a word or multiple words contained within double
|
10
|
+
# quotes. The modifier is one or more alphanumeric characters. The modifier
|
11
|
+
# and term and separated by a colon followed by zero or more whitespace
|
12
|
+
# characters.
|
13
|
+
#
|
14
|
+
# The following are examples of tokens:
|
15
|
+
#
|
16
|
+
# Token.new('foo')
|
17
|
+
# Token.new('"foo bar"')
|
18
|
+
# Token.new('foo: bar')
|
19
|
+
# Token.new('foo: "bar baz"')
|
5
20
|
class Token < DelegateClass(String)
|
6
|
-
##
|
7
|
-
# Pattern for decomposing a token into a modifier and a term.
|
8
|
-
STRUCTURE = /\A(?:(#{MODIFIER}):[[:space:]]*)?"?(.+?)"?\z/
|
9
|
-
|
10
21
|
##
|
11
22
|
# Returns the modifier portion of the token. Returns +nil+ if token does
|
12
23
|
# not have a modifier.
|
13
24
|
#
|
14
|
-
# Token.new('foo: bar').modifier # =>
|
25
|
+
# Token.new('foo: bar').modifier # => "foo"
|
15
26
|
# Token.new('bar').modifier # => nil
|
16
27
|
def modifier
|
17
|
-
self[
|
28
|
+
self[SIMPLE_OR_COMPOUND_TOKEN_WITH_GROUPING, 1]
|
18
29
|
end
|
19
30
|
|
20
|
-
|
31
|
+
alias operator modifier
|
21
32
|
|
22
33
|
##
|
23
34
|
# Returns the term portion of the token. If the term is wrapped in quotes,
|
24
35
|
# they are removed.
|
25
36
|
#
|
26
|
-
# Token.new('foo: bar').term # =>
|
27
|
-
# Token.new('bar').term # =>
|
28
|
-
# Token.new('"bar baz"').term # =>
|
37
|
+
# Token.new('foo: bar').term # => "bar"
|
38
|
+
# Token.new('bar').term # => "bar"
|
39
|
+
# Token.new('"bar baz"').term # => "bar baz"
|
40
|
+
# Token.new('""').term # => ""
|
29
41
|
def term
|
30
|
-
self[
|
42
|
+
self[SIMPLE_OR_COMPOUND_TOKEN_WITH_GROUPING, 2].delete_prefix('"').delete_suffix('"')
|
31
43
|
end
|
32
44
|
|
33
45
|
##
|
@@ -36,12 +48,15 @@ module SearchLingo
|
|
36
48
|
# Token.new('foo: bar').compound? # => true
|
37
49
|
# Token.new('bar').compound? # => false
|
38
50
|
def compound?
|
39
|
-
|
51
|
+
!modifier.nil? && !modifier.empty?
|
40
52
|
end
|
41
53
|
|
42
54
|
def inspect # :nodoc:
|
43
|
-
'
|
44
|
-
|
55
|
+
format '#<%<cls>s String(%<str>s) modifier=%<mod>s term=%<term>s>',
|
56
|
+
cls: self.class,
|
57
|
+
str: super,
|
58
|
+
mod: modifier.inspect,
|
59
|
+
term: term.inspect
|
45
60
|
end
|
46
61
|
end
|
47
62
|
end
|
@@ -1,25 +1,21 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
1
3
|
require 'forwardable'
|
2
4
|
require 'strscan'
|
3
5
|
require 'search_lingo/constants'
|
4
6
|
require 'search_lingo/token'
|
5
7
|
|
6
8
|
module SearchLingo
|
9
|
+
##
|
10
|
+
# Tokenizer breaks down a query string into individual tokens.
|
11
|
+
#
|
12
|
+
# Tokenizer.new 'foo'
|
13
|
+
# Tokenizer.foo 'foo "bar baz"'
|
14
|
+
# Tokenizer.foo 'foo "bar baz" froz: quux'
|
7
15
|
class Tokenizer
|
8
16
|
include Enumerable
|
9
17
|
extend Forwardable
|
10
18
|
|
11
|
-
##
|
12
|
-
# Pattern for matching a simple token (a term without a modifier).
|
13
|
-
SIMPLE_TOKEN = /#{TERM}/
|
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.
|
21
|
-
DELIMITER = /[[:space:]]*/
|
22
|
-
|
23
19
|
def initialize(query) # :nodoc:
|
24
20
|
@scanner = StringScanner.new query.strip
|
25
21
|
end
|
@@ -30,18 +26,17 @@ module SearchLingo
|
|
30
26
|
def each
|
31
27
|
return to_enum(__callee__) unless block_given?
|
32
28
|
|
33
|
-
until scanner.eos?
|
34
|
-
yield self.next
|
35
|
-
end
|
29
|
+
yield self.next until scanner.eos?
|
36
30
|
end
|
37
31
|
|
38
32
|
##
|
39
|
-
# Returns a Token for the next token in the query string. When the end of
|
33
|
+
# Returns a +Token+ for the next token in the query string. When the end of
|
40
34
|
# the query string is reached raises +StopIteration+.
|
41
35
|
def next
|
42
36
|
scanner.skip DELIMITER
|
43
|
-
token = scanner.scan
|
37
|
+
token = scanner.scan SIMPLE_OR_COMPOUND_TOKEN
|
44
38
|
raise StopIteration unless token
|
39
|
+
|
45
40
|
Token.new token
|
46
41
|
end
|
47
42
|
|
data/lib/search_lingo/version.rb
CHANGED
data/lib/search_lingo.rb
CHANGED
data/search_lingo.gemspec
CHANGED
@@ -1,34 +1,43 @@
|
|
1
|
-
#
|
2
|
-
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
5
|
require 'search_lingo/version'
|
5
6
|
|
6
7
|
Gem::Specification.new do |spec|
|
7
|
-
spec.name =
|
8
|
+
spec.name = 'search_lingo'
|
8
9
|
spec.version = SearchLingo::VERSION
|
9
|
-
spec.authors = [
|
10
|
-
spec.email = [
|
10
|
+
spec.authors = ['John Parker']
|
11
|
+
spec.email = ['jparker@urgetopunt.com']
|
11
12
|
|
12
|
-
spec.summary =
|
13
|
-
spec.description =
|
14
|
-
|
15
|
-
|
13
|
+
spec.summary = 'Framework for defining and parsing search queries.'
|
14
|
+
spec.description = <<~DESCRIPTION
|
15
|
+
SearchLingo is a simple framework for defining simple query languages and
|
16
|
+
translating them into application-specific queries.
|
17
|
+
DESCRIPTION
|
18
|
+
spec.homepage = 'https://github.com/jparker/search_lingo'
|
19
|
+
spec.license = 'MIT'
|
16
20
|
|
17
|
-
spec.files = `git ls-files -z`.split("\x0").reject
|
18
|
-
|
21
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
22
|
+
f.match(%r{^(test|spec|features)/})
|
23
|
+
end
|
24
|
+
spec.bindir = 'exe'
|
19
25
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
-
spec.require_paths = [
|
26
|
+
spec.require_paths = ['lib']
|
21
27
|
|
22
28
|
spec.rdoc_options += ['-x', 'examples/', '-x', 'test/']
|
23
29
|
|
24
|
-
spec.required_ruby_version = '>= 2.
|
30
|
+
spec.required_ruby_version = '>= 2.7'
|
25
31
|
|
26
|
-
spec.add_development_dependency
|
27
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
32
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
28
33
|
spec.add_development_dependency 'minitest'
|
29
34
|
spec.add_development_dependency 'minitest-focus'
|
30
35
|
spec.add_development_dependency 'mocha'
|
31
36
|
spec.add_development_dependency 'pry'
|
37
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
38
|
+
spec.add_development_dependency 'rubocop', '~> 1.12'
|
39
|
+
spec.add_development_dependency 'rubocop-minitest'
|
32
40
|
spec.add_development_dependency 'sequel', '~> 5.0'
|
33
41
|
spec.add_development_dependency 'sqlite3'
|
42
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
34
43
|
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:
|
4
|
+
version: 3.0.0.pre1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- John Parker
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-04-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -16,30 +16,30 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '2.0'
|
20
20
|
type: :development
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '2.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: minitest
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "
|
31
|
+
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '
|
33
|
+
version: '0'
|
34
34
|
type: :development
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- - "
|
38
|
+
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: '
|
40
|
+
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name: minitest
|
42
|
+
name: minitest-focus
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
@@ -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: mocha
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
@@ -67,7 +67,7 @@ dependencies:
|
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '0'
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
70
|
+
name: pry
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
72
72
|
requirements:
|
73
73
|
- - ">="
|
@@ -81,7 +81,35 @@ dependencies:
|
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '13.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '13.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rubocop
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.12'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.12'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop-minitest
|
85
113
|
requirement: !ruby/object:Gem::Requirement
|
86
114
|
requirements:
|
87
115
|
- - ">="
|
@@ -122,8 +150,9 @@ dependencies:
|
|
122
150
|
- - ">="
|
123
151
|
- !ruby/object:Gem::Version
|
124
152
|
version: '0'
|
125
|
-
description:
|
126
|
-
|
153
|
+
description: |
|
154
|
+
SearchLingo is a simple framework for defining simple query languages and
|
155
|
+
translating them into application-specific queries.
|
127
156
|
email:
|
128
157
|
- jparker@urgetopunt.com
|
129
158
|
executables: []
|
@@ -131,6 +160,8 @@ extensions: []
|
|
131
160
|
extra_rdoc_files: []
|
132
161
|
files:
|
133
162
|
- ".gitignore"
|
163
|
+
- ".gitlab-ci.yml"
|
164
|
+
- ".rubocop.yml"
|
134
165
|
- ".travis.yml"
|
135
166
|
- Gemfile
|
136
167
|
- LICENSE.txt
|
@@ -153,7 +184,8 @@ files:
|
|
153
184
|
homepage: https://github.com/jparker/search_lingo
|
154
185
|
licenses:
|
155
186
|
- MIT
|
156
|
-
metadata:
|
187
|
+
metadata:
|
188
|
+
rubygems_mfa_required: 'true'
|
157
189
|
post_install_message:
|
158
190
|
rdoc_options:
|
159
191
|
- "-x"
|
@@ -166,15 +198,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
166
198
|
requirements:
|
167
199
|
- - ">="
|
168
200
|
- !ruby/object:Gem::Version
|
169
|
-
version: '2.
|
201
|
+
version: '2.7'
|
170
202
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
171
203
|
requirements:
|
172
204
|
- - ">"
|
173
205
|
- !ruby/object:Gem::Version
|
174
206
|
version: 1.3.1
|
175
207
|
requirements: []
|
176
|
-
|
177
|
-
rubygems_version: 2.7.7
|
208
|
+
rubygems_version: 3.3.7
|
178
209
|
signing_key:
|
179
210
|
specification_version: 4
|
180
211
|
summary: Framework for defining and parsing search queries.
|