noiseless 0.6.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: 191caa0c33ef498748dcff326328dbd5e02b7f3a225ef1999b91127edb23490b
4
- data.tar.gz: 3260bb7ca298d9b3ccdfb9e89a5a6e89759dbe56e2637b811439653cef4f9673
3
+ metadata.gz: 1b661712a5930c3da3a455f6e70c2d99cfbc424cf4e007c58abd587ab170854f
4
+ data.tar.gz: 547b60b50d5efb9b32da878523d96d5a58c5a7a645ab8bed13ebec40269370b7
5
5
  SHA512:
6
- metadata.gz: eff7ee517dd4bea214b4602231baf34e518fc01546f3fe47f8b759aa75efb889aa6113bca2de56031b5ebf01ac893099217be4f5afc7af7c0802bf8686759601
7
- data.tar.gz: f0cd3e05dafd3b3a86a7d6a278eac89a5230dbd859674fe209264e8b033a8ec75640a44d969ec1cf945e3ad414fa4137b9816871f1c05e9e30d1e6eb9957d6c9
6
+ metadata.gz: 17a625f20fb2c879f427d429c82ecb4b9c2b338ffec03bd69de83ee9c2b800800c57eaef0bb6f4d2e32876e91497c5b12bdf389a2f1edcb055bc228764b03473
7
+ data.tar.gz: 4799c4ba712b8d097b30553ac800caa798349d70c0ecbc7ca901b3f54752a9ed48ce2fcb4d3784d76117c0b7598801efc6f9e3685c5c945e34639740970a8d2e
@@ -359,8 +359,8 @@ module Noiseless
359
359
  end
360
360
 
361
361
  def build_aggregations_hash(aggregations)
362
- aggregations.each_with_object({}) do |agg, hash|
363
- hash[agg.name] = build_single_aggregation(agg)
362
+ aggregations.to_h do |agg|
363
+ [agg.name, build_single_aggregation(agg)]
364
364
  end
365
365
  end
366
366
 
@@ -30,11 +30,27 @@ module Noiseless
30
30
  # +timeout:+.
31
31
  DEFAULT_TIMEOUT = 5
32
32
 
33
- def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, **connection_params)
33
+ # Default wall-clock deadline (seconds) for a complete round-trip:
34
+ # request write, response headers, and full body. The idle timeout
35
+ # above never trips against a backend that keeps trickling bytes —
36
+ # each read makes "progress" — which leaves callers blocked in
37
+ # Sync { ... .wait } indefinitely. This caps total duration instead.
38
+ # Override per-connection with +request_timeout:+; nil disables the
39
+ # deadline (long bulk imports may need a higher value or nil).
40
+ DEFAULT_REQUEST_TIMEOUT = 30
41
+
42
+ BufferedResponse = Struct.new(:status, :body) do
43
+ def read = body
44
+ def success? = (200..299).cover?(status)
45
+ def close = nil
46
+ end
47
+
48
+ def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, request_timeout: DEFAULT_REQUEST_TIMEOUT, **connection_params)
34
49
  # Ensure we always have at least one host
35
50
  hosts_array = Array(hosts)
36
51
  @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
37
52
  @timeout = timeout
53
+ @request_timeout = request_timeout
38
54
  @connection_params = connection_params
39
55
 
40
56
  # Initialize HTTP clients for each host. The endpoint timeout makes a
@@ -95,7 +111,27 @@ module Noiseless
95
111
  host = @hosts.sample
96
112
  client = @clients[host]
97
113
 
98
- wrap_transport_errors(host: host) { yield(client) }
114
+ wrap_transport_errors(host: host) do
115
+ with_request_deadline(host: host) do
116
+ buffer_response(yield(client))
117
+ end
118
+ end
119
+ end
120
+
121
+ def with_request_deadline(host:, &)
122
+ task = @request_timeout && Async::Task.current?
123
+ return yield unless task
124
+
125
+ task.with_timeout(@request_timeout, &)
126
+ rescue Async::TimeoutError
127
+ raise Noiseless::ConnectionError,
128
+ "search backend at #{host} exceeded request_timeout (#{@request_timeout}s wall-clock)"
129
+ end
130
+
131
+ def buffer_response(response)
132
+ BufferedResponse.new(response.status, response.read)
133
+ ensure
134
+ response.close
99
135
  end
100
136
 
101
137
  def parse_json_response!(response, error_class: Noiseless::RequestError, context: nil)
@@ -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
@@ -402,7 +402,7 @@ module Noiseless
402
402
  headers
403
403
  end
404
404
 
405
- # rubocop:disable Lint/DuplicateBranch
405
+ # rubocop:disable-next Lint/DuplicateBranch
406
406
  def map_type_to_typesense(elasticsearch_type)
407
407
  # Map Elasticsearch types to Typesense types
408
408
  case elasticsearch_type
@@ -418,7 +418,6 @@ module Noiseless
418
418
  "string" # Default to string for unknown types
419
419
  end
420
420
  end
421
- # rubocop:enable Lint/DuplicateBranch
422
421
  end
423
422
  end
424
423
  end
@@ -7,9 +7,10 @@ module Noiseless
7
7
  @configs = {}
8
8
  end
9
9
 
10
- # Register a named client statically from YAML (boot-time only)
11
- def register(name, adapter:, hosts:, timeout: nil)
12
- @configs[name.to_sym] = { adapter: adapter, hosts: hosts, timeout: timeout }
10
+ # Register a named client statically from YAML (boot-time only).
11
+ # request_timeout: :default uses the adapter default, nil disables the deadline.
12
+ def register(name, adapter:, hosts:, timeout: nil, request_timeout: :default)
13
+ @configs[name.to_sym] = { adapter: adapter, hosts: hosts, timeout: timeout, request_timeout: request_timeout }
13
14
  end
14
15
 
15
16
  # Retrieve a client; defaults to :primary
@@ -21,6 +22,7 @@ module Noiseless
21
22
  config = @configs.fetch(name) { raise "Unknown connection: #{name}" }
22
23
  params = { hosts: config[:hosts] }
23
24
  params[:timeout] = config[:timeout] unless config[:timeout].nil?
25
+ params[:request_timeout] = config[:request_timeout] unless config[:request_timeout] == :default
24
26
  Adapters.lookup(config[:adapter], **params)
25
27
  end
26
28
  end
@@ -221,13 +221,13 @@ module Noiseless
221
221
  end
222
222
 
223
223
  def to_ast
224
- filter_nodes = @nodes.select { |n| n.is_a?(AST::Filter) }
225
- vector_nodes = @nodes.select { |n| n.is_a?(AST::Vector) }
224
+ filter_nodes = @nodes.grep(AST::Filter)
225
+ vector_nodes = @nodes.grep(AST::Vector)
226
226
  must_nodes = @nodes.reject do |n|
227
227
  n.is_a?(AST::Filter) || n.is_a?(AST::Sort) || n.is_a?(AST::Paginate) || n.is_a?(AST::Vector)
228
228
  end
229
229
  bool_node = AST::Bool.new(must: must_nodes, filter: filter_nodes)
230
- sort_nodes = @nodes.select { |n| n.is_a?(AST::Sort) }
230
+ sort_nodes = @nodes.grep(AST::Sort)
231
231
  paginate_node = @nodes.find { |n| n.is_a?(AST::Paginate) }
232
232
  AST::Root.new(
233
233
  indexes: @indexes,
@@ -13,7 +13,7 @@ module Noiseless
13
13
  end
14
14
 
15
15
  def each
16
- return enum_for(__method__) unless block_given?
16
+ enum_for(__method__) unless block_given?
17
17
  end
18
18
  end
19
19
  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.6.0"
4
+ VERSION = "0.7.1"
5
5
  end
data/lib/noiseless.rb CHANGED
@@ -98,7 +98,9 @@ module Noiseless
98
98
  config.connections_config.each do |name, params|
99
99
  adapter_name = params[:adapter]
100
100
  hosts = params[:hosts] || []
101
- connections.register(name, adapter: adapter_name, hosts: hosts, timeout: params[:timeout])
101
+ register_params = { adapter: adapter_name, hosts: hosts, timeout: params[:timeout] }
102
+ register_params[:request_timeout] = params[:request_timeout] if params.key?(:request_timeout)
103
+ connections.register(name, **register_params)
102
104
  end
103
105
  end
104
106
 
@@ -11,9 +11,7 @@ namespace :release do
11
11
  version_path = File.expand_path("../../lib/noiseless/version", __dir__)
12
12
  require version_path
13
13
 
14
- if spec.version.to_s != Noiseless::VERSION
15
- raise "Version mismatch: gemspec=#{spec.version} lib=#{Noiseless::VERSION}"
16
- end
14
+ raise "Version mismatch: gemspec=#{spec.version} lib=#{Noiseless::VERSION}" if spec.version.to_s != Noiseless::VERSION
17
15
 
18
16
  ruby = Shellwords.escape(RbConfig.ruby)
19
17
  sh "#{ruby} -Ilib -e 'require \"noiseless\"; abort(\"version mismatch\") unless Noiseless::VERSION == \"#{Noiseless::VERSION}\"'"
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.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -29,42 +29,42 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.20'
32
+ version: '2.45'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.20'
39
+ version: '2.45'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: async-http
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.80'
46
+ version: '0.103'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.80'
53
+ version: '0.103'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: async-pool
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.8'
60
+ version: '0.11'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.8'
67
+ version: '0.11'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: railties
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -85,14 +85,14 @@ dependencies:
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '2.7'
88
+ version: '2.8'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '2.7'
95
+ version: '2.8'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: async-safe
98
98
  requirement: !ruby/object:Gem::Requirement
@@ -109,6 +109,20 @@ dependencies:
109
109
  version: '0.5'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: minitest
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '6.0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '6.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: minitest-mock
112
126
  requirement: !ruby/object:Gem::Requirement
113
127
  requirements:
114
128
  - - "~>"