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: 1d1df760c71373680498360fa0daef2cfc6a2dd42b08c7edd67f1027174958a5
4
- data.tar.gz: bd720e1daa952b0c64c769a1f2927d5dd9083a5c3722c97c71fc644ab47f0670
3
+ metadata.gz: 1b661712a5930c3da3a455f6e70c2d99cfbc424cf4e007c58abd587ab170854f
4
+ data.tar.gz: 547b60b50d5efb9b32da878523d96d5a58c5a7a645ab8bed13ebec40269370b7
5
5
  SHA512:
6
- metadata.gz: 1802b68562f768b1eee9c5533d26f9a5f387cc8d3052f27474809f889ea142bc8d6903ca4c60c56cfae2989c6e5e36def0abcac0f205d8fe6e86f1a4d87bd979
7
- data.tar.gz: 8fad323b3284af9d96d9609dac487fa2980310d3470b074d0d135b2fbc09eef80d294f9369ae3836ec9fa6b0b7d515ed6cf1c7724e88ab55729a0e66ac1ae11d
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
- 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)
@@ -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 = node.fields.map(&:to_s)
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.map do |node|
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(node.field.to_s)} #{direction}"
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
- return model_class if model_class
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 = 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 }
443
+ { "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }
448
444
  elsif action[:delete]
449
- index = action[:delete][:_index]
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
- @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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih