noiseless 0.7.2 → 0.8.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 +4 -4
- data/README.md +2 -2
- data/lib/noiseless/adapter.rb +28 -35
- data/lib/noiseless/adapters/elasticsearch.rb +0 -1
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +63 -324
- data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
- data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
- data/lib/noiseless/adapters/indices_api.rb +3 -1
- data/lib/noiseless/adapters/open_search.rb +30 -88
- data/lib/noiseless/adapters/postgresql.rb +5 -5
- data/lib/noiseless/adapters/typesense.rb +1 -2
- data/lib/noiseless/ast/aggregation.rb +0 -23
- data/lib/noiseless/ast/aggregation_builder.rb +28 -0
- data/lib/noiseless/ast/hybrid.rb +4 -2
- data/lib/noiseless/ast/join.rb +4 -5
- data/lib/noiseless/ast/vector.rb +25 -4
- data/lib/noiseless/bulk_importer.rb +16 -27
- data/lib/noiseless/callbacks.rb +4 -30
- data/lib/noiseless/connection_manager.rb +9 -3
- data/lib/noiseless/controller_runtime.rb +37 -0
- data/lib/noiseless/document_manager.rb +21 -36
- data/lib/noiseless/embedding.rb +33 -0
- data/lib/noiseless/instrumentation.rb +2 -136
- data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
- data/lib/noiseless/introspection/query_visualizer.rb +3 -228
- data/lib/noiseless/introspection.rb +1 -1
- data/lib/noiseless/log_subscriber.rb +103 -0
- data/lib/noiseless/model.rb +2 -2
- data/lib/noiseless/multi_search.rb +2 -4
- data/lib/noiseless/pagination.rb +3 -3
- data/lib/noiseless/query_builder.rb +5 -4
- data/lib/noiseless/railtie.rb +0 -2
- data/lib/noiseless/response/records.rb +2 -1
- data/lib/noiseless/runtime_reset_middleware.rb +1 -1
- data/lib/noiseless/search_index_update_job.rb +13 -14
- data/lib/noiseless/test_case.rb +0 -1
- data/lib/noiseless/version.rb +1 -1
- data/lib/noiseless.rb +1 -10
- metadata +31 -13
- data/lib/tasks/benchmark.rake +0 -35
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "pgvector_support"
|
|
4
3
|
|
|
5
4
|
module Noiseless
|
|
6
5
|
module Adapters
|
|
@@ -10,61 +9,84 @@ module Noiseless
|
|
|
10
9
|
# and optionally pgvector for semantic search
|
|
11
10
|
module PostgresqlExecution
|
|
12
11
|
include PgvectorSupport
|
|
13
|
-
|
|
14
|
-
SIMILARITY_THRESHOLD = 0.3
|
|
15
|
-
DEFAULT_LIMIT = 20
|
|
12
|
+
include PostgresqlQuery
|
|
16
13
|
|
|
17
14
|
private
|
|
18
15
|
|
|
19
16
|
def execute_search(query_hash, model_class: nil, **)
|
|
20
17
|
model = resolve_model(query_hash[:indexes], model_class)
|
|
21
|
-
|
|
18
|
+
raise Noiseless::SearchError, "no ActiveRecord model for index #{query_hash[:indexes]&.first.inspect}" unless model
|
|
19
|
+
|
|
20
|
+
with_search_errors do
|
|
21
|
+
next execute_vector_search(model, query_hash) if query_hash[:vector] || query_hash[:hybrid]
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
scope = build_search_scope(model, query_hash)
|
|
24
|
+
total = scope.except(:order, :limit, :offset).count
|
|
25
|
+
records = apply_pagination(scope, query_hash[:paginate]).to_a
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
format_as_search_response(records, model, total: total)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
def with_search_errors
|
|
32
|
+
yield
|
|
33
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
34
|
+
raise Noiseless::ConnectionError, "postgresql unreachable: #{e.message}"
|
|
35
|
+
rescue Noiseless::Error
|
|
36
|
+
raise
|
|
31
37
|
rescue StandardError => e
|
|
32
|
-
|
|
38
|
+
raise Noiseless::SearchError, "postgresql search failed: #{e.message}"
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def execute_vector_search(model, query_hash)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
scope = vector_search(
|
|
47
|
-
scope,
|
|
48
|
-
vector_node.embedding,
|
|
49
|
-
column: vector_node.field,
|
|
50
|
-
limit: vector_node.k,
|
|
51
|
-
distance_metric: vector_node.distance_metric
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
records = scope.to_a
|
|
55
|
-
format_vector_response(records, model, vector_node)
|
|
56
|
-
rescue StandardError => e
|
|
57
|
-
error_response(e)
|
|
42
|
+
raise Noiseless::SearchError, "vector search requires the pgvector extension" unless pgvector_available?
|
|
43
|
+
|
|
44
|
+
scope = apply_filter_clauses(model.all, query_hash[:bool]&.filter || [], model)
|
|
45
|
+
neighbors, k = nearest(scope, query_hash)
|
|
46
|
+
|
|
47
|
+
paginate_node = query_hash[:paginate]
|
|
48
|
+
return format_vector_response(neighbors.to_a, model) unless paginate_node
|
|
49
|
+
|
|
50
|
+
records = paginate_neighbors(neighbors, paginate_node, k).to_a
|
|
51
|
+
format_vector_response(records, model, total: [scope.count, k].min)
|
|
58
52
|
end
|
|
59
53
|
|
|
60
|
-
def
|
|
54
|
+
def nearest(scope, query_hash)
|
|
55
|
+
if (hybrid = query_hash[:hybrid])
|
|
56
|
+
vector = hybrid.vector
|
|
57
|
+
neighbors = hybrid_search(scope, text_query: hybrid.text_query, embedding: vector.embedding, text_fields: hybrid.fields,
|
|
58
|
+
vector_column: vector.field, text_weight: hybrid.text_weight,
|
|
59
|
+
vector_weight: hybrid.vector_weight, limit: vector.k)
|
|
60
|
+
return [neighbors, vector.k]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
vector = query_hash[:vector]
|
|
64
|
+
[vector_search(scope, vector.embedding, column: vector.field, limit: vector.k, distance_metric: vector.distance_metric),
|
|
65
|
+
vector.k]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def paginate_neighbors(scope, paginate_node, k)
|
|
69
|
+
page = paginate_node.page || 1
|
|
70
|
+
per_page = paginate_node.per_page || DEFAULT_LIMIT
|
|
71
|
+
offset = (page - 1) * per_page
|
|
72
|
+
limit = (k - offset).clamp(0, per_page)
|
|
73
|
+
return scope.none if limit.zero?
|
|
74
|
+
|
|
75
|
+
scope.limit(limit).offset(offset)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format_vector_response(records, model, total: records.size)
|
|
61
79
|
hits = records.map do |record|
|
|
62
|
-
|
|
80
|
+
score = if record.has_attribute?(:combined_score)
|
|
81
|
+
record.combined_score
|
|
82
|
+
else
|
|
83
|
+
1.0 - (record.respond_to?(:vector_distance) ? record.vector_distance : 0)
|
|
84
|
+
end
|
|
63
85
|
{
|
|
64
86
|
"_index" => model.table_name,
|
|
65
87
|
"_id" => record.id.to_s,
|
|
66
|
-
"_score" =>
|
|
67
|
-
"_source" => record.as_json(except: [
|
|
88
|
+
"_score" => score,
|
|
89
|
+
"_source" => record.as_json(except: %w[vector_distance combined_score])
|
|
68
90
|
}
|
|
69
91
|
end
|
|
70
92
|
|
|
@@ -73,7 +95,7 @@ module Noiseless
|
|
|
73
95
|
"timed_out" => false,
|
|
74
96
|
"_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
|
|
75
97
|
"hits" => {
|
|
76
|
-
"total" => { "value" =>
|
|
98
|
+
"total" => { "value" => total, "relation" => "eq" },
|
|
77
99
|
"max_score" => hits.first&.dig("_score"),
|
|
78
100
|
"hits" => hits
|
|
79
101
|
}
|
|
@@ -101,8 +123,6 @@ module Noiseless
|
|
|
101
123
|
def execute_index_exists?(index_name)
|
|
102
124
|
model = resolve_model([index_name])
|
|
103
125
|
model.present? && model.table_exists?
|
|
104
|
-
rescue StandardError
|
|
105
|
-
false
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
# Document writes are no-ops: the table IS the index, so queries always
|
|
@@ -124,8 +144,6 @@ module Noiseless
|
|
|
124
144
|
def execute_document_exists?(index, id)
|
|
125
145
|
model = resolve_model([index])
|
|
126
146
|
model&.exists?(id: id) || false
|
|
127
|
-
rescue StandardError
|
|
128
|
-
false
|
|
129
147
|
end
|
|
130
148
|
|
|
131
149
|
def execute_cluster_health(**)
|
|
@@ -144,203 +162,6 @@ module Noiseless
|
|
|
144
162
|
}
|
|
145
163
|
end
|
|
146
164
|
|
|
147
|
-
# Query building methods
|
|
148
|
-
|
|
149
|
-
def build_search_scope(model, query_hash)
|
|
150
|
-
scope = model.all
|
|
151
|
-
|
|
152
|
-
# Apply must clauses (full-text search)
|
|
153
|
-
scope = apply_must_clauses(scope, query_hash[:bool]&.must || [], model)
|
|
154
|
-
|
|
155
|
-
# Apply filter clauses (exact matches)
|
|
156
|
-
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
|
|
157
|
-
|
|
158
|
-
# Apply sorting (pagination is applied by the caller, after counting)
|
|
159
|
-
apply_sorting(scope, query_hash[:sort] || [], model)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def apply_must_clauses(scope, must_nodes, model)
|
|
163
|
-
return scope if must_nodes.empty?
|
|
164
|
-
|
|
165
|
-
must_nodes.each do |node|
|
|
166
|
-
scope = case node
|
|
167
|
-
when AST::Match
|
|
168
|
-
apply_match(scope, node, model)
|
|
169
|
-
when AST::MultiMatch
|
|
170
|
-
apply_multi_match(scope, node, model)
|
|
171
|
-
when AST::Wildcard
|
|
172
|
-
apply_wildcard(scope, node, model)
|
|
173
|
-
when AST::Range
|
|
174
|
-
apply_range(scope, node, model)
|
|
175
|
-
when AST::Prefix
|
|
176
|
-
apply_prefix(scope, node, model)
|
|
177
|
-
else
|
|
178
|
-
scope
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
scope
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def apply_match(scope, node, model)
|
|
186
|
-
field = node.field.to_s
|
|
187
|
-
value = node.value.to_s
|
|
188
|
-
|
|
189
|
-
# Mapping-only fields (denormalized into a search index, not columns)
|
|
190
|
-
# cannot be satisfied here; fail closed rather than matching everything.
|
|
191
|
-
return scope.none unless column?(model, field)
|
|
192
|
-
|
|
193
|
-
# Use pg_trgm similarity for fuzzy matching, accent-insensitive when
|
|
194
|
-
# the unaccent extension is present.
|
|
195
|
-
if trgm_available? && text_column?(model, field)
|
|
196
|
-
scope.where(
|
|
197
|
-
"#{fuzzy_column(field)} % #{fuzzy_param} OR " \
|
|
198
|
-
"#{fuzzy_column(field)} ILIKE #{fuzzy_param}",
|
|
199
|
-
value,
|
|
200
|
-
"%#{sanitize_like(value)}%"
|
|
201
|
-
)
|
|
202
|
-
else
|
|
203
|
-
# Fallback to ILIKE
|
|
204
|
-
scope.where("#{quoted_column(field)} ILIKE ?", "%#{sanitize_like(value)}%")
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def apply_multi_match(scope, node, model)
|
|
209
|
-
query = node.query.to_s
|
|
210
|
-
# Drop mapping-only fields; if none of the requested fields are real
|
|
211
|
-
# columns the query cannot be satisfied — fail closed.
|
|
212
|
-
fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
|
|
213
|
-
return scope.none if fields.empty?
|
|
214
|
-
|
|
215
|
-
conditions = fields.map do |field|
|
|
216
|
-
if trgm_available? && text_column?(model, field)
|
|
217
|
-
"(#{fuzzy_column(field)} % #{fuzzy_param} OR " \
|
|
218
|
-
"#{fuzzy_column(field)} ILIKE #{fuzzy_param})"
|
|
219
|
-
else
|
|
220
|
-
"#{quoted_column(field)} ILIKE ?"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
params = fields.flat_map do |field|
|
|
225
|
-
if trgm_available? && text_column?(model, field)
|
|
226
|
-
[query, "%#{sanitize_like(query)}%"]
|
|
227
|
-
else
|
|
228
|
-
["%#{sanitize_like(query)}%"]
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
scope.where(conditions.join(" OR "), *params)
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def apply_wildcard(scope, node, model = nil)
|
|
236
|
-
field = node.field.to_s
|
|
237
|
-
return scope.none if model && !column?(model, field)
|
|
238
|
-
|
|
239
|
-
# Convert OpenSearch wildcards to SQL: * -> %, ? -> _
|
|
240
|
-
pattern = node.value.to_s.tr("*", "%").tr("?", "_")
|
|
241
|
-
|
|
242
|
-
scope.where("#{quoted_column(field)} ILIKE ?", pattern)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def apply_range(scope, node, model = nil)
|
|
246
|
-
return scope.none if model && !column?(model, node.field.to_s)
|
|
247
|
-
|
|
248
|
-
field = quoted_column(node.field.to_s)
|
|
249
|
-
|
|
250
|
-
scope = scope.where("#{field} >= ?", node.gte) if node.gte
|
|
251
|
-
scope = scope.where("#{field} <= ?", node.lte) if node.lte
|
|
252
|
-
scope = scope.where("#{field} > ?", node.gt) if node.gt
|
|
253
|
-
scope = scope.where("#{field} < ?", node.lt) if node.lt
|
|
254
|
-
|
|
255
|
-
scope
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def apply_prefix(scope, node, model = nil)
|
|
259
|
-
return scope.none if model && !column?(model, node.field.to_s)
|
|
260
|
-
|
|
261
|
-
scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def apply_filter_clauses(scope, filter_nodes, model = nil)
|
|
265
|
-
return scope if filter_nodes.empty?
|
|
266
|
-
|
|
267
|
-
filter_nodes.each do |node|
|
|
268
|
-
value = node.value
|
|
269
|
-
|
|
270
|
-
scope = if value.is_a?(Hash) && value[:geo_distance]
|
|
271
|
-
apply_geo_filter(scope, node)
|
|
272
|
-
elsif model && !column?(model, node.field.to_s)
|
|
273
|
-
# A filter on a mapping-only field cannot be enforced;
|
|
274
|
-
# silently dropping it would broaden results, so fail closed.
|
|
275
|
-
scope.none
|
|
276
|
-
elsif model && array_column?(model, node.field.to_s)
|
|
277
|
-
apply_array_filter(scope, node.field.to_s, value, model)
|
|
278
|
-
else
|
|
279
|
-
scope.where(node.field => value)
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
scope
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def apply_array_filter(scope, field, value, model)
|
|
287
|
-
cast = "#{model.columns_hash[field].sql_type.sub(/\[\]\z/, '')}[]"
|
|
288
|
-
operator = value.is_a?(Array) ? "&&" : "@>"
|
|
289
|
-
|
|
290
|
-
scope.where("#{quoted_column(field)} #{operator} ARRAY[?]::#{cast}", value)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def apply_geo_filter(scope, node)
|
|
294
|
-
# Requires PostGIS
|
|
295
|
-
geo_config = node.value[:geo_distance]
|
|
296
|
-
distance = geo_config[:distance]
|
|
297
|
-
field = node.field.to_s
|
|
298
|
-
|
|
299
|
-
# Find the geo point in config
|
|
300
|
-
geo_point = geo_config.find { |_k, v| v.is_a?(Hash) && v[:lat] && v[:lon] }&.last
|
|
301
|
-
return scope unless geo_point
|
|
302
|
-
|
|
303
|
-
# Use PostGIS ST_DWithin for efficient geo filtering
|
|
304
|
-
scope.where(
|
|
305
|
-
"ST_DWithin(#{field}::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)",
|
|
306
|
-
geo_point[:lon],
|
|
307
|
-
geo_point[:lat],
|
|
308
|
-
parse_distance(distance)
|
|
309
|
-
)
|
|
310
|
-
rescue StandardError
|
|
311
|
-
# If PostGIS not available, skip geo filter
|
|
312
|
-
scope
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def apply_sorting(scope, sort_nodes, model = nil)
|
|
316
|
-
sorted_fields = []
|
|
317
|
-
order_clauses = sort_nodes.filter_map do |node|
|
|
318
|
-
field = node.field.to_s
|
|
319
|
-
# Sorting on a mapping-only field is cosmetic — drop it instead of
|
|
320
|
-
# erroring the whole query.
|
|
321
|
-
next if model && !column?(model, field)
|
|
322
|
-
|
|
323
|
-
sorted_fields << field
|
|
324
|
-
direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
|
325
|
-
"#{quoted_column(field)} #{direction}"
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
primary_key = model.respond_to?(:primary_key) ? model.primary_key : nil
|
|
329
|
-
order_clauses << "#{quoted_column(primary_key)} ASC" if primary_key && !sorted_fields.include?(primary_key.to_s)
|
|
330
|
-
return scope if order_clauses.empty?
|
|
331
|
-
|
|
332
|
-
scope.order(Arel.sql(order_clauses.join(", ")))
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def apply_pagination(scope, paginate_node)
|
|
336
|
-
page = paginate_node&.page || 1
|
|
337
|
-
per_page = paginate_node&.per_page || DEFAULT_LIMIT
|
|
338
|
-
|
|
339
|
-
offset = (page - 1) * per_page
|
|
340
|
-
|
|
341
|
-
scope.limit(per_page).offset(offset)
|
|
342
|
-
end
|
|
343
|
-
|
|
344
165
|
# Response formatting
|
|
345
166
|
|
|
346
167
|
def format_as_search_response(records, model, total: records.size)
|
|
@@ -365,33 +186,6 @@ module Noiseless
|
|
|
365
186
|
}
|
|
366
187
|
end
|
|
367
188
|
|
|
368
|
-
def empty_response
|
|
369
|
-
{
|
|
370
|
-
"took" => 0,
|
|
371
|
-
"timed_out" => false,
|
|
372
|
-
"_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
|
|
373
|
-
"hits" => {
|
|
374
|
-
"total" => { "value" => 0, "relation" => "eq" },
|
|
375
|
-
"max_score" => nil,
|
|
376
|
-
"hits" => []
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def error_response(error)
|
|
382
|
-
{
|
|
383
|
-
"took" => 0,
|
|
384
|
-
"timed_out" => false,
|
|
385
|
-
"_shards" => { "total" => 1, "successful" => 0, "skipped" => 0, "failed" => 1 },
|
|
386
|
-
"hits" => {
|
|
387
|
-
"total" => { "value" => 0, "relation" => "eq" },
|
|
388
|
-
"max_score" => nil,
|
|
389
|
-
"hits" => []
|
|
390
|
-
},
|
|
391
|
-
"error" => { "type" => error.class.name, "reason" => error.message }
|
|
392
|
-
}
|
|
393
|
-
end
|
|
394
|
-
|
|
395
189
|
# Helper methods
|
|
396
190
|
|
|
397
191
|
def resolve_model(indexes, model_class = nil)
|
|
@@ -406,69 +200,14 @@ module Noiseless
|
|
|
406
200
|
# Try cached model first (populated via register_model)
|
|
407
201
|
return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
|
|
408
202
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
model_name.constantize
|
|
412
|
-
rescue NameError
|
|
413
|
-
nil
|
|
203
|
+
candidate = index_name.to_s.classify.safe_constantize
|
|
204
|
+
candidate if active_record_model?(candidate)
|
|
414
205
|
end
|
|
415
206
|
|
|
416
207
|
def active_record_model?(klass)
|
|
417
208
|
klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
418
209
|
end
|
|
419
210
|
|
|
420
|
-
def trgm_available?
|
|
421
|
-
@trgm_available ||= available_extensions.include?("pg_trgm")
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def unaccent_available?
|
|
425
|
-
@unaccent_available ||= available_extensions.include?("unaccent")
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
def column?(model, field)
|
|
429
|
-
model.columns_hash.key?(field.to_s)
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def text_column?(model, field)
|
|
433
|
-
column = model.columns_hash[field.to_s]
|
|
434
|
-
column && %i[string text citext].include?(column.type) && !column.array
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
def array_column?(model, field)
|
|
438
|
-
model.columns_hash[field.to_s]&.array
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
def fuzzy_column(field)
|
|
442
|
-
unaccent_available? ? "unaccent(#{quoted_column(field)})" : quoted_column(field)
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
def fuzzy_param
|
|
446
|
-
unaccent_available? ? "unaccent(?)" : "?"
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
def quoted_column(field)
|
|
450
|
-
ActiveRecord::Base.connection.quote_column_name(field)
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def sanitize_like(value)
|
|
454
|
-
# Escape special LIKE characters
|
|
455
|
-
value.to_s.gsub(/[%_\\]/) { |x| "\\#{x}" }
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
def parse_distance(distance)
|
|
459
|
-
# Parse OpenSearch distance format (e.g., "10km", "5mi")
|
|
460
|
-
case distance.to_s
|
|
461
|
-
when /(\d+(?:\.\d+)?)\s*km/i
|
|
462
|
-
::Regexp.last_match(1).to_f * 1000
|
|
463
|
-
when /(\d+(?:\.\d+)?)\s*mi/i
|
|
464
|
-
::Regexp.last_match(1).to_f * 1609.34
|
|
465
|
-
when /(\d+(?:\.\d+)?)\s*m/i
|
|
466
|
-
::Regexp.last_match(1).to_f
|
|
467
|
-
else
|
|
468
|
-
distance.to_f
|
|
469
|
-
end
|
|
470
|
-
end
|
|
471
|
-
|
|
472
211
|
def process_bulk_action(action)
|
|
473
212
|
if action[:index]
|
|
474
213
|
{ "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }
|