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.
@@ -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