noiseless 0.7.0 → 0.7.2
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a2305879802aa874c213e1a5a87707ec7222af979d21834f21f4fd2c994cb48
|
|
4
|
+
data.tar.gz: 065cf12ecb097ac17ee1e33d99d5df0ccb79ca4ea6713640f82f6bcac2fad51a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc5302df3e73a9a413e1a7382db1334e11dd28ee2564bb5ae9e0f5d6a2d62f7b241584463678ef6f0db6112740c70d5687fdc425903979056c9d61ff10140bc6
|
|
7
|
+
data.tar.gz: baba1a26426413fef74f6f900d3146aa9c7bcda385030dd3b722e8277c5dc5b8a25bb79adbf6dc9d0c695e4d3b43e4353e4fe99e3bffb7ec5fc5a0d18ff566cf
|
|
@@ -24,9 +24,10 @@ module Noiseless
|
|
|
24
24
|
return execute_vector_search(model, query_hash) if query_hash[:vector]
|
|
25
25
|
|
|
26
26
|
scope = build_search_scope(model, query_hash)
|
|
27
|
-
|
|
27
|
+
total = scope.except(:order, :limit, :offset).count
|
|
28
|
+
records = apply_pagination(scope, query_hash[:paginate]).to_a
|
|
28
29
|
|
|
29
|
-
format_as_search_response(records, model)
|
|
30
|
+
format_as_search_response(records, model, total: total)
|
|
30
31
|
rescue StandardError => e
|
|
31
32
|
error_response(e)
|
|
32
33
|
end
|
|
@@ -39,7 +40,7 @@ module Noiseless
|
|
|
39
40
|
scope = model.all
|
|
40
41
|
|
|
41
42
|
# Apply any filters first
|
|
42
|
-
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [])
|
|
43
|
+
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
|
|
43
44
|
|
|
44
45
|
# Apply vector search
|
|
45
46
|
scope = vector_search(
|
|
@@ -104,43 +105,20 @@ module Noiseless
|
|
|
104
105
|
false
|
|
105
106
|
end
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
record.save!
|
|
114
|
-
|
|
115
|
-
{ "_index" => index, "_id" => id, "result" => record.previously_new_record? ? "created" : "updated" }
|
|
116
|
-
rescue StandardError => e
|
|
117
|
-
{ "_index" => index, "_id" => id, "result" => "error", "error" => e.message }
|
|
108
|
+
# Document writes are no-ops: the table IS the index, so queries always
|
|
109
|
+
# see current data. Writing indexed documents (which may be transformed
|
|
110
|
+
# by mappings) back into source rows would corrupt them, and deleting a
|
|
111
|
+
# record because its index entry was removed inverts ownership.
|
|
112
|
+
def execute_index_document(index, id, _document, **)
|
|
113
|
+
{ "_index" => index, "_id" => id, "result" => "noop" }
|
|
118
114
|
end
|
|
119
115
|
|
|
120
|
-
def execute_update_document(index, id,
|
|
121
|
-
|
|
122
|
-
return { "_id" => id, "result" => "error", "error" => "Model not found" } unless model
|
|
123
|
-
|
|
124
|
-
record = model.find(id)
|
|
125
|
-
record.update!(changes.slice(*model.column_names))
|
|
126
|
-
|
|
127
|
-
{ "_index" => index, "_id" => id, "result" => "updated" }
|
|
128
|
-
rescue ActiveRecord::RecordNotFound
|
|
129
|
-
{ "_index" => index, "_id" => id, "result" => "not_found" }
|
|
130
|
-
rescue StandardError => e
|
|
131
|
-
{ "_index" => index, "_id" => id, "result" => "error", "error" => e.message }
|
|
116
|
+
def execute_update_document(index, id, _changes, **)
|
|
117
|
+
{ "_index" => index, "_id" => id, "result" => "noop" }
|
|
132
118
|
end
|
|
133
119
|
|
|
134
120
|
def execute_delete_document(index, id, **)
|
|
135
|
-
|
|
136
|
-
return { "_id" => id, "result" => "error", "error" => "Model not found" } unless model
|
|
137
|
-
|
|
138
|
-
model.destroy(id)
|
|
139
|
-
{ "_index" => index, "_id" => id, "result" => "deleted" }
|
|
140
|
-
rescue ActiveRecord::RecordNotFound
|
|
141
|
-
{ "_index" => index, "_id" => id, "result" => "not_found" }
|
|
142
|
-
rescue StandardError => e
|
|
143
|
-
{ "_index" => index, "_id" => id, "result" => "error", "error" => e.message }
|
|
121
|
+
{ "_index" => index, "_id" => id, "result" => "noop" }
|
|
144
122
|
end
|
|
145
123
|
|
|
146
124
|
def execute_document_exists?(index, id)
|
|
@@ -175,13 +153,10 @@ module Noiseless
|
|
|
175
153
|
scope = apply_must_clauses(scope, query_hash[:bool]&.must || [], model)
|
|
176
154
|
|
|
177
155
|
# Apply filter clauses (exact matches)
|
|
178
|
-
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [])
|
|
179
|
-
|
|
180
|
-
# Apply sorting
|
|
181
|
-
scope = apply_sorting(scope, query_hash[:sort] || [])
|
|
156
|
+
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
|
|
182
157
|
|
|
183
|
-
# Apply pagination
|
|
184
|
-
|
|
158
|
+
# Apply sorting (pagination is applied by the caller, after counting)
|
|
159
|
+
apply_sorting(scope, query_hash[:sort] || [], model)
|
|
185
160
|
end
|
|
186
161
|
|
|
187
162
|
def apply_must_clauses(scope, must_nodes, model)
|
|
@@ -194,11 +169,11 @@ module Noiseless
|
|
|
194
169
|
when AST::MultiMatch
|
|
195
170
|
apply_multi_match(scope, node, model)
|
|
196
171
|
when AST::Wildcard
|
|
197
|
-
apply_wildcard(scope, node)
|
|
172
|
+
apply_wildcard(scope, node, model)
|
|
198
173
|
when AST::Range
|
|
199
|
-
apply_range(scope, node)
|
|
174
|
+
apply_range(scope, node, model)
|
|
200
175
|
when AST::Prefix
|
|
201
|
-
apply_prefix(scope, node)
|
|
176
|
+
apply_prefix(scope, node, model)
|
|
202
177
|
else
|
|
203
178
|
scope
|
|
204
179
|
end
|
|
@@ -211,11 +186,16 @@ module Noiseless
|
|
|
211
186
|
field = node.field.to_s
|
|
212
187
|
value = node.value.to_s
|
|
213
188
|
|
|
214
|
-
#
|
|
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.
|
|
215
195
|
if trgm_available? && text_column?(model, field)
|
|
216
196
|
scope.where(
|
|
217
|
-
"
|
|
218
|
-
"
|
|
197
|
+
"#{fuzzy_column(field)} % #{fuzzy_param} OR " \
|
|
198
|
+
"#{fuzzy_column(field)} ILIKE #{fuzzy_param}",
|
|
219
199
|
value,
|
|
220
200
|
"%#{sanitize_like(value)}%"
|
|
221
201
|
)
|
|
@@ -227,12 +207,15 @@ module Noiseless
|
|
|
227
207
|
|
|
228
208
|
def apply_multi_match(scope, node, model)
|
|
229
209
|
query = node.query.to_s
|
|
230
|
-
fields
|
|
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?
|
|
231
214
|
|
|
232
215
|
conditions = fields.map do |field|
|
|
233
216
|
if trgm_available? && text_column?(model, field)
|
|
234
|
-
"(
|
|
235
|
-
"
|
|
217
|
+
"(#{fuzzy_column(field)} % #{fuzzy_param} OR " \
|
|
218
|
+
"#{fuzzy_column(field)} ILIKE #{fuzzy_param})"
|
|
236
219
|
else
|
|
237
220
|
"#{quoted_column(field)} ILIKE ?"
|
|
238
221
|
end
|
|
@@ -249,15 +232,19 @@ module Noiseless
|
|
|
249
232
|
scope.where(conditions.join(" OR "), *params)
|
|
250
233
|
end
|
|
251
234
|
|
|
252
|
-
def apply_wildcard(scope, node)
|
|
235
|
+
def apply_wildcard(scope, node, model = nil)
|
|
253
236
|
field = node.field.to_s
|
|
237
|
+
return scope.none if model && !column?(model, field)
|
|
238
|
+
|
|
254
239
|
# Convert OpenSearch wildcards to SQL: * -> %, ? -> _
|
|
255
240
|
pattern = node.value.to_s.tr("*", "%").tr("?", "_")
|
|
256
241
|
|
|
257
242
|
scope.where("#{quoted_column(field)} ILIKE ?", pattern)
|
|
258
243
|
end
|
|
259
244
|
|
|
260
|
-
def apply_range(scope, node)
|
|
245
|
+
def apply_range(scope, node, model = nil)
|
|
246
|
+
return scope.none if model && !column?(model, node.field.to_s)
|
|
247
|
+
|
|
261
248
|
field = quoted_column(node.field.to_s)
|
|
262
249
|
|
|
263
250
|
scope = scope.where("#{field} >= ?", node.gte) if node.gte
|
|
@@ -268,11 +255,13 @@ module Noiseless
|
|
|
268
255
|
scope
|
|
269
256
|
end
|
|
270
257
|
|
|
271
|
-
def apply_prefix(scope, node)
|
|
258
|
+
def apply_prefix(scope, node, model = nil)
|
|
259
|
+
return scope.none if model && !column?(model, node.field.to_s)
|
|
260
|
+
|
|
272
261
|
scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
|
|
273
262
|
end
|
|
274
263
|
|
|
275
|
-
def apply_filter_clauses(scope, filter_nodes)
|
|
264
|
+
def apply_filter_clauses(scope, filter_nodes, model = nil)
|
|
276
265
|
return scope if filter_nodes.empty?
|
|
277
266
|
|
|
278
267
|
filter_nodes.each do |node|
|
|
@@ -280,6 +269,12 @@ module Noiseless
|
|
|
280
269
|
|
|
281
270
|
scope = if value.is_a?(Hash) && value[:geo_distance]
|
|
282
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)
|
|
283
278
|
else
|
|
284
279
|
scope.where(node.field => value)
|
|
285
280
|
end
|
|
@@ -288,6 +283,13 @@ module Noiseless
|
|
|
288
283
|
scope
|
|
289
284
|
end
|
|
290
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
|
+
|
|
291
293
|
def apply_geo_filter(scope, node)
|
|
292
294
|
# Requires PostGIS
|
|
293
295
|
geo_config = node.value[:geo_distance]
|
|
@@ -310,14 +312,23 @@ module Noiseless
|
|
|
310
312
|
scope
|
|
311
313
|
end
|
|
312
314
|
|
|
313
|
-
def apply_sorting(scope, sort_nodes)
|
|
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)
|
|
315
322
|
|
|
316
|
-
|
|
323
|
+
sorted_fields << field
|
|
317
324
|
direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
|
318
|
-
"#{quoted_column(
|
|
325
|
+
"#{quoted_column(field)} #{direction}"
|
|
319
326
|
end
|
|
320
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
|
+
|
|
321
332
|
scope.order(Arel.sql(order_clauses.join(", ")))
|
|
322
333
|
end
|
|
323
334
|
|
|
@@ -332,9 +343,7 @@ module Noiseless
|
|
|
332
343
|
|
|
333
344
|
# Response formatting
|
|
334
345
|
|
|
335
|
-
def format_as_search_response(records, model)
|
|
336
|
-
total = records.size
|
|
337
|
-
|
|
346
|
+
def format_as_search_response(records, model, total: records.size)
|
|
338
347
|
hits = records.map do |record|
|
|
339
348
|
{
|
|
340
349
|
"_index" => model.table_name,
|
|
@@ -386,12 +395,15 @@ module Noiseless
|
|
|
386
395
|
# Helper methods
|
|
387
396
|
|
|
388
397
|
def resolve_model(indexes, model_class = nil)
|
|
389
|
-
|
|
398
|
+
# The standard Model#execute path passes the Noiseless::Model search
|
|
399
|
+
# class here; only an ActiveRecord model can back a PG search, so
|
|
400
|
+
# fall through to index-name resolution for anything else.
|
|
401
|
+
return model_class if active_record_model?(model_class)
|
|
390
402
|
|
|
391
403
|
index_name = indexes&.first
|
|
392
404
|
return nil unless index_name
|
|
393
405
|
|
|
394
|
-
# Try cached model first
|
|
406
|
+
# Try cached model first (populated via register_model)
|
|
395
407
|
return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
|
|
396
408
|
|
|
397
409
|
# Try to infer model from index name
|
|
@@ -401,6 +413,10 @@ module Noiseless
|
|
|
401
413
|
nil
|
|
402
414
|
end
|
|
403
415
|
|
|
416
|
+
def active_record_model?(klass)
|
|
417
|
+
klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
418
|
+
end
|
|
419
|
+
|
|
404
420
|
def trgm_available?
|
|
405
421
|
@trgm_available ||= available_extensions.include?("pg_trgm")
|
|
406
422
|
end
|
|
@@ -409,9 +425,25 @@ module Noiseless
|
|
|
409
425
|
@unaccent_available ||= available_extensions.include?("unaccent")
|
|
410
426
|
end
|
|
411
427
|
|
|
428
|
+
def column?(model, field)
|
|
429
|
+
model.columns_hash.key?(field.to_s)
|
|
430
|
+
end
|
|
431
|
+
|
|
412
432
|
def text_column?(model, field)
|
|
413
433
|
column = model.columns_hash[field.to_s]
|
|
414
|
-
column && %i[string text].include?(column.type)
|
|
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(?)" : "?"
|
|
415
447
|
end
|
|
416
448
|
|
|
417
449
|
def quoted_column(field)
|
|
@@ -439,18 +471,9 @@ module Noiseless
|
|
|
439
471
|
|
|
440
472
|
def process_bulk_action(action)
|
|
441
473
|
if action[:index]
|
|
442
|
-
index
|
|
443
|
-
id = action[:index][:_id]
|
|
444
|
-
data = action[:index][:data]
|
|
445
|
-
|
|
446
|
-
result = execute_index_document(index, id, data)
|
|
447
|
-
{ "index" => result }
|
|
474
|
+
{ "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }
|
|
448
475
|
elsif action[:delete]
|
|
449
|
-
|
|
450
|
-
id = action[:delete][:_id]
|
|
451
|
-
|
|
452
|
-
result = execute_delete_document(index, id)
|
|
453
|
-
{ "delete" => result }
|
|
476
|
+
{ "delete" => execute_delete_document(action[:delete][:_index], action[:delete][:_id]) }
|
|
454
477
|
else
|
|
455
478
|
{ "error" => "Unknown action type" }
|
|
456
479
|
end
|
|
@@ -12,7 +12,9 @@ module Noiseless
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def sources
|
|
15
|
-
|
|
15
|
+
# Indifferent access so callers can pluck(:id) or read ["id"] regardless
|
|
16
|
+
# of which adapter produced the hit.
|
|
17
|
+
@sources ||= hits.map { |hit| (hit["_source"] || {}).with_indifferent_access }
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
alias records sources
|
data/lib/noiseless/version.rb
CHANGED