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: 1d1df760c71373680498360fa0daef2cfc6a2dd42b08c7edd67f1027174958a5
4
- data.tar.gz: bd720e1daa952b0c64c769a1f2927d5dd9083a5c3722c97c71fc644ab47f0670
3
+ metadata.gz: 3a2305879802aa874c213e1a5a87707ec7222af979d21834f21f4fd2c994cb48
4
+ data.tar.gz: 065cf12ecb097ac17ee1e33d99d5df0ccb79ca4ea6713640f82f6bcac2fad51a
5
5
  SHA512:
6
- metadata.gz: 1802b68562f768b1eee9c5533d26f9a5f387cc8d3052f27474809f889ea142bc8d6903ca4c60c56cfae2989c6e5e36def0abcac0f205d8fe6e86f1a4d87bd979
7
- data.tar.gz: 8fad323b3284af9d96d9609dac487fa2980310d3470b074d0d135b2fbc09eef80d294f9369ae3836ec9fa6b0b7d515ed6cf1c7724e88ab55729a0e66ac1ae11d
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
- records = scope.to_a
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
- def execute_index_document(index, id, document, **)
108
- model = resolve_model([index])
109
- return { "_id" => id, "result" => "error", "error" => "Model not found" } unless model
110
-
111
- record = model.find_or_initialize_by(id: id)
112
- record.assign_attributes(document.slice(*model.column_names))
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, changes, **)
121
- model = resolve_model([index])
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
- model = resolve_model([index])
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
- apply_pagination(scope, query_hash[:paginate])
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
- # Use pg_trgm similarity for fuzzy matching with unaccent
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
- "unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
218
- "unaccent(#{quoted_column(field)}) ILIKE unaccent(?)",
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 = node.fields.map(&: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?
231
214
 
232
215
  conditions = fields.map do |field|
233
216
  if trgm_available? && text_column?(model, field)
234
- "(unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
235
- "unaccent(#{quoted_column(field)}) ILIKE unaccent(?))"
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
- return scope if sort_nodes.empty?
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
- order_clauses = sort_nodes.map do |node|
323
+ sorted_fields << field
317
324
  direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
318
- "#{quoted_column(node.field.to_s)} #{direction}"
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
- return model_class if model_class
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 = action[:index][:_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
- index = action[:delete][:_index]
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
- @sources ||= hits.map { |hit| hit["_source"] }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noiseless
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noiseless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih