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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48dc212ce20b6ca6d502ad3776598375e9aadf5aba67448a0db0833ff9ae9b22
4
- data.tar.gz: 9110f9d208a47032ded854d4541eb2d212359599f8b6ddc13bdb6add5c4ee6ab
3
+ metadata.gz: 06b21144b96415a618c188261eca38aab055ca218d294a966f96f543c41759a2
4
+ data.tar.gz: eff6897cd99f4abf444a9dd9c8c5d1d545b1da5277d507a8fb8b7a685756ca44
5
5
  SHA512:
6
- metadata.gz: a9bfec6dad76471dedab609b8eef445c3b61bfb3eaded66d362849a1f42192de117485b7a9c4157aa8277a7a8a34df5f9c9f1aa15997d001751c92a3259172fe
7
- data.tar.gz: 38a894d13baec1cb47a68bf805243fe238355dcf65cc35effae2d5c3d304082883fa73e60de07340d2f29c74ad15e89b5b1ca6d20235a63bfd6cd545b07d67af
6
+ metadata.gz: fbd45adde84bf9a73d73c15cb91545e1e46b7ddbc2b0647506a087dcf77cf1aa7777ed29e57c8329a101b98b01b647308ed5657157fc60cc9858bd5281a25ca0
7
+ data.tar.gz: a05213f9fd31a2049f649bc935ac9d5edf0e4c8cd4bc390ee423bdc660180d78fb1a9802ffe7ff428b886b70911f93c98d469edb1dfb59b5c9e43c13c4eb1a74
data/.gitignore CHANGED
@@ -8,3 +8,4 @@
8
8
  /pkg/
9
9
  /spec/reports/
10
10
  /tmp/
11
+ .tool-versions
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
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
4
+ TargetRubyVersion: 2.7.0
5
+ Style/TrailingCommaInArrayLiteral:
6
+ EnforcedStyleForMultiline: consistent_comma
7
+ Style/TrailingCommaInHashLiteral:
8
+ EnforcedStyleForMultiline: consistent_comma
data/.travis.yml CHANGED
@@ -1,4 +1,7 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.4.4
4
- - 2.5.1
3
+ - "2.7"
4
+ - "3.0"
5
+ - "3.1"
6
+ before_install:
7
+ - gem install bundler
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen-string-literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  # Specify your gem's dependencies in search_lingo.gemspec
data/Rakefile CHANGED
@@ -1,9 +1,15 @@
1
- require "bundler/gem_tasks"
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/**/test_*.rb'
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 "bundler/setup"
4
- require "search_lingo"
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 "pry"
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
- # Assume this model has attributes: :id, :date, :name
4
+ # Attributes:
5
+ # :id
6
+ # :date
7
+ # :name
3
8
  end
4
9
 
5
10
  class Receipt < ActiveRecord::Base # :nodoc:
6
- # Assume this model has attributes: :id, :check_no, :check_date, :post_date, :amount
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 /\Aid:\s*([[:digit:]]+)\z/ do |m|
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
- parser SearchLingo::Parsers::DateParser.new Receipt.arel_table[:check_date]
34
- parser SearchLingo::Parsers::DateParser.new Receipt.arel_table[:post_date],
35
- modifier: 'posted'
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|
@@ -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
- if token.modifier == 'cat'
21
- # This is kind of broken. The categories table will be joined once each
22
- # time this parser matches a token.
23
- chain.join(:categories, id: :category_id)
24
- .where Sequel.qualify('categories', 'name') => token.term
25
- end
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
- begin
57
- date = Date.parse "#{m[:y]}/#{m[:m]}/#{m[:d]}"
58
- chain.where due_date: date
59
- rescue ArgumentError
60
- # Date.parse raised an ArgumentError
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+ model.
12
- def initialize(query, scope)
13
- @query = query || ''
14
- @scope = scope
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
- # If both a callable object and a block are given, or if neither a callable
31
- # object nor a block are given, an +ArgumentError+ will be raised.
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
- unless block_given? ^ parser.respond_to?(:call)
47
- raise ArgumentError, 'parse must be called with callable OR block'
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 borken down into tokens, and each token is passed through the
68
- # list of defined parsers. If a parser is successful, +:match+ is thrown,
69
- # processing moves on to the next token. If none of the parsers succeed and
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
- catch(:match) do
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 parse(token, chain)
146
+ def run_parsers(token, chain)
104
147
  parsers.each do |parser|
105
148
  result = parser.call token, chain
106
- throw :match, result if result
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(token, chain)
164
+ def default_parse(_token, _chain)
119
165
  raise NotImplementedError,
120
- "#default_parse must be implemented by #{self.class}"
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 terms within a token.
8
- TERM = /"[^"]+"|[[:graph:]]+/
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
- # DateParser.new Booking.arel_table[:date]
17
- # DateParser.new Contract.arel_table[:date], modifier: 'contract'
18
- def initialize(column, modifier: nil)
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 = %r{#{modifier}:[[:space:]]*} if modifier
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 the token as a date, closed date range, or open date
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 :stop do
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
- '#<%s:0x%x @prefix=%s @column=%s>' %
43
- [self.class, object_id << 1, prefix.inspect, column.inspect]
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 :stop, chain.where(column.eq(date))
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 :stop, chain.where(column.in(min..max))
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 :stop, chain.where(column.lteq(date))
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 :stop, chain.where(column.gteq(date))
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's date.
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 /\A#{US_DATE}\z/ do |m|
27
- return Date.parse "#{m[:y]}/#{m[:m]}/#{m[:d]}" if m[:y]
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
- module_function :parse
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
@@ -1 +1,3 @@
1
+ # frozen-string-literal: true
2
+
1
3
  require 'search_lingo/parsers/date_parser'
@@ -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 # => 'foo'
25
+ # Token.new('foo: bar').modifier # => "foo"
15
26
  # Token.new('bar').modifier # => nil
16
27
  def modifier
17
- self[STRUCTURE, 1]
28
+ self[SIMPLE_OR_COMPOUND_TOKEN_WITH_GROUPING, 1]
18
29
  end
19
30
 
20
- alias_method :operator, :modifier
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 # => 'bar'
27
- # Token.new('bar').term # => 'bar'
28
- # Token.new('"bar baz"').term # => 'bar baz'
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[STRUCTURE, 2]
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
- !!modifier
51
+ !modifier.nil? && !modifier.empty?
40
52
  end
41
53
 
42
54
  def inspect # :nodoc:
43
- '#<%s String(%s) modifier=%s term=%s>' %
44
- [self.class, super, modifier.inspect, term.inspect]
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 COMPOUND_TOKEN
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
 
@@ -1,3 +1,5 @@
1
+ # frozen-string-literal: true
2
+
1
3
  module SearchLingo
2
- VERSION = '2.0.0.pre2'
4
+ VERSION = '3.0.0.pre1'
3
5
  end
data/lib/search_lingo.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen-string-literal: true
2
+
1
3
  require 'search_lingo/version'
2
4
  require 'search_lingo/abstract_search'
3
5
  require 'search_lingo/parsers'
data/search_lingo.gemspec CHANGED
@@ -1,34 +1,43 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
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 = "search_lingo"
8
+ spec.name = 'search_lingo'
8
9
  spec.version = SearchLingo::VERSION
9
- spec.authors = ["John Parker"]
10
- spec.email = ["jparker@urgetopunt.com"]
10
+ spec.authors = ['John Parker']
11
+ spec.email = ['jparker@urgetopunt.com']
11
12
 
12
- spec.summary = %q{Framework for defining and parsing search queries.}
13
- spec.description = %q{SearchLingo is a simple framework for defining simple query languages and translating them into application-specific queries.}
14
- spec.homepage = "https://github.com/jparker/search_lingo"
15
- spec.license = "MIT"
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 { |f| f.match(%r{^(test|spec|features)/}) }
18
- spec.bindir = "exe"
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 = ["lib"]
26
+ spec.require_paths = ['lib']
21
27
 
22
28
  spec.rdoc_options += ['-x', 'examples/', '-x', 'test/']
23
29
 
24
- spec.required_ruby_version = '>= 2.1'
30
+ spec.required_ruby_version = '>= 2.7'
25
31
 
26
- spec.add_development_dependency "bundler", "~> 1.9"
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: 2.0.0.pre2
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: 2018-10-02 00:00:00.000000000 Z
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: '1.9'
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: '1.9'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: rake
28
+ name: minitest
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '10.0'
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: '10.0'
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: minitest-focus
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: mocha
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: pry
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: SearchLingo is a simple framework for defining simple query languages
126
- and translating them into application-specific queries.
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.1'
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
- rubyforge_project:
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.