elasticfusion 1.0.0
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/.codeclimate.yml +5 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +67 -0
- data/.travis.yml +22 -0
- data/Gemfile +3 -0
- data/LICENSE +116 -0
- data/README.md +40 -0
- data/Rakefile +11 -0
- data/elasticfusion.gemspec +31 -0
- data/lib/elasticfusion.rb +7 -0
- data/lib/elasticfusion/definition.rb +25 -0
- data/lib/elasticfusion/hooks.rb +14 -0
- data/lib/elasticfusion/jobs/reindex_job.rb +12 -0
- data/lib/elasticfusion/loader.rb +9 -0
- data/lib/elasticfusion/model/indexing.rb +37 -0
- data/lib/elasticfusion/model/searching.rb +16 -0
- data/lib/elasticfusion/model/settings.rb +63 -0
- data/lib/elasticfusion/search/builder.rb +60 -0
- data/lib/elasticfusion/search/errors.rb +32 -0
- data/lib/elasticfusion/search/peeker.rb +63 -0
- data/lib/elasticfusion/search/query/ast.rb +17 -0
- data/lib/elasticfusion/search/query/lexer.rb +90 -0
- data/lib/elasticfusion/search/query/parser.rb +147 -0
- data/lib/elasticfusion/search/query/value_sanitizer.rb +45 -0
- data/lib/elasticfusion/search/query/visitor.rb +22 -0
- data/lib/elasticfusion/search/query/visitors/elasticsearch.rb +52 -0
- data/lib/elasticfusion/search/query/visitors/polyadic_tree.rb +59 -0
- data/lib/elasticfusion/search/wrapper.rb +56 -0
- data/lib/elasticfusion/version.rb +4 -0
- metadata +213 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Elasticfusion
|
3
|
+
module Model
|
4
|
+
class Settings
|
5
|
+
delegate :[], :values_at, to: :@settings
|
6
|
+
|
7
|
+
def initialize(model, &block)
|
8
|
+
@model = model
|
9
|
+
|
10
|
+
@settings = DSL.build_settings(&block) if block_given?
|
11
|
+
@settings ||= {}
|
12
|
+
|
13
|
+
@settings[:searchable_mapping] = searchable_mapping
|
14
|
+
@settings[:searchable_fields] ||= @settings[:searchable_mapping].keys
|
15
|
+
end
|
16
|
+
|
17
|
+
def searchable_mapping
|
18
|
+
mapping = @model.__elasticsearch__.mapping.to_hash[
|
19
|
+
@model.__elasticsearch__.document_type.to_sym][:properties]
|
20
|
+
|
21
|
+
if @settings[:searchable_fields]
|
22
|
+
mapping.select { |field, _| @settings[:searchable_fields].include? field }
|
23
|
+
else
|
24
|
+
mapping
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class DSL
|
29
|
+
def self.build_settings(&block)
|
30
|
+
new.tap { |dsl| dsl.instance_eval(&block) }.settings
|
31
|
+
end
|
32
|
+
|
33
|
+
def settings
|
34
|
+
@settings ||= {}
|
35
|
+
end
|
36
|
+
|
37
|
+
def scopes
|
38
|
+
settings[:scopes] = yield
|
39
|
+
end
|
40
|
+
|
41
|
+
def keyword_field(field)
|
42
|
+
settings[:keyword_field] = field
|
43
|
+
end
|
44
|
+
|
45
|
+
def searchable_fields(ary)
|
46
|
+
settings[:searchable_fields] = ary
|
47
|
+
end
|
48
|
+
|
49
|
+
def default_query(query)
|
50
|
+
settings[:default_query] = query
|
51
|
+
end
|
52
|
+
|
53
|
+
def default_sort(sort)
|
54
|
+
settings[:default_sort] = sort
|
55
|
+
end
|
56
|
+
|
57
|
+
def reindex_when_updated(attributes)
|
58
|
+
settings[:reindex_when_updated] = attributes
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Elasticfusion
|
3
|
+
module Search
|
4
|
+
class Builder
|
5
|
+
def initialize(settings)
|
6
|
+
@scopes = settings[:scopes] || {}
|
7
|
+
@default_query = settings[:default_query] || { match_all: {} }
|
8
|
+
@default_sort = settings[:default_sort] || {}
|
9
|
+
|
10
|
+
@queries = []
|
11
|
+
@filters = []
|
12
|
+
@sorts = []
|
13
|
+
end
|
14
|
+
|
15
|
+
# Attribute writers
|
16
|
+
|
17
|
+
def query(q)
|
18
|
+
@queries << q
|
19
|
+
end
|
20
|
+
|
21
|
+
def filter(f)
|
22
|
+
@filters << f
|
23
|
+
end
|
24
|
+
|
25
|
+
def scope(scope, *args)
|
26
|
+
scope = @scopes[scope]
|
27
|
+
raise ArgumentError, "Unknown scope #{scope}" if scope.nil?
|
28
|
+
|
29
|
+
@filters << scope.call(*args)
|
30
|
+
end
|
31
|
+
|
32
|
+
def sort_by(field, direction)
|
33
|
+
raise Search::InvalidSortOrderError if %w(desc asc).exclude? direction.to_s
|
34
|
+
@sorts << { field => direction }
|
35
|
+
end
|
36
|
+
|
37
|
+
# An explicit setter for sort order tiebreaker.
|
38
|
+
# Makes the purpose of the code that uses it more clear, otherwise is identical to +sort_by+.
|
39
|
+
def ensure_deterministic_order_with_unique_field(field)
|
40
|
+
@sorts << { field => :desc }
|
41
|
+
end
|
42
|
+
|
43
|
+
# Attribute readers
|
44
|
+
|
45
|
+
def queries
|
46
|
+
return @queries if @queries.any?
|
47
|
+
@default_query
|
48
|
+
end
|
49
|
+
|
50
|
+
def filters
|
51
|
+
@filters
|
52
|
+
end
|
53
|
+
|
54
|
+
def sorts
|
55
|
+
return @sorts if @sorts.any?
|
56
|
+
@default_sort
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Elasticfusion
|
3
|
+
module Search
|
4
|
+
class SearchError < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
class ImbalancedParenthesesError < SearchError
|
8
|
+
def message
|
9
|
+
'Imbalanced parentheses.'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class InvalidFieldValueError < SearchError
|
14
|
+
attr_reader :field, :value
|
15
|
+
|
16
|
+
def initialize(field, value)
|
17
|
+
@field = field
|
18
|
+
@value = value
|
19
|
+
end
|
20
|
+
|
21
|
+
def message
|
22
|
+
"\"#{value}\" is not a valid value for \"#{field}\"."
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class InvalidSortOrderError < SearchError
|
27
|
+
def message
|
28
|
+
'Invalid sort order. Accepted values: "desc" and "asc".'
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Elasticfusion
|
4
|
+
module Search
|
5
|
+
# An instance of +Peeker+ can return consecutive (previous and next)
|
6
|
+
# records for a given record and an instance of +Search::Wrapper+.
|
7
|
+
#
|
8
|
+
# Under the hood, it uses search_after parameters (see
|
9
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/5.2/search-request-search-after.html).
|
10
|
+
class Peeker
|
11
|
+
def initialize(wrapper)
|
12
|
+
@wrapper = wrapper
|
13
|
+
end
|
14
|
+
|
15
|
+
def next_record(current_record)
|
16
|
+
request = @wrapper.elasticsearch_request
|
17
|
+
|
18
|
+
first_record_after(current_record, request)
|
19
|
+
end
|
20
|
+
|
21
|
+
def previous_record(current_record)
|
22
|
+
request = @wrapper.elasticsearch_request
|
23
|
+
request[:sort] = reverse_sort request[:sort]
|
24
|
+
|
25
|
+
first_record_after(current_record, request)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def first_record_after(record, request)
|
31
|
+
indexed = record.as_indexed_json
|
32
|
+
|
33
|
+
request[:size] = 1
|
34
|
+
request[:search_after] = request[:sort].map do |sort_hash|
|
35
|
+
field, _direction = sort_hash.first
|
36
|
+
|
37
|
+
if indexed[field].is_a? Time
|
38
|
+
ms_since_epoch = (indexed[field].to_f * 1000).to_i
|
39
|
+
ms_since_epoch
|
40
|
+
else
|
41
|
+
indexed[field]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
@wrapper.perform(request).records.first
|
46
|
+
end
|
47
|
+
|
48
|
+
def reverse_sort(sort)
|
49
|
+
sort.map do |sort_hash|
|
50
|
+
field, direction = sort_hash.first
|
51
|
+
|
52
|
+
inverse_direction = if direction.to_sym == :asc
|
53
|
+
:desc
|
54
|
+
else
|
55
|
+
:asc
|
56
|
+
end
|
57
|
+
|
58
|
+
{ field => inverse_direction }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Elasticfusion
|
3
|
+
module Search
|
4
|
+
module Query
|
5
|
+
Expression = Struct.new(:op, :left, :right)
|
6
|
+
|
7
|
+
# See visitors/polyadic_tree_visitor.rb
|
8
|
+
PolyadicExpression = Struct.new(:op, :children)
|
9
|
+
|
10
|
+
NegatedClause = Struct.new(:body)
|
11
|
+
|
12
|
+
FieldTerm = Struct.new(:field, :qualifier, :value)
|
13
|
+
|
14
|
+
Term = Struct.new(:body)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'strscan'
|
3
|
+
|
4
|
+
module Elasticfusion
|
5
|
+
module Search
|
6
|
+
module Query
|
7
|
+
class Lexer
|
8
|
+
TOKENS = {
|
9
|
+
whitespace: /\s+/,
|
10
|
+
and: /AND|,/,
|
11
|
+
or: /OR|\|/,
|
12
|
+
not: /NOT|-/,
|
13
|
+
field_query_delimiter: /:/,
|
14
|
+
field_qualifier: /less than|more than|earlier than|later than/,
|
15
|
+
|
16
|
+
safe_string_until: /(\s*)(AND|OR|,|\||"|\(|\))/,
|
17
|
+
quoted_string: /"(?:[^\\]|\\.)*?"/,
|
18
|
+
string_with_balanced_parentheses_until: /AND|OR|,|\|/
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
def initialize(string, searchable_fields)
|
22
|
+
@scanner = StringScanner.new(string)
|
23
|
+
@field_regex = /(#{searchable_fields.join('|')}):/ if searchable_fields.any?
|
24
|
+
end
|
25
|
+
|
26
|
+
def match(token)
|
27
|
+
@scanner.scan TOKENS[token]
|
28
|
+
end
|
29
|
+
|
30
|
+
def skip(token)
|
31
|
+
@scanner.skip TOKENS[token]
|
32
|
+
end
|
33
|
+
|
34
|
+
def match_field
|
35
|
+
return unless @field_regex
|
36
|
+
field = @scanner.scan @field_regex
|
37
|
+
if field
|
38
|
+
field[0..-2] # remove field query delimiter (":")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def left_parentheses
|
43
|
+
@scanner.skip /\(/
|
44
|
+
end
|
45
|
+
|
46
|
+
def right_parentheses(expected_count)
|
47
|
+
@scanner.skip /\){,#{expected_count}}/
|
48
|
+
end
|
49
|
+
|
50
|
+
# May contain words, numbers, spaces, dashes, and underscores.
|
51
|
+
def safe_string
|
52
|
+
match_until TOKENS[:safe_string_until]
|
53
|
+
end
|
54
|
+
|
55
|
+
# May contain any characters except for quotes (the latter are allowed when escaped).
|
56
|
+
def quoted_string
|
57
|
+
string = match(:quoted_string)
|
58
|
+
|
59
|
+
if string
|
60
|
+
string[1..-2] # ignore quotes
|
61
|
+
.gsub(/\\"/, '"')
|
62
|
+
.gsub(/\\\\/, '\\')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def string_with_balanced_parentheses
|
67
|
+
string = match_until TOKENS[:string_with_balanced_parentheses_until]
|
68
|
+
|
69
|
+
if string
|
70
|
+
opening_parens = string.count('(')
|
71
|
+
|
72
|
+
balanced = string.split(')')[0..opening_parens].join(')')
|
73
|
+
balanced += ')' if opening_parens > 0 && string.ends_with?(')')
|
74
|
+
|
75
|
+
cutoff = string.length - balanced.length
|
76
|
+
@scanner.pos -= cutoff
|
77
|
+
|
78
|
+
balanced.strip
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# StringScanner#scan_until returns everything up to and including the regex.
|
83
|
+
# To avoid including the pattern, we use a lookahead.
|
84
|
+
def match_until(regex)
|
85
|
+
@scanner.scan /.+?(?=#{regex.source}|\z)/
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'elasticfusion/search/query/lexer'
|
3
|
+
require 'elasticfusion/search/query/ast'
|
4
|
+
|
5
|
+
module Elasticfusion
|
6
|
+
module Search
|
7
|
+
module Query
|
8
|
+
class Parser
|
9
|
+
def initialize(query, searchable_fields = [])
|
10
|
+
@lexer = Lexer.new(query, searchable_fields)
|
11
|
+
end
|
12
|
+
|
13
|
+
delegate :match, :skip,
|
14
|
+
:match_field, :left_parentheses, :right_parentheses,
|
15
|
+
:safe_string, :quoted_string, :string_with_balanced_parentheses, to: :@lexer
|
16
|
+
|
17
|
+
# query = disjunction
|
18
|
+
# ;
|
19
|
+
# disjunction = conjunction , [ ( "OR" | "|" ) , disjunction ]
|
20
|
+
# ;
|
21
|
+
# conjunction = boolean clause , [ ( "AND" | "," ) , conjunction ]
|
22
|
+
# ;
|
23
|
+
# boolean clause = ( "NOT" | "-" ) , boolean clause
|
24
|
+
# | clause
|
25
|
+
# ;
|
26
|
+
# clause = parenthesized expression
|
27
|
+
# | field term
|
28
|
+
# | term
|
29
|
+
# ;
|
30
|
+
# parenthesized expression = "(" , disjunction , ")"
|
31
|
+
# ;
|
32
|
+
# field term = field , ":" , [ field qualifier ] , safe string
|
33
|
+
# ;
|
34
|
+
# term = quoted string
|
35
|
+
# | string with balanced parentheses
|
36
|
+
# ;
|
37
|
+
|
38
|
+
def ast
|
39
|
+
disjunction
|
40
|
+
end
|
41
|
+
|
42
|
+
def disjunction
|
43
|
+
skip :whitespace
|
44
|
+
left = conjunction
|
45
|
+
|
46
|
+
skip :whitespace
|
47
|
+
connective = match :or
|
48
|
+
|
49
|
+
skip :whitespace
|
50
|
+
right = disjunction if connective
|
51
|
+
|
52
|
+
if right
|
53
|
+
Expression.new :or, left, right
|
54
|
+
else
|
55
|
+
left
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def conjunction
|
60
|
+
skip :whitespace
|
61
|
+
left = boolean_clause
|
62
|
+
|
63
|
+
skip :whitespace
|
64
|
+
connective = match :and
|
65
|
+
|
66
|
+
skip :whitespace
|
67
|
+
right = conjunction if connective
|
68
|
+
|
69
|
+
if right
|
70
|
+
Expression.new :and, left, right
|
71
|
+
else
|
72
|
+
left
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def boolean_clause
|
77
|
+
negation = match :not
|
78
|
+
skip :whitespace
|
79
|
+
|
80
|
+
if negation
|
81
|
+
body = boolean_clause
|
82
|
+
redundant_negation = body.is_a?(NegatedClause)
|
83
|
+
|
84
|
+
if redundant_negation
|
85
|
+
body.body
|
86
|
+
else
|
87
|
+
NegatedClause.new body
|
88
|
+
end
|
89
|
+
else
|
90
|
+
clause
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def clause
|
95
|
+
parenthesized_expression || field_term || term
|
96
|
+
end
|
97
|
+
|
98
|
+
def parenthesized_expression
|
99
|
+
opening_parens = left_parentheses
|
100
|
+
|
101
|
+
if opening_parens
|
102
|
+
body = disjunction
|
103
|
+
closing_parens = right_parentheses(opening_parens)
|
104
|
+
|
105
|
+
if opening_parens == closing_parens
|
106
|
+
body
|
107
|
+
else
|
108
|
+
raise ImbalancedParenthesesError
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def field_term
|
114
|
+
field = match_field
|
115
|
+
|
116
|
+
if field
|
117
|
+
qualifier = field_qualifier
|
118
|
+
|
119
|
+
skip :whitespace if qualifier
|
120
|
+
|
121
|
+
field_query = safe_string
|
122
|
+
|
123
|
+
FieldTerm.new field, qualifier, field_query
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def term
|
128
|
+
string = quoted_string || string_with_balanced_parentheses
|
129
|
+
|
130
|
+
Term.new string.downcase
|
131
|
+
end
|
132
|
+
|
133
|
+
FIELD_QUALIFIERS = { 'less than' => :lt,
|
134
|
+
'more than' => :gt,
|
135
|
+
'earlier than' => :lt,
|
136
|
+
'later than' => :gt }.freeze
|
137
|
+
|
138
|
+
def field_qualifier
|
139
|
+
skip :whitespace
|
140
|
+
|
141
|
+
qualifier = match :field_qualifier
|
142
|
+
FIELD_QUALIFIERS[qualifier]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|