noiseless 0.7.0 → 0.7.1
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: 1b661712a5930c3da3a455f6e70c2d99cfbc424cf4e007c58abd587ab170854f
|
|
4
|
+
data.tar.gz: 547b60b50d5efb9b32da878523d96d5a58c5a7a645ab8bed13ebec40269370b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17a625f20fb2c879f427d429c82ecb4b9c2b338ffec03bd69de83ee9c2b800800c57eaef0bb6f4d2e32876e91497c5b12bdf389a2f1edcb055bc228764b03473
|
|
7
|
+
data.tar.gz: 4799c4ba712b8d097b30553ac800caa798349d70c0ecbc7ca901b3f54752a9ed48ce2fcb4d3784d76117c0b7598801efc6f9e3685c5c945e34639740970a8d2e
|
|
@@ -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)
|
|
@@ -211,6 +186,10 @@ module Noiseless
|
|
|
211
186
|
field = node.field.to_s
|
|
212
187
|
value = node.value.to_s
|
|
213
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
|
+
|
|
214
193
|
# Use pg_trgm similarity for fuzzy matching with unaccent
|
|
215
194
|
if trgm_available? && text_column?(model, field)
|
|
216
195
|
scope.where(
|
|
@@ -227,7 +206,10 @@ module Noiseless
|
|
|
227
206
|
|
|
228
207
|
def apply_multi_match(scope, node, model)
|
|
229
208
|
query = node.query.to_s
|
|
230
|
-
fields
|
|
209
|
+
# Drop mapping-only fields; if none of the requested fields are real
|
|
210
|
+
# columns the query cannot be satisfied — fail closed.
|
|
211
|
+
fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
|
|
212
|
+
return scope.none if fields.empty?
|
|
231
213
|
|
|
232
214
|
conditions = fields.map do |field|
|
|
233
215
|
if trgm_available? && text_column?(model, field)
|
|
@@ -272,7 +254,7 @@ module Noiseless
|
|
|
272
254
|
scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
|
|
273
255
|
end
|
|
274
256
|
|
|
275
|
-
def apply_filter_clauses(scope, filter_nodes)
|
|
257
|
+
def apply_filter_clauses(scope, filter_nodes, model = nil)
|
|
276
258
|
return scope if filter_nodes.empty?
|
|
277
259
|
|
|
278
260
|
filter_nodes.each do |node|
|
|
@@ -280,6 +262,10 @@ module Noiseless
|
|
|
280
262
|
|
|
281
263
|
scope = if value.is_a?(Hash) && value[:geo_distance]
|
|
282
264
|
apply_geo_filter(scope, node)
|
|
265
|
+
elsif model && !column?(model, node.field.to_s)
|
|
266
|
+
# A filter on a mapping-only field cannot be enforced;
|
|
267
|
+
# silently dropping it would broaden results, so fail closed.
|
|
268
|
+
scope.none
|
|
283
269
|
else
|
|
284
270
|
scope.where(node.field => value)
|
|
285
271
|
end
|
|
@@ -310,13 +296,19 @@ module Noiseless
|
|
|
310
296
|
scope
|
|
311
297
|
end
|
|
312
298
|
|
|
313
|
-
def apply_sorting(scope, sort_nodes)
|
|
299
|
+
def apply_sorting(scope, sort_nodes, model = nil)
|
|
314
300
|
return scope if sort_nodes.empty?
|
|
315
301
|
|
|
316
|
-
order_clauses = sort_nodes.
|
|
302
|
+
order_clauses = sort_nodes.filter_map do |node|
|
|
303
|
+
field = node.field.to_s
|
|
304
|
+
# Sorting on a mapping-only field is cosmetic — drop it instead of
|
|
305
|
+
# erroring the whole query.
|
|
306
|
+
next if model && !column?(model, field)
|
|
307
|
+
|
|
317
308
|
direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
|
318
|
-
"#{quoted_column(
|
|
309
|
+
"#{quoted_column(field)} #{direction}"
|
|
319
310
|
end
|
|
311
|
+
return scope if order_clauses.empty?
|
|
320
312
|
|
|
321
313
|
scope.order(Arel.sql(order_clauses.join(", ")))
|
|
322
314
|
end
|
|
@@ -332,9 +324,7 @@ module Noiseless
|
|
|
332
324
|
|
|
333
325
|
# Response formatting
|
|
334
326
|
|
|
335
|
-
def format_as_search_response(records, model)
|
|
336
|
-
total = records.size
|
|
337
|
-
|
|
327
|
+
def format_as_search_response(records, model, total: records.size)
|
|
338
328
|
hits = records.map do |record|
|
|
339
329
|
{
|
|
340
330
|
"_index" => model.table_name,
|
|
@@ -386,12 +376,15 @@ module Noiseless
|
|
|
386
376
|
# Helper methods
|
|
387
377
|
|
|
388
378
|
def resolve_model(indexes, model_class = nil)
|
|
389
|
-
|
|
379
|
+
# The standard Model#execute path passes the Noiseless::Model search
|
|
380
|
+
# class here; only an ActiveRecord model can back a PG search, so
|
|
381
|
+
# fall through to index-name resolution for anything else.
|
|
382
|
+
return model_class if active_record_model?(model_class)
|
|
390
383
|
|
|
391
384
|
index_name = indexes&.first
|
|
392
385
|
return nil unless index_name
|
|
393
386
|
|
|
394
|
-
# Try cached model first
|
|
387
|
+
# Try cached model first (populated via register_model)
|
|
395
388
|
return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
|
|
396
389
|
|
|
397
390
|
# Try to infer model from index name
|
|
@@ -401,6 +394,10 @@ module Noiseless
|
|
|
401
394
|
nil
|
|
402
395
|
end
|
|
403
396
|
|
|
397
|
+
def active_record_model?(klass)
|
|
398
|
+
klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
399
|
+
end
|
|
400
|
+
|
|
404
401
|
def trgm_available?
|
|
405
402
|
@trgm_available ||= available_extensions.include?("pg_trgm")
|
|
406
403
|
end
|
|
@@ -409,9 +406,13 @@ module Noiseless
|
|
|
409
406
|
@unaccent_available ||= available_extensions.include?("unaccent")
|
|
410
407
|
end
|
|
411
408
|
|
|
409
|
+
def column?(model, field)
|
|
410
|
+
model.columns_hash.key?(field.to_s)
|
|
411
|
+
end
|
|
412
|
+
|
|
412
413
|
def text_column?(model, field)
|
|
413
414
|
column = model.columns_hash[field.to_s]
|
|
414
|
-
column && %i[string text].include?(column.type)
|
|
415
|
+
column && %i[string text citext].include?(column.type) && !column.array
|
|
415
416
|
end
|
|
416
417
|
|
|
417
418
|
def quoted_column(field)
|
|
@@ -439,18 +440,9 @@ module Noiseless
|
|
|
439
440
|
|
|
440
441
|
def process_bulk_action(action)
|
|
441
442
|
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 }
|
|
443
|
+
{ "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }
|
|
448
444
|
elsif action[:delete]
|
|
449
|
-
|
|
450
|
-
id = action[:delete][:_id]
|
|
451
|
-
|
|
452
|
-
result = execute_delete_document(index, id)
|
|
453
|
-
{ "delete" => result }
|
|
445
|
+
{ "delete" => execute_delete_document(action[:delete][:_index], action[:delete][:_id]) }
|
|
454
446
|
else
|
|
455
447
|
{ "error" => "Unknown action type" }
|
|
456
448
|
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