search-engine-for-typesense 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/LICENSE +21 -0
- data/README.md +148 -0
- data/app/search_engine/search_engine/app_info.rb +11 -0
- data/app/search_engine/search_engine/index_partition_job.rb +170 -0
- data/lib/generators/search_engine/install/install_generator.rb +20 -0
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
- data/lib/generators/search_engine/model/model_generator.rb +86 -0
- data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
- data/lib/search-engine-for-typesense.rb +12 -0
- data/lib/search_engine/active_record_syncable.rb +247 -0
- data/lib/search_engine/admin/stopwords.rb +125 -0
- data/lib/search_engine/admin/synonyms.rb +125 -0
- data/lib/search_engine/admin.rb +12 -0
- data/lib/search_engine/ast/and.rb +52 -0
- data/lib/search_engine/ast/binary_op.rb +75 -0
- data/lib/search_engine/ast/eq.rb +19 -0
- data/lib/search_engine/ast/group.rb +18 -0
- data/lib/search_engine/ast/gt.rb +12 -0
- data/lib/search_engine/ast/gte.rb +12 -0
- data/lib/search_engine/ast/in.rb +28 -0
- data/lib/search_engine/ast/lt.rb +12 -0
- data/lib/search_engine/ast/lte.rb +12 -0
- data/lib/search_engine/ast/matches.rb +55 -0
- data/lib/search_engine/ast/node.rb +176 -0
- data/lib/search_engine/ast/not_eq.rb +13 -0
- data/lib/search_engine/ast/not_in.rb +24 -0
- data/lib/search_engine/ast/or.rb +52 -0
- data/lib/search_engine/ast/prefix.rb +51 -0
- data/lib/search_engine/ast/raw.rb +41 -0
- data/lib/search_engine/ast/unary_op.rb +43 -0
- data/lib/search_engine/ast.rb +101 -0
- data/lib/search_engine/base/creation.rb +727 -0
- data/lib/search_engine/base/deletion.rb +80 -0
- data/lib/search_engine/base/display_coercions.rb +36 -0
- data/lib/search_engine/base/hydration.rb +312 -0
- data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
- data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
- data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
- data/lib/search_engine/base/index_maintenance.rb +459 -0
- data/lib/search_engine/base/indexing_dsl.rb +255 -0
- data/lib/search_engine/base/joins.rb +479 -0
- data/lib/search_engine/base/model_dsl.rb +472 -0
- data/lib/search_engine/base/presets.rb +43 -0
- data/lib/search_engine/base/pretty_printer.rb +315 -0
- data/lib/search_engine/base/relation_delegation.rb +42 -0
- data/lib/search_engine/base/scopes.rb +113 -0
- data/lib/search_engine/base/updating.rb +92 -0
- data/lib/search_engine/base.rb +38 -0
- data/lib/search_engine/bulk.rb +284 -0
- data/lib/search_engine/cache.rb +33 -0
- data/lib/search_engine/cascade.rb +531 -0
- data/lib/search_engine/cli/doctor.rb +631 -0
- data/lib/search_engine/cli/support.rb +217 -0
- data/lib/search_engine/cli.rb +222 -0
- data/lib/search_engine/client/http_adapter.rb +63 -0
- data/lib/search_engine/client/request_builder.rb +92 -0
- data/lib/search_engine/client/services/base.rb +74 -0
- data/lib/search_engine/client/services/collections.rb +161 -0
- data/lib/search_engine/client/services/documents.rb +214 -0
- data/lib/search_engine/client/services/operations.rb +152 -0
- data/lib/search_engine/client/services/search.rb +190 -0
- data/lib/search_engine/client/services.rb +29 -0
- data/lib/search_engine/client.rb +765 -0
- data/lib/search_engine/client_options.rb +20 -0
- data/lib/search_engine/collection_resolver.rb +191 -0
- data/lib/search_engine/collections_graph.rb +330 -0
- data/lib/search_engine/compiled_params.rb +143 -0
- data/lib/search_engine/compiler.rb +383 -0
- data/lib/search_engine/config/observability.rb +27 -0
- data/lib/search_engine/config/presets.rb +92 -0
- data/lib/search_engine/config/selection.rb +16 -0
- data/lib/search_engine/config/typesense.rb +48 -0
- data/lib/search_engine/config/validators.rb +97 -0
- data/lib/search_engine/config.rb +917 -0
- data/lib/search_engine/console_helpers.rb +130 -0
- data/lib/search_engine/deletion.rb +103 -0
- data/lib/search_engine/dispatcher.rb +125 -0
- data/lib/search_engine/dsl/parser.rb +582 -0
- data/lib/search_engine/engine.rb +167 -0
- data/lib/search_engine/errors.rb +290 -0
- data/lib/search_engine/filters/sanitizer.rb +189 -0
- data/lib/search_engine/hydration/materializers.rb +808 -0
- data/lib/search_engine/hydration/selection_context.rb +96 -0
- data/lib/search_engine/indexer/batch_planner.rb +76 -0
- data/lib/search_engine/indexer/bulk_import.rb +626 -0
- data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
- data/lib/search_engine/indexer/retry_policy.rb +103 -0
- data/lib/search_engine/indexer.rb +747 -0
- data/lib/search_engine/instrumentation.rb +308 -0
- data/lib/search_engine/joins/guard.rb +202 -0
- data/lib/search_engine/joins/resolver.rb +95 -0
- data/lib/search_engine/logging/color.rb +78 -0
- data/lib/search_engine/logging/format_helpers.rb +92 -0
- data/lib/search_engine/logging/partition_progress.rb +53 -0
- data/lib/search_engine/logging_subscriber.rb +388 -0
- data/lib/search_engine/mapper.rb +785 -0
- data/lib/search_engine/multi.rb +286 -0
- data/lib/search_engine/multi_result.rb +186 -0
- data/lib/search_engine/notifications/compact_logger.rb +675 -0
- data/lib/search_engine/observability.rb +162 -0
- data/lib/search_engine/operations.rb +58 -0
- data/lib/search_engine/otel.rb +227 -0
- data/lib/search_engine/partitioner.rb +128 -0
- data/lib/search_engine/ranking_plan.rb +118 -0
- data/lib/search_engine/registry.rb +158 -0
- data/lib/search_engine/relation/compiler.rb +711 -0
- data/lib/search_engine/relation/deletion.rb +37 -0
- data/lib/search_engine/relation/dsl/filters.rb +624 -0
- data/lib/search_engine/relation/dsl/selection.rb +240 -0
- data/lib/search_engine/relation/dsl.rb +903 -0
- data/lib/search_engine/relation/dx/dry_run.rb +59 -0
- data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
- data/lib/search_engine/relation/dx.rb +231 -0
- data/lib/search_engine/relation/materializers.rb +118 -0
- data/lib/search_engine/relation/options.rb +138 -0
- data/lib/search_engine/relation/state.rb +274 -0
- data/lib/search_engine/relation/updating.rb +44 -0
- data/lib/search_engine/relation.rb +623 -0
- data/lib/search_engine/result.rb +664 -0
- data/lib/search_engine/schema.rb +1083 -0
- data/lib/search_engine/sources/active_record_source.rb +185 -0
- data/lib/search_engine/sources/base.rb +62 -0
- data/lib/search_engine/sources/lambda_source.rb +55 -0
- data/lib/search_engine/sources/sql_source.rb +196 -0
- data/lib/search_engine/sources.rb +71 -0
- data/lib/search_engine/stale_rules.rb +160 -0
- data/lib/search_engine/test/minitest_assertions.rb +57 -0
- data/lib/search_engine/test/offline_client.rb +134 -0
- data/lib/search_engine/test/rspec_matchers.rb +77 -0
- data/lib/search_engine/test/stub_client.rb +201 -0
- data/lib/search_engine/test.rb +66 -0
- data/lib/search_engine/test_autoload.rb +8 -0
- data/lib/search_engine/update.rb +35 -0
- data/lib/search_engine/version.rb +7 -0
- data/lib/search_engine.rb +332 -0
- data/lib/tasks/search_engine.rake +501 -0
- data/lib/tasks/search_engine_doctor.rake +16 -0
- metadata +225 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
module DSL
|
|
5
|
+
# Safe parser that converts Relation#where inputs into validated AST nodes.
|
|
6
|
+
#
|
|
7
|
+
# Supported inputs:
|
|
8
|
+
# - Hash: { field => value } (scalar => Eq, Array => In)
|
|
9
|
+
# - Raw String: full Typesense fragment (escape hatch) => Raw
|
|
10
|
+
# - Fragment + args: ["price > ?", 100] or ["brand_id IN ?", [1,2,3]]
|
|
11
|
+
# - Joined Hash: { assoc => { field => value } } => LHS "$assoc.field"
|
|
12
|
+
#
|
|
13
|
+
# Validation/coercion:
|
|
14
|
+
# - Field names validated against model attributes (when available)
|
|
15
|
+
# - Booleans coerced from "true"/"false" strings when attribute type is boolean
|
|
16
|
+
# - Date/DateTime coerced to Time.utc; Arrays flattened one level and compacted
|
|
17
|
+
# - When using joined fields, association names are validated via klass.join_for
|
|
18
|
+
# and (optionally) required to be present in relation joins when +joins+ is provided.
|
|
19
|
+
module Parser
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Accepted hints that should be treated as time-like for coercion purposes.
|
|
23
|
+
TIME_LIKE_HINTS = [
|
|
24
|
+
:time, 'time', :datetime, 'datetime',
|
|
25
|
+
:time_string, 'time_string', :datetime_string, 'datetime_string',
|
|
26
|
+
Time, Date, DateTime
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Parse a where input into AST nodes.
|
|
30
|
+
#
|
|
31
|
+
# Return convention:
|
|
32
|
+
# - Single predicate => a single AST node
|
|
33
|
+
# - Hash with multiple keys or list-of-inputs => Array<AST::Node>
|
|
34
|
+
#
|
|
35
|
+
# @param input [Hash, String, Array]
|
|
36
|
+
# @param args [Array] optional, used only when +input+ is a template String
|
|
37
|
+
# @param klass [Class] SearchEngine::Base subclass used for attribute validation
|
|
38
|
+
# @param joins [Array<Symbol>, nil] Optional list of applied join names on the relation
|
|
39
|
+
# @return [SearchEngine::AST::Node, Array<SearchEngine::AST::Node>]
|
|
40
|
+
# @raise [SearchEngine::Errors::InvalidField, SearchEngine::Errors::InvalidOperator, SearchEngine::Errors::InvalidType]
|
|
41
|
+
def parse(input, klass:, args: [], joins: nil)
|
|
42
|
+
case input
|
|
43
|
+
when Hash
|
|
44
|
+
parse_hash(input, klass: klass, joins: joins)
|
|
45
|
+
when String
|
|
46
|
+
if placeholders?(input)
|
|
47
|
+
needed = count_placeholders(input)
|
|
48
|
+
ensure_placeholder_arity!(needed, args.length, input)
|
|
49
|
+
parse_template(input, args, klass: klass)
|
|
50
|
+
else
|
|
51
|
+
parse_raw(input)
|
|
52
|
+
end
|
|
53
|
+
when Array
|
|
54
|
+
parse_array_input(input, klass: klass, joins: joins)
|
|
55
|
+
when Symbol
|
|
56
|
+
# Back-compat: treat symbol as raw fragment name
|
|
57
|
+
parse_raw(input.to_s)
|
|
58
|
+
else
|
|
59
|
+
raise ArgumentError, "Parser: unsupported input #{input.class}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse a list of heterogenous where arguments (as passed to Relation#where).
|
|
64
|
+
# @param list [Array]
|
|
65
|
+
# @param klass [Class]
|
|
66
|
+
# @param joins [Array<Symbol>, nil]
|
|
67
|
+
# @return [Array<SearchEngine::AST::Node>]
|
|
68
|
+
def parse_list(list, klass:, joins: nil)
|
|
69
|
+
items = Array(list).flatten.compact
|
|
70
|
+
return [] if items.empty?
|
|
71
|
+
|
|
72
|
+
nodes = []
|
|
73
|
+
i = 0
|
|
74
|
+
while i < items.length
|
|
75
|
+
entry = items[i]
|
|
76
|
+
case entry
|
|
77
|
+
when Hash
|
|
78
|
+
nodes.concat(Array(parse_hash(entry, klass: klass, joins: joins)))
|
|
79
|
+
i += 1
|
|
80
|
+
when String
|
|
81
|
+
if placeholders?(entry)
|
|
82
|
+
needed = count_placeholders(entry)
|
|
83
|
+
args_for_template = items[(i + 1)..(i + needed)] || []
|
|
84
|
+
ensure_placeholder_arity!(needed, args_for_template.length, entry)
|
|
85
|
+
nodes << parse_template(entry, args_for_template, klass: klass)
|
|
86
|
+
i += 1 + needed
|
|
87
|
+
else
|
|
88
|
+
nodes << parse_raw(entry)
|
|
89
|
+
i += 1
|
|
90
|
+
end
|
|
91
|
+
when Symbol
|
|
92
|
+
nodes << parse_raw(entry.to_s)
|
|
93
|
+
i += 1
|
|
94
|
+
when Array
|
|
95
|
+
nodes << parse_array_entry(entry, klass: klass, joins: joins)
|
|
96
|
+
i += 1
|
|
97
|
+
else
|
|
98
|
+
raise ArgumentError, "Parser: unsupported where argument #{entry.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
nodes
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Internals -------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def parse_array_entry(entry, klass:, joins: nil)
|
|
107
|
+
return parse_raw(entry.to_s) unless entry.first.is_a?(String)
|
|
108
|
+
return parse_list(entry, klass: klass, joins: joins) unless placeholders?(entry.first)
|
|
109
|
+
|
|
110
|
+
template = entry.first
|
|
111
|
+
args_list = if entry.length == 2 && entry[1].is_a?(Array)
|
|
112
|
+
[entry[1]]
|
|
113
|
+
else
|
|
114
|
+
entry[1..]
|
|
115
|
+
end
|
|
116
|
+
needed = count_placeholders(template)
|
|
117
|
+
ensure_placeholder_arity!(needed, Array(args_list).length, template)
|
|
118
|
+
parse_template(template, Array(args_list), klass: klass)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_hash(hash, klass:, joins: nil)
|
|
122
|
+
raise ArgumentError, 'Parser: hash input must be a Hash' unless hash.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
attrs = safe_attributes_map(klass)
|
|
125
|
+
validate_hash_keys!(hash, attrs, klass)
|
|
126
|
+
|
|
127
|
+
pairs = []
|
|
128
|
+
|
|
129
|
+
hash.each do |k, v|
|
|
130
|
+
key_sym = k.to_sym
|
|
131
|
+
value = v
|
|
132
|
+
|
|
133
|
+
if value.is_a?(Hash)
|
|
134
|
+
# assoc => { field => value }
|
|
135
|
+
validate_assoc_and_join!(klass, key_sym, joins)
|
|
136
|
+
|
|
137
|
+
value.each do |inner_field, inner_value|
|
|
138
|
+
field_sym = inner_field.to_sym
|
|
139
|
+
path = "$#{key_sym}.#{field_sym}"
|
|
140
|
+
SearchEngine::Joins::Guard.ensure_single_hop_path!(path)
|
|
141
|
+
|
|
142
|
+
# Resolve target klass for joined-field type coercion and validate field best-effort
|
|
143
|
+
cfg = klass.join_for(key_sym)
|
|
144
|
+
target_klass = begin
|
|
145
|
+
SearchEngine.collection_for(cfg[:collection])
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
begin
|
|
150
|
+
SearchEngine::Joins::Guard.validate_joined_field!(cfg, field_sym)
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# skip strictly when registry/attributes missing or join missing
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
coercion_klass = target_klass || klass
|
|
156
|
+
if array_like?(inner_value)
|
|
157
|
+
values = normalize_array_values(inner_value, field: field_sym, klass: coercion_klass)
|
|
158
|
+
ensure_non_empty_values!(values, field: field_sym, klass: coercion_klass)
|
|
159
|
+
pairs << SearchEngine::AST.in_(path, values)
|
|
160
|
+
else
|
|
161
|
+
coerced = coerce_value_for_field(inner_value, field: field_sym, klass: coercion_klass)
|
|
162
|
+
pairs << SearchEngine::AST.eq(path, coerced)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
field = key_sym
|
|
167
|
+
|
|
168
|
+
if array_like?(value)
|
|
169
|
+
values = normalize_array_values(value, field: field, klass: klass)
|
|
170
|
+
ensure_non_empty_values!(values, field: field, klass: klass)
|
|
171
|
+
pairs << SearchEngine::AST.in_(field, values)
|
|
172
|
+
else
|
|
173
|
+
coerced = coerce_value_for_field(value, field: field, klass: klass)
|
|
174
|
+
pairs << SearchEngine::AST.eq(field, coerced)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
return pairs.first if pairs.length == 1
|
|
180
|
+
|
|
181
|
+
pairs
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_template(template, args, klass:)
|
|
185
|
+
raise ArgumentError, 'Parser: template must be a String' unless template.is_a?(String)
|
|
186
|
+
|
|
187
|
+
args = Array(args)
|
|
188
|
+
m = template.match(/\A\s*([A-Za-z_][A-Za-z0-9_]*)\s*(=|!=|>=|<=|>|<|IN|NOT\s+IN|MATCHES|PREFIX)\s*\?\s*\z/i)
|
|
189
|
+
unless m
|
|
190
|
+
raise SearchEngine::Errors::InvalidOperator.new(
|
|
191
|
+
"invalid template '#{template}'. Supported: =, !=, >, >=, <, <=, IN, NOT IN, MATCHES, PREFIX",
|
|
192
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
193
|
+
details: { template: template }
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
field_raw = m[1]
|
|
198
|
+
op = m[2].upcase.gsub(/\s+/, ' ')
|
|
199
|
+
|
|
200
|
+
field_sym = field_raw.to_sym
|
|
201
|
+
attrs = safe_attributes_map(klass)
|
|
202
|
+
validate_field!(field_sym, attrs, klass)
|
|
203
|
+
|
|
204
|
+
case op
|
|
205
|
+
when '='
|
|
206
|
+
SearchEngine::AST.eq(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
207
|
+
when '!='
|
|
208
|
+
SearchEngine::AST.not_eq(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
209
|
+
when '>'
|
|
210
|
+
SearchEngine::AST.gt(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
211
|
+
when '>='
|
|
212
|
+
SearchEngine::AST.gte(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
213
|
+
when '<'
|
|
214
|
+
SearchEngine::AST.lt(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
215
|
+
when '<='
|
|
216
|
+
SearchEngine::AST.lte(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
|
|
217
|
+
when 'IN'
|
|
218
|
+
values = normalize_array_values(args.first, field: field_sym, klass: klass)
|
|
219
|
+
ensure_non_empty_values!(values, field: field_sym, klass: klass)
|
|
220
|
+
SearchEngine::AST.in_(field_sym, values)
|
|
221
|
+
when 'NOT IN'
|
|
222
|
+
values = normalize_array_values(args.first, field: field_sym, klass: klass)
|
|
223
|
+
ensure_non_empty_values!(values, field: field_sym, klass: klass)
|
|
224
|
+
SearchEngine::AST.not_in(field_sym, values)
|
|
225
|
+
when 'MATCHES'
|
|
226
|
+
SearchEngine::AST.matches(field_sym, args.first)
|
|
227
|
+
when 'PREFIX'
|
|
228
|
+
SearchEngine::AST.prefix(field_sym, String(args.first))
|
|
229
|
+
else
|
|
230
|
+
raise SearchEngine::Errors::InvalidOperator, "unsupported operator '#{op}'"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def parse_raw(fragment)
|
|
235
|
+
SearchEngine::AST.raw(String(fragment))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Heuristic: treat an Array input as a template+args when it starts with a
|
|
239
|
+
# String that has placeholders; otherwise treat as list-of-inputs.
|
|
240
|
+
def parse_array_input(arr, klass:, joins: nil)
|
|
241
|
+
return [] if arr.nil?
|
|
242
|
+
|
|
243
|
+
arr = Array(arr)
|
|
244
|
+
|
|
245
|
+
if arr.first.is_a?(String) && placeholders?(arr.first) && arr.length >= 2
|
|
246
|
+
template = arr.first
|
|
247
|
+
args_list = if arr.length == 2 && arr[1].is_a?(Array)
|
|
248
|
+
[arr[1]]
|
|
249
|
+
else
|
|
250
|
+
arr[1..]
|
|
251
|
+
end
|
|
252
|
+
needed = count_placeholders(template)
|
|
253
|
+
ensure_placeholder_arity!(needed, Array(args_list).length, template)
|
|
254
|
+
parse_template(template, Array(args_list), klass: klass)
|
|
255
|
+
else
|
|
256
|
+
parse_list(arr, klass: klass, joins: joins)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# --- Utilities -------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def placeholders?(str)
|
|
263
|
+
str.is_a?(String) && SearchEngine::Filters::Sanitizer.count_placeholders(str).positive?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def count_placeholders(str)
|
|
267
|
+
SearchEngine::Filters::Sanitizer.count_placeholders(str)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def ensure_placeholder_arity!(needed, provided, template)
|
|
271
|
+
return if needed == provided
|
|
272
|
+
|
|
273
|
+
raise SearchEngine::Errors::InvalidOperator.new(
|
|
274
|
+
"expected #{needed} args for #{needed} placeholders in template '#{template}', got #{provided}.",
|
|
275
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
276
|
+
details: { needed: needed, provided: provided, template: template }
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def safe_attributes_map(klass)
|
|
281
|
+
if klass.respond_to?(:attributes)
|
|
282
|
+
base = klass.attributes || {}
|
|
283
|
+
return base if base.key?(:id)
|
|
284
|
+
|
|
285
|
+
# Infer implicit id type:
|
|
286
|
+
# - When identify_by is declared as :id, keep integer to preserve numeric coercions in tests
|
|
287
|
+
# - Otherwise, default to :string (composed or custom ids)
|
|
288
|
+
if klass.instance_variable_defined?(:@__identify_by_kind__) &&
|
|
289
|
+
klass.instance_variable_get(:@__identify_by_kind__) == :symbol &&
|
|
290
|
+
klass.instance_variable_defined?(:@__identify_by_symbol__) &&
|
|
291
|
+
klass.instance_variable_get(:@__identify_by_symbol__) == :id
|
|
292
|
+
base.merge(id: :integer)
|
|
293
|
+
else
|
|
294
|
+
base.merge(id: :string)
|
|
295
|
+
end
|
|
296
|
+
else
|
|
297
|
+
{ id: :string }
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def validate_hash_keys!(hash, attributes_map, klass)
|
|
302
|
+
return if hash.nil? || hash.empty?
|
|
303
|
+
|
|
304
|
+
known = attributes_map.keys.map(&:to_sym)
|
|
305
|
+
# Exclude keys whose values are Hash (treated as assoc => { ... })
|
|
306
|
+
candidate_keys = hash.reject { |_, v| v.is_a?(Hash) }.keys
|
|
307
|
+
unknown = candidate_keys.map(&:to_sym) - known
|
|
308
|
+
return if unknown.empty?
|
|
309
|
+
|
|
310
|
+
return unless strict_fields?
|
|
311
|
+
|
|
312
|
+
raise build_invalid_field_error(unknown.first, known, klass)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def validate_field!(field, attributes_map, klass)
|
|
316
|
+
return if attributes_map.nil? || attributes_map.empty?
|
|
317
|
+
return unless strict_fields?
|
|
318
|
+
|
|
319
|
+
sym = field.to_sym
|
|
320
|
+
return if attributes_map.key?(sym)
|
|
321
|
+
|
|
322
|
+
known = attributes_map.keys.map(&:to_sym)
|
|
323
|
+
raise build_invalid_field_error(sym, known, klass)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def array_like?(value)
|
|
327
|
+
value.is_a?(Array)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def normalize_array_values(value, field:, klass:)
|
|
331
|
+
arr = Array(value).flatten(1).compact
|
|
332
|
+
arr.map { |v| coerce_value_for_field(v, field: field, klass: klass) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ensure_non_empty_values!(values, field:, klass:)
|
|
336
|
+
return if values.is_a?(Array) && !values.empty?
|
|
337
|
+
|
|
338
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
339
|
+
invalid_type_message(field: field, klass: klass, expectation: 'a non-empty Array', got: values),
|
|
340
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
341
|
+
details: { field: field, got_class: values.class.name }
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def coerce_value_for_field(value, field:, klass:)
|
|
346
|
+
# Unwrap single-element array types to their inner hint (e.g., [:string] -> :string)
|
|
347
|
+
type_hint = begin
|
|
348
|
+
t = safe_attributes_map(klass)[field.to_sym]
|
|
349
|
+
t.is_a?(Array) && t.size == 1 ? t.first : t
|
|
350
|
+
rescue StandardError
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
coerced = coerce_value(value, type_hint: type_hint, field: field, klass: klass)
|
|
354
|
+
return coerced unless coerced.equal?(:__no_coercion__)
|
|
355
|
+
|
|
356
|
+
# If generic coercion returned :__no_coercion__, try to coerce based on type hint
|
|
357
|
+
case type_hint
|
|
358
|
+
when :boolean
|
|
359
|
+
coerce_boolean(value, type_hint)
|
|
360
|
+
when :time, :datetime, :time_string, :datetime_string
|
|
361
|
+
coerce_time(value, type_hint, field: field, klass: klass)
|
|
362
|
+
|
|
363
|
+
when :integer
|
|
364
|
+
coerce_numeric(value, type_hint, field: field, klass: klass)
|
|
365
|
+
when :float, :decimal
|
|
366
|
+
coerce_numeric(value, type_hint, field: field, klass: klass)
|
|
367
|
+
when :string
|
|
368
|
+
# Ensure string-typed fields compare as strings (e.g., 1070 -> "1070")
|
|
369
|
+
value.to_s
|
|
370
|
+
else
|
|
371
|
+
value # No specific coercion for this type hint
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def coerce_value(value, type_hint: nil, field: nil, klass: nil)
|
|
376
|
+
coerced_bool = coerce_boolean(value, type_hint)
|
|
377
|
+
return coerced_bool unless coerced_bool.equal?(:__no_coercion__)
|
|
378
|
+
|
|
379
|
+
coerced_time = coerce_time(value, type_hint, field: field, klass: klass)
|
|
380
|
+
return coerced_time unless coerced_time.equal?(:__no_coercion__)
|
|
381
|
+
|
|
382
|
+
coerced_number = coerce_numeric(value, type_hint, field: field, klass: klass)
|
|
383
|
+
return coerced_number unless coerced_number.equal?(:__no_coercion__)
|
|
384
|
+
|
|
385
|
+
value
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def coerce_boolean(value, type_hint)
|
|
389
|
+
return :__no_coercion__ unless type_boolean?(type_hint) && value.is_a?(String)
|
|
390
|
+
|
|
391
|
+
lc = value.strip.downcase
|
|
392
|
+
return true if lc == 'true'
|
|
393
|
+
return false if lc == 'false'
|
|
394
|
+
|
|
395
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
396
|
+
invalid_type_message(field: nil, klass: nil, expectation: 'boolean', got: value),
|
|
397
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
398
|
+
details: { got: value }
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
private_class_method :coerce_boolean
|
|
402
|
+
|
|
403
|
+
def coerce_time(value, type_hint, field:, klass:)
|
|
404
|
+
# Direct time-like Ruby objects
|
|
405
|
+
return value.to_time.utc if value.is_a?(DateTime) || value.is_a?(Date)
|
|
406
|
+
return (value.utc? ? value : value.utc) if value.is_a?(Time)
|
|
407
|
+
|
|
408
|
+
# Only attempt further coercion when hint is time-like
|
|
409
|
+
return :__no_coercion__ unless type_time?(type_hint)
|
|
410
|
+
|
|
411
|
+
case value
|
|
412
|
+
when Integer
|
|
413
|
+
Time.at(value).utc
|
|
414
|
+
when Numeric
|
|
415
|
+
Time.at(value.to_i).utc
|
|
416
|
+
when String
|
|
417
|
+
if value.match?(/^\d+$/)
|
|
418
|
+
Time.at(Integer(value, 10)).utc
|
|
419
|
+
else
|
|
420
|
+
require 'time'
|
|
421
|
+
Time.parse(value).utc
|
|
422
|
+
end
|
|
423
|
+
else
|
|
424
|
+
:__no_coercion__
|
|
425
|
+
end
|
|
426
|
+
rescue StandardError
|
|
427
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
428
|
+
invalid_type_message(field: field, klass: klass, expectation: 'time', got: value),
|
|
429
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
430
|
+
details: { field: field, got: value }
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
private_class_method :coerce_time
|
|
434
|
+
|
|
435
|
+
def coerce_numeric(value, type_hint, field:, klass:)
|
|
436
|
+
if type_integer?(type_hint)
|
|
437
|
+
return value if value.is_a?(Integer)
|
|
438
|
+
|
|
439
|
+
if value.is_a?(String)
|
|
440
|
+
begin
|
|
441
|
+
int_val = Integer(value, 10)
|
|
442
|
+
return int_val
|
|
443
|
+
rescue StandardError
|
|
444
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
445
|
+
invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
|
|
446
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
447
|
+
details: { field: field, got: value }
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
if value.is_a?(Numeric)
|
|
453
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
454
|
+
invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
|
|
455
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
456
|
+
details: { field: field, got: value }
|
|
457
|
+
)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
return :__no_coercion__
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if type_float?(type_hint)
|
|
464
|
+
return value if value.is_a?(Integer)
|
|
465
|
+
return value.to_f if value.is_a?(Numeric)
|
|
466
|
+
|
|
467
|
+
if value.is_a?(String)
|
|
468
|
+
begin
|
|
469
|
+
Float(value)
|
|
470
|
+
rescue StandardError
|
|
471
|
+
raise SearchEngine::Errors::InvalidType.new(
|
|
472
|
+
invalid_type_message(field: field, klass: klass, expectation: 'numeric', got: value),
|
|
473
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
|
|
474
|
+
details: { field: field, got: value }
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
return value.to_f
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
:__no_coercion__
|
|
482
|
+
end
|
|
483
|
+
private_class_method :coerce_numeric
|
|
484
|
+
|
|
485
|
+
def type_boolean?(hint)
|
|
486
|
+
case hint
|
|
487
|
+
when :boolean, 'boolean', TrueClass, FalseClass then true
|
|
488
|
+
else false
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def type_integer?(hint)
|
|
493
|
+
case hint
|
|
494
|
+
when :integer, 'integer', Integer then true
|
|
495
|
+
else false
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def type_float?(hint)
|
|
500
|
+
case hint
|
|
501
|
+
when :float, 'float', :decimal, 'decimal', Float then true
|
|
502
|
+
else false
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def type_time?(hint)
|
|
507
|
+
TIME_LIKE_HINTS.include?(hint)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def strict_fields?
|
|
511
|
+
begin
|
|
512
|
+
cfg = SearchEngine.config
|
|
513
|
+
val = cfg.respond_to?(:strict_fields) ? cfg.strict_fields : nil
|
|
514
|
+
return !!val unless val.nil?
|
|
515
|
+
rescue StandardError
|
|
516
|
+
# default below
|
|
517
|
+
end
|
|
518
|
+
true
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def build_invalid_field_error(field, known, klass)
|
|
522
|
+
klass_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
|
|
523
|
+
suggestion = did_you_mean(field, known)
|
|
524
|
+
msg = "unknown field #{field.inspect} for #{klass_name}"
|
|
525
|
+
msg += " (did you mean #{suggestion.inspect}?)" if suggestion
|
|
526
|
+
SearchEngine::Errors::InvalidField.new(msg)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def did_you_mean(field, known)
|
|
530
|
+
return nil if known.nil? || known.empty?
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
require 'did_you_mean'
|
|
534
|
+
require 'did_you_mean/levenshtein'
|
|
535
|
+
rescue StandardError
|
|
536
|
+
return nil
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
candidates = known.map(&:to_s)
|
|
540
|
+
input = field.to_s
|
|
541
|
+
|
|
542
|
+
# Compute minimal Levenshtein distance deterministically
|
|
543
|
+
distances = candidates.each_with_object({}) do |cand, acc|
|
|
544
|
+
acc[cand] = DidYouMean::Levenshtein.distance(input, cand)
|
|
545
|
+
end
|
|
546
|
+
min = distances.values.min
|
|
547
|
+
return nil if min.nil? || min > 2
|
|
548
|
+
|
|
549
|
+
best = distances.select { |_, d| d == min }.keys
|
|
550
|
+
return nil unless best.length == 1
|
|
551
|
+
|
|
552
|
+
best.first.to_sym
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def invalid_type_message(field:, klass:, expectation:, got:)
|
|
556
|
+
klass_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
|
|
557
|
+
%(expected #{field.inspect} to be #{expectation} for #{klass_name} (got #{got.class}: #{got.inspect}))
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def validate_assoc_and_join!(klass, assoc_name, joins)
|
|
561
|
+
# Validate association exists and config completeness
|
|
562
|
+
begin
|
|
563
|
+
SearchEngine::Joins::Guard.ensure_config_complete!(klass, assoc_name)
|
|
564
|
+
rescue SearchEngine::Errors::InvalidJoin
|
|
565
|
+
# Provide high-level guidance with suggestions if available
|
|
566
|
+
model_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
|
|
567
|
+
msg = "association :#{assoc_name} is not declared on #{model_name} (declare with `join :#{assoc_name}, ...`)"
|
|
568
|
+
raise SearchEngine::Errors::InvalidJoin, msg
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# When enforcing applied joins, ensure relation has the association
|
|
572
|
+
return if joins.nil? || Array(joins).include?(assoc_name)
|
|
573
|
+
|
|
574
|
+
raise SearchEngine::Errors::JoinNotApplied.new(
|
|
575
|
+
"Call .joins(:#{assoc_name}) before filtering/sorting on #{assoc_name} fields",
|
|
576
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#troubleshooting',
|
|
577
|
+
details: { assoc: assoc_name, used_for: 'filtering' }
|
|
578
|
+
)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|