noiseless 0.1.0 → 0.3.0

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: 3568cdfb6fe26188f2ccb0287265f3adfd128b8a056f06636496c8644a87cad3
4
- data.tar.gz: f0b29fa15d18a083950553337b9295e480c3966204dc614df5b9b4999b073742
3
+ metadata.gz: f66be9c1d81e3b887f0c0d4485381c4535f087f8365e2581c4c41d89cdba0934
4
+ data.tar.gz: 1565bffbcfb554d2b6afcc78ebb1ded79c5c58a8f157b1e25ccd9a449cd2abe2
5
5
  SHA512:
6
- metadata.gz: cda0f5f8ebdfa6d4f35f6caa9d8c49d179d0fd0d1bdbe928ac9605d3da67e91635b0a15827fcc74c5b2196b4381050a11051a97c1f6a8b75890a20f88d32be60
7
- data.tar.gz: f379b20348f2c293255aa56815e458a0668ad77aa0fe01026ed21239a751a815264bbccd87bee54dbad6b55496940f543c186f008621eaa5e767e455a6f9d156
6
+ metadata.gz: 4a7e23f4eb136bc6c0dc457dcb6f267e000269aceddd6450264ac489429dda2e20c7b382b32c2ab2a91271faeccee419a5c72530e3781087a9f630e8bd16a15d
7
+ data.tar.gz: 605cf0980142cbe65692b9ef8d2a543969578620fa17b35e069a0344ac1a5ca5947781c6d9cf617203408a73e11cd08626c000d046e4a7a9839508919de8d463
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "async"
4
+ require "json"
4
5
  require_relative "introspection"
5
6
 
6
7
  module Noiseless
@@ -8,6 +9,17 @@ module Noiseless
8
9
  include Instrumentation
9
10
  include Introspection
10
11
 
12
+ GEO_FILTER_KEYS = %i[geo_distance geo_bounding_box geo_polygon geo_shape].freeze
13
+ RANGE_OPERATORS = %i[gte lte gt lt].freeze
14
+
15
+ # Aggregation types that require a field (or script) to execute.
16
+ FIELD_BASED_AGG_TYPES = %i[
17
+ terms rare_terms significant_terms missing
18
+ avg sum min max cardinality value_count stats extended_stats
19
+ percentiles percentile_ranks histogram date_histogram
20
+ geohash_grid geotile_grid geo_distance
21
+ ].freeze
22
+
11
23
  def initialize(hosts: [], **connection_params)
12
24
  @hosts = hosts
13
25
  @connection_params = connection_params.dup
@@ -60,6 +72,14 @@ module Noiseless
60
72
  end
61
73
  end
62
74
 
75
+ def refresh_index(index_name)
76
+ Async do
77
+ instrument(:refresh_index, index: index_name) do
78
+ execute_refresh_index(index_name)
79
+ end
80
+ end
81
+ end
82
+
63
83
  def index_exists?(index_name)
64
84
  Async do
65
85
  execute_index_exists?(index_name)
@@ -188,11 +208,32 @@ module Noiseless
188
208
  {
189
209
  bool: {
190
210
  must: must_queries,
191
- filter: bool_node.filter.map { |f| { term: { f.field => f.value } } }
211
+ filter: bool_node.filter.map { |f| build_filter_clause(f) }
192
212
  }.reject { |_, v| v.empty? }
193
213
  }
194
214
  end
195
215
 
216
+ def build_filter_clause(node)
217
+ value = node.value
218
+ case value
219
+ when Hash
220
+ keys = value.keys.map(&:to_sym)
221
+ if keys.intersect?(GEO_FILTER_KEYS)
222
+ # Geo filters are already complete query clauses, e.g.
223
+ # { geo_distance: { distance: "100km", location: { lat:, lon: } } }
224
+ value
225
+ elsif (keys - RANGE_OPERATORS).empty?
226
+ { range: { node.field => value } }
227
+ else
228
+ { term: { node.field => value } }
229
+ end
230
+ when Array
231
+ { terms: { node.field => value } }
232
+ else
233
+ { term: { node.field => value } }
234
+ end
235
+ end
236
+
196
237
  def build_sort_hash(sort_nodes)
197
238
  return [] if sort_nodes.empty?
198
239
 
@@ -208,6 +249,31 @@ module Noiseless
208
249
  }
209
250
  end
210
251
 
252
+ # Parses a backend HTTP response, raising when the backend reported an error.
253
+ # Success responses return the parsed JSON payload; error responses raise
254
+ # `error_class` with the backend's error type/reason so failures are never
255
+ # silently converted into empty results.
256
+ def parse_json_response!(response, error_class: Noiseless::RequestError, context: nil)
257
+ body = response.read
258
+ return JSON.parse(body) if response.success?
259
+
260
+ payload = begin
261
+ JSON.parse(body)
262
+ rescue JSON::ParserError, TypeError
263
+ nil
264
+ end
265
+ error = payload.is_a?(Hash) ? payload["error"] : nil
266
+ reason = if error.is_a?(Hash)
267
+ [error["type"], error["reason"]].compact.join(": ")
268
+ elsif error
269
+ error.to_s
270
+ else
271
+ "HTTP #{response.status}"
272
+ end
273
+ message = context ? "#{context}: #{reason}" : reason
274
+ raise error_class.new(message, status: response.status, error_type: error.is_a?(Hash) ? error["type"] : nil)
275
+ end
276
+
211
277
  # Override in subclasses
212
278
  def execute_search(_query_hash, **_opts)
213
279
  {
@@ -233,6 +299,10 @@ module Noiseless
233
299
  { acknowledged: true }
234
300
  end
235
301
 
302
+ def execute_refresh_index(_index_name)
303
+ { "acknowledged" => true }
304
+ end
305
+
236
306
  def execute_index_exists?(_index_name)
237
307
  true
238
308
  end
@@ -302,6 +372,10 @@ module Noiseless
302
372
  agg_body[:field] = agg.field if agg.field
303
373
  agg_body.merge!(agg.options)
304
374
 
375
+ # Convention: an unqualified field-based aggregation targets the field
376
+ # named after the aggregation itself.
377
+ agg_body[:field] = agg.name if !agg_body.key?(:field) && !agg_body.key?(:script) && FIELD_BASED_AGG_TYPES.include?(agg.type.to_sym)
378
+
305
379
  result[agg.type] = agg_body
306
380
 
307
381
  # Add sub-aggregations if any
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ # Cluster health API - needed for Rails healthcheck
6
+ class ClusterAPI
7
+ def initialize(adapter)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def health(**)
12
+ Sync do
13
+ @adapter.send(:execute_cluster_health, **)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -7,22 +7,8 @@ module Noiseless
7
7
  class Elasticsearch < Adapter
8
8
  include ExecutionModules::ElasticsearchExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["ELASTICSEARCH_PORT"] || 9200
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
26
12
 
27
13
  # Cluster health API - needed for Rails healthcheck
28
14
  def cluster
@@ -34,36 +20,10 @@ module Noiseless
34
20
  @indices ||= IndicesAPI.new(self)
35
21
  end
36
22
 
37
- class ClusterAPI
38
- def initialize(adapter)
39
- @adapter = adapter
40
- end
41
-
42
- def health(**)
43
- Sync do
44
- @adapter.send(:execute_cluster_health, **)
45
- end
46
- end
47
- end
48
-
49
- class IndicesAPI
50
- def initialize(adapter)
51
- @adapter = adapter
52
- end
53
-
54
- def get(index:)
55
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
56
- end
57
-
58
- def stats(index:)
59
- # Return basic stats structure
60
- { "indices" => { index => {} } }
61
- end
23
+ private
62
24
 
63
- def refresh(index:)
64
- # Refresh the index to make documents immediately searchable
65
- @adapter.send(:execute_refresh_index, index)
66
- end
25
+ def default_port
26
+ ENV["ELASTICSEARCH_PORT"] || 9200
67
27
  end
68
28
  end
69
29
  end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "es_compatible_execution"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module ElasticsearchExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include EsCompatibleExecution
12
11
 
13
12
  private
14
13
 
@@ -17,16 +16,7 @@ module Noiseless
17
16
  body = JSON.generate(query_hash)
18
17
 
19
18
  response = post_request(path, body)
20
- JSON.parse(response.read)
21
- ensure
22
- response&.close
23
- end
24
-
25
- def execute_bulk(actions, **_opts)
26
- body = "#{actions.map { |action| JSON.generate(action) }.join("\n")}\n"
27
-
28
- response = post_request("/_bulk", body, content_type: "application/x-ndjson")
29
- JSON.parse(response.read)
19
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search")
30
20
  ensure
31
21
  response&.close
32
22
  end
@@ -37,42 +27,7 @@ module Noiseless
37
27
  body[:settings] = settings if settings
38
28
 
39
29
  response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
40
- JSON.parse(response.read)
41
- ensure
42
- response&.close
43
- end
44
-
45
- def execute_delete_index(index_name, **_opts)
46
- response = delete_request("/#{index_name}")
47
- JSON.parse(response.read)
48
- ensure
49
- response&.close
50
- end
51
-
52
- def execute_refresh_index(index_name)
53
- response = post_request("/#{index_name}/_refresh", nil)
54
- JSON.parse(response.read)
55
- rescue StandardError => e
56
- {
57
- "_shards" => {
58
- "total" => 0,
59
- "successful" => 0,
60
- "failed" => 0
61
- },
62
- "error" => {
63
- "type" => e.class.name,
64
- "reason" => e.message
65
- }
66
- }
67
- ensure
68
- response&.close
69
- end
70
-
71
- def execute_index_exists?(index_name)
72
- response = head_request("/#{index_name}")
73
- response.success?
74
- rescue StandardError
75
- false
30
+ parse_json_response!(response, context: "create index #{index_name}")
76
31
  ensure
77
32
  response&.close
78
33
  end
@@ -82,32 +37,7 @@ module Noiseless
82
37
  body = JSON.generate(document)
83
38
 
84
39
  response = id ? put_request(path, body) : post_request(path, body)
85
- JSON.parse(response.read)
86
- ensure
87
- response&.close
88
- end
89
-
90
- def execute_update_document(index, id, changes, **_opts)
91
- body = JSON.generate(doc: changes)
92
-
93
- response = post_request("/#{index}/_update/#{id}", body)
94
- JSON.parse(response.read)
95
- ensure
96
- response&.close
97
- end
98
-
99
- def execute_delete_document(index, id, **_opts)
100
- response = delete_request("/#{index}/_doc/#{id}")
101
- JSON.parse(response.read)
102
- ensure
103
- response&.close
104
- end
105
-
106
- def execute_document_exists?(index, id)
107
- response = head_request("/#{index}/_doc/#{id}")
108
- response.success?
109
- rescue StandardError
110
- false
40
+ parse_json_response!(response, context: "index document #{index}/#{id}")
111
41
  ensure
112
42
  response&.close
113
43
  end
@@ -132,56 +62,6 @@ module Noiseless
132
62
  ensure
133
63
  response&.close
134
64
  end
135
-
136
- # HTTP helpers using Async::HTTP with connection pooling
137
- def get_request(path)
138
- with_client do |client|
139
- client.get(path, default_headers)
140
- end
141
- end
142
-
143
- def post_request(path, body, content_type: "application/json")
144
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
145
-
146
- with_client do |client|
147
- client.post(path, headers, body)
148
- end
149
- end
150
-
151
- def put_request(path, body, content_type: "application/json")
152
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
153
-
154
- with_client do |client|
155
- client.put(path, headers, body)
156
- end
157
- end
158
-
159
- def delete_request(path)
160
- with_client do |client|
161
- client.delete(path, default_headers)
162
- end
163
- end
164
-
165
- def head_request(path)
166
- with_client do |client|
167
- client.head(path, default_headers)
168
- end
169
- end
170
-
171
- def with_client
172
- # Select a random host for load balancing
173
- host = @hosts.sample
174
- client = @clients[host]
175
-
176
- yield(client)
177
- end
178
-
179
- def default_headers
180
- [
181
- ["accept", "application/json"],
182
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
183
- ]
184
- end
185
65
  end
186
66
  end
187
67
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "http_transport"
5
+
6
+ module Noiseless
7
+ module Adapters
8
+ module ExecutionModules
9
+ # Document and index operations shared by the wire-compatible
10
+ # Elasticsearch and OpenSearch HTTP APIs.
11
+ module EsCompatibleExecution
12
+ include HttpTransport
13
+
14
+ private
15
+
16
+ def execute_bulk(actions, **_opts)
17
+ body = actions.map do |action|
18
+ if action[:index]
19
+ action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
20
+ data_line = action[:index][:data]
21
+ "#{JSON.generate(action_line)}\n#{JSON.generate(data_line)}\n"
22
+ else
23
+ "#{JSON.generate(action)}\n"
24
+ end
25
+ end.join
26
+
27
+ response = post_request("/_bulk", body, content_type: "application/x-ndjson")
28
+ parse_json_response!(response, context: "bulk")
29
+ ensure
30
+ response&.close
31
+ end
32
+
33
+ def execute_delete_index(index_name, **_opts)
34
+ response = delete_request("/#{index_name}")
35
+ # Deleting an absent index is idempotent, matching official ES/OS
36
+ # clients' ignore-404 behaviour.
37
+ return { "acknowledged" => true, "result" => "not_found" } if response.status == 404
38
+
39
+ parse_json_response!(response, context: "delete index #{index_name}")
40
+ ensure
41
+ response&.close
42
+ end
43
+
44
+ def execute_refresh_index(index_name)
45
+ response = post_request("/#{index_name}/_refresh", nil)
46
+ parse_json_response!(response, context: "refresh index #{index_name}")
47
+ ensure
48
+ response&.close
49
+ end
50
+
51
+ def execute_index_exists?(index_name)
52
+ response = head_request("/#{index_name}")
53
+ response.success?
54
+ rescue StandardError
55
+ false
56
+ ensure
57
+ response&.close
58
+ end
59
+
60
+ def execute_update_document(index, id, changes, **_opts)
61
+ body = JSON.generate(doc: changes)
62
+
63
+ response = post_request("/#{index}/_update/#{id}", body)
64
+ parse_json_response!(response, context: "update document #{index}/#{id}")
65
+ ensure
66
+ response&.close
67
+ end
68
+
69
+ def execute_delete_document(index, id, **_opts)
70
+ response = delete_request("/#{index}/_doc/#{id}")
71
+ # 404 covers both a missing document and a missing index; either way
72
+ # the delete is idempotent.
73
+ return { "_index" => index, "_id" => id, "result" => "not_found" } if response.status == 404
74
+
75
+ parse_json_response!(response, context: "delete document #{index}/#{id}")
76
+ ensure
77
+ response&.close
78
+ end
79
+
80
+ def execute_document_exists?(index, id)
81
+ response = head_request("/#{index}/_doc/#{id}")
82
+ response.success?
83
+ rescue StandardError
84
+ false
85
+ ensure
86
+ response&.close
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ module ExecutionModules
6
+ # Shared Async::HTTP connection handling for HTTP-based adapters.
7
+ # Host classes must provide a private +default_port+ method.
8
+ module HttpTransport
9
+ def initialize(hosts: [], **connection_params)
10
+ # Ensure we always have at least one host
11
+ hosts_array = Array(hosts)
12
+ @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
13
+ @connection_params = connection_params
14
+
15
+ # Initialize HTTP clients for each host
16
+ @clients = {}
17
+ @hosts.each do |host|
18
+ endpoint = Async::HTTP::Endpoint.parse(host)
19
+ @clients[host] = Async::HTTP::Client.new(endpoint)
20
+ end
21
+
22
+ super(hosts: @hosts, **connection_params)
23
+ end
24
+
25
+ def close
26
+ @clients&.each_value(&:close)
27
+ end
28
+
29
+ private
30
+
31
+ # HTTP helpers using Async::HTTP with connection pooling
32
+ def get_request(path)
33
+ with_client do |client|
34
+ client.get(path, default_headers)
35
+ end
36
+ end
37
+
38
+ def post_request(path, body, content_type: "application/json")
39
+ headers = body ? default_headers + [["content-type", content_type]] : default_headers
40
+
41
+ with_client do |client|
42
+ client.post(path, headers, body)
43
+ end
44
+ end
45
+
46
+ def put_request(path, body, content_type: "application/json")
47
+ headers = body ? default_headers + [["content-type", content_type]] : default_headers
48
+
49
+ with_client do |client|
50
+ client.put(path, headers, body)
51
+ end
52
+ end
53
+
54
+ def delete_request(path)
55
+ with_client do |client|
56
+ client.delete(path, default_headers)
57
+ end
58
+ end
59
+
60
+ def head_request(path)
61
+ with_client do |client|
62
+ client.head(path, default_headers)
63
+ end
64
+ end
65
+
66
+ def with_client
67
+ # Select a random host for load balancing
68
+ host = @hosts.sample
69
+ client = @clients[host]
70
+
71
+ yield(client)
72
+ end
73
+
74
+ def default_headers
75
+ [
76
+ ["accept", "application/json"],
77
+ ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
78
+ ]
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "es_compatible_execution"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module OpensearchExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include EsCompatibleExecution
12
11
 
13
12
  private
14
13
 
@@ -18,43 +17,7 @@ module Noiseless
18
17
  body = JSON.generate(query_hash)
19
18
 
20
19
  response = post_request(path, body)
21
- JSON.parse(response.read)
22
- rescue StandardError => e
23
- # Return empty response on error to maintain compatibility
24
- {
25
- took: 0,
26
- timed_out: false,
27
- _shards: { total: 0, successful: 0, skipped: 0, failed: 0 },
28
- hits: {
29
- total: { value: 0, relation: "eq" },
30
- max_score: nil,
31
- hits: []
32
- },
33
- error: {
34
- type: e.class.name,
35
- reason: e.message
36
- }
37
- }
38
- ensure
39
- response&.close
40
- end
41
-
42
- def execute_bulk(actions, **_opts)
43
- # Build bulk request body
44
- bulk_body = actions.map do |action|
45
- if action[:index]
46
- action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
47
- data_line = action[:index][:data]
48
- "#{JSON.generate(action_line)}\n#{JSON.generate(data_line)}\n"
49
- else
50
- "#{JSON.generate(action)}\n"
51
- end
52
- end.join
53
-
54
- response = post_request("/_bulk", bulk_body, content_type: "application/x-ndjson")
55
- JSON.parse(response.read)
56
- rescue StandardError => e
57
- { items: [], errors: true, error: { type: e.class.name, reason: e.message } }
20
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search #{index_path}")
58
21
  ensure
59
22
  response&.close
60
23
  end
@@ -65,46 +28,7 @@ module Noiseless
65
28
  body[:settings] = settings if settings
66
29
 
67
30
  response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
68
- JSON.parse(response.read)
69
- rescue StandardError => e
70
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
71
- ensure
72
- response&.close
73
- end
74
-
75
- def execute_delete_index(index_name, **_opts)
76
- response = delete_request("/#{index_name}")
77
- JSON.parse(response.read)
78
- rescue StandardError => e
79
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
80
- ensure
81
- response&.close
82
- end
83
-
84
- def execute_refresh_index(index_name)
85
- response = post_request("/#{index_name}/_refresh", nil)
86
- JSON.parse(response.read)
87
- rescue StandardError => e
88
- {
89
- "_shards" => {
90
- "total" => 0,
91
- "successful" => 0,
92
- "failed" => 0
93
- },
94
- "error" => {
95
- "type" => e.class.name,
96
- "reason" => e.message
97
- }
98
- }
99
- ensure
100
- response&.close
101
- end
102
-
103
- def execute_index_exists?(index_name)
104
- response = head_request("/#{index_name}")
105
- response.success?
106
- rescue StandardError
107
- false
31
+ parse_json_response!(response, context: "create index #{index_name}")
108
32
  ensure
109
33
  response&.close
110
34
  end
@@ -114,38 +38,7 @@ module Noiseless
114
38
  body = JSON.generate(document)
115
39
 
116
40
  response = put_request(path, body)
117
- JSON.parse(response.read)
118
- rescue StandardError => e
119
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
120
- ensure
121
- response&.close
122
- end
123
-
124
- def execute_update_document(index, id, changes, **_opts)
125
- body = JSON.generate(doc: changes)
126
-
127
- response = post_request("/#{index}/_update/#{id}", body)
128
- JSON.parse(response.read)
129
- rescue StandardError => e
130
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
131
- ensure
132
- response&.close
133
- end
134
-
135
- def execute_delete_document(index, id, **_opts)
136
- response = delete_request("/#{index}/_doc/#{id}")
137
- JSON.parse(response.read)
138
- rescue StandardError => e
139
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
140
- ensure
141
- response&.close
142
- end
143
-
144
- def execute_document_exists?(index, id)
145
- response = head_request("/#{index}/_doc/#{id}")
146
- response.success?
147
- rescue StandardError
148
- false
41
+ parse_json_response!(response, context: "index document #{index}/#{id}")
149
42
  ensure
150
43
  response&.close
151
44
  end
@@ -178,13 +71,7 @@ module Noiseless
178
71
  body = JSON.generate(enhanced_query)
179
72
 
180
73
  response = post_request("/_search", body)
181
- JSON.parse(response.read)
182
- rescue StandardError => e
183
- {
184
- pit_id: pit_id,
185
- error: { type: e.class.name, reason: e.message },
186
- hits: { total: { value: 0 }, hits: [] }
187
- }
74
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
188
75
  ensure
189
76
  response&.close
190
77
  end
@@ -198,12 +85,7 @@ module Noiseless
198
85
  body = JSON.generate(template_query)
199
86
 
200
87
  response = post_request("/_search/template", body)
201
- JSON.parse(response.read)
202
- rescue StandardError => e
203
- {
204
- error: { type: e.class.name, reason: e.message },
205
- hits: { total: { value: 0 }, hits: [] }
206
- }
88
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search template #{template_id}")
207
89
  ensure
208
90
  response&.close
209
91
  end
@@ -321,56 +203,6 @@ module Noiseless
321
203
  ensure
322
204
  response&.close
323
205
  end
324
-
325
- # HTTP helpers using Async::HTTP with connection pooling
326
- def get_request(path)
327
- with_client do |client|
328
- client.get(path, default_headers)
329
- end
330
- end
331
-
332
- def post_request(path, body, content_type: "application/json")
333
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
334
-
335
- with_client do |client|
336
- client.post(path, headers, body)
337
- end
338
- end
339
-
340
- def put_request(path, body, content_type: "application/json")
341
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
342
-
343
- with_client do |client|
344
- client.put(path, headers, body)
345
- end
346
- end
347
-
348
- def delete_request(path)
349
- with_client do |client|
350
- client.delete(path, default_headers)
351
- end
352
- end
353
-
354
- def head_request(path)
355
- with_client do |client|
356
- client.head(path, default_headers)
357
- end
358
- end
359
-
360
- def with_client
361
- # Select a random host for load balancing
362
- host = @hosts.sample
363
- client = @clients[host]
364
-
365
- yield(client)
366
- end
367
-
368
- def default_headers
369
- [
370
- ["accept", "application/json"],
371
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
372
- ]
373
- end
374
206
  end
375
207
  end
376
208
  end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "http_transport"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module TypesenseExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include HttpTransport
12
11
 
13
12
  private
14
13
 
@@ -391,54 +390,8 @@ module Noiseless
391
390
  response&.close
392
391
  end
393
392
 
394
- # HTTP helpers using Async::HTTP with connection pooling
395
- def get_request(path)
396
- with_client do |client|
397
- client.get(path, default_headers)
398
- end
399
- end
400
-
401
- def post_request(path, body, content_type: "application/json")
402
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
403
-
404
- with_client do |client|
405
- client.post(path, headers, body)
406
- end
407
- end
408
-
409
- def put_request(path, body, content_type: "application/json")
410
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
411
-
412
- with_client do |client|
413
- client.put(path, headers, body)
414
- end
415
- end
416
-
417
- def delete_request(path)
418
- with_client do |client|
419
- client.delete(path, default_headers)
420
- end
421
- end
422
-
423
- def head_request(path)
424
- with_client do |client|
425
- client.head(path, default_headers)
426
- end
427
- end
428
-
429
- def with_client
430
- # Select a random host for load balancing
431
- host = @hosts.sample
432
- client = @clients[host]
433
-
434
- yield(client)
435
- end
436
-
437
393
  def default_headers
438
- headers = [
439
- ["accept", "application/json"],
440
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
441
- ]
394
+ headers = super
442
395
 
443
396
  # Add Typesense API key if configured
444
397
  if @connection_params && @connection_params[:api_key]
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ # Indices API - needed for index management operations
6
+ class IndicesAPI
7
+ def initialize(adapter)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def get(index:)
12
+ @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
13
+ end
14
+
15
+ def stats(index:)
16
+ # Return basic stats structure
17
+ { "indices" => { index => {} } }
18
+ end
19
+
20
+ def refresh(index:)
21
+ # Refresh the index to make documents immediately searchable
22
+ @adapter.refresh_index(index).wait
23
+ end
24
+ end
25
+ end
26
+ end
@@ -7,22 +7,8 @@ module Noiseless
7
7
  class OpenSearch < Adapter
8
8
  include ExecutionModules::OpensearchExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["OPENSEARCH_PORT"] || 9200
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
26
12
 
27
13
  # OpenSearch-specific features
28
14
  def point_in_time_search(ast_node, pit_id:, **)
@@ -65,38 +51,6 @@ module Noiseless
65
51
  end
66
52
  end
67
53
 
68
- class ClusterAPI
69
- def initialize(adapter)
70
- @adapter = adapter
71
- end
72
-
73
- def health(**)
74
- Sync do
75
- @adapter.send(:execute_cluster_health, **)
76
- end
77
- end
78
- end
79
-
80
- class IndicesAPI
81
- def initialize(adapter)
82
- @adapter = adapter
83
- end
84
-
85
- def get(index:)
86
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
87
- end
88
-
89
- def stats(index:)
90
- # Return basic stats structure
91
- { "indices" => { index => {} } }
92
- end
93
-
94
- def refresh(index:)
95
- # Refresh the index to make documents immediately searchable
96
- @adapter.send(:execute_refresh_index, index)
97
- end
98
- end
99
-
100
54
  # Search Pipelines API for OpenSearch 3.x
101
55
  # Pipelines can include request and response processors for neural search, reranking, etc.
102
56
  class PipelinesAPI
@@ -203,6 +157,12 @@ module Noiseless
203
157
  end
204
158
  end
205
159
  end
160
+
161
+ private
162
+
163
+ def default_port
164
+ ENV["OPENSEARCH_PORT"] || 9200
165
+ end
206
166
  end
207
167
  end
208
168
  end
@@ -7,22 +7,7 @@ module Noiseless
7
7
  class Typesense < Adapter
8
8
  include ExecutionModules::TypesenseExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["TYPESENSE_PORT"] || 8108
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
26
11
 
27
12
  # Cluster health API - needed for Rails healthcheck
28
13
  def cluster
@@ -34,37 +19,18 @@ module Noiseless
34
19
  @indices ||= IndicesAPI.new(self)
35
20
  end
36
21
 
37
- class ClusterAPI
38
- def initialize(adapter)
39
- @adapter = adapter
40
- end
41
-
42
- def health(**)
43
- Sync do
44
- @adapter.send(:execute_cluster_health, **)
45
- end
46
- end
47
- end
48
-
49
- class IndicesAPI
50
- def initialize(adapter)
51
- @adapter = adapter
52
- end
53
-
54
- def get(index:)
55
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
56
- end
57
-
58
- def stats(index:)
59
- # Return basic stats structure
60
- { "indices" => { index => {} } }
61
- end
62
-
22
+ class IndicesAPI < Adapters::IndicesAPI
63
23
  def refresh(index: nil) # rubocop:disable Lint/UnusedMethodArgument
64
24
  # Typesense doesn't require explicit refresh - documents are immediately available
65
25
  { "_shards" => { "total" => 1, "successful" => 1, "failed" => 0 } }
66
26
  end
67
27
  end
28
+
29
+ private
30
+
31
+ def default_port
32
+ ENV["TYPESENSE_PORT"] || 8108
33
+ end
68
34
  end
69
35
  end
70
36
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module AST
5
+ # Base for leaf nodes that pair a single field with a value.
6
+ class FieldValueNode < Node
7
+ attr_reader :field, :value
8
+
9
+ def initialize(field, value)
10
+ super()
11
+ @field = field
12
+ @value = value
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Filter < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Filter < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Match < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Match < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Prefix < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Prefix < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Wildcard < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Wildcard < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noiseless
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/noiseless.rb CHANGED
@@ -17,6 +17,21 @@ require_relative "noiseless/version"
17
17
  module Noiseless
18
18
  class Error < StandardError; end
19
19
 
20
+ # HTTP-level failure from the search backend (non-2xx response).
21
+ class RequestError < Error
22
+ attr_reader :status, :error_type
23
+
24
+ def initialize(message, status: nil, error_type: nil)
25
+ super(message)
26
+ @status = status
27
+ @error_type = error_type
28
+ end
29
+ end
30
+
31
+ # Raised when a search query is rejected by the backend
32
+ # (malformed query, missing index, shard failures).
33
+ class SearchError < RequestError; end
34
+
20
35
  class Configuration
21
36
  attr_accessor :connections_config, :default_connection, :default_adapter, :config_path
22
37
 
@@ -85,7 +100,8 @@ module Noiseless
85
100
 
86
101
  # Setup Zeitwerk autoloader
87
102
  loader = Zeitwerk::Loader.for_gem
88
- loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch")
103
+ loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch",
104
+ "cluster_api" => "ClusterAPI", "indices_api" => "IndicesAPI")
89
105
  loader.ignore("#{__dir__}/application_search.rb")
90
106
  loader.ignore("#{__dir__}/noiseless/test_helper.rb")
91
107
  loader.ignore("#{__dir__}/noiseless/test_case.rb")
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -206,12 +206,16 @@ files:
206
206
  - lib/noiseless.rb
207
207
  - lib/noiseless/adapter.rb
208
208
  - lib/noiseless/adapters.rb
209
+ - lib/noiseless/adapters/cluster_api.rb
209
210
  - lib/noiseless/adapters/elasticsearch.rb
210
211
  - lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb
212
+ - lib/noiseless/adapters/execution_modules/es_compatible_execution.rb
213
+ - lib/noiseless/adapters/execution_modules/http_transport.rb
211
214
  - lib/noiseless/adapters/execution_modules/opensearch_execution.rb
212
215
  - lib/noiseless/adapters/execution_modules/pgvector_support.rb
213
216
  - lib/noiseless/adapters/execution_modules/postgresql_execution.rb
214
217
  - lib/noiseless/adapters/execution_modules/typesense_execution.rb
218
+ - lib/noiseless/adapters/indices_api.rb
215
219
  - lib/noiseless/adapters/open_search.rb
216
220
  - lib/noiseless/adapters/postgresql.rb
217
221
  - lib/noiseless/adapters/typesense.rb
@@ -222,6 +226,7 @@ files:
222
226
  - lib/noiseless/ast/collapse.rb
223
227
  - lib/noiseless/ast/combined_fields.rb
224
228
  - lib/noiseless/ast/conversation.rb
229
+ - lib/noiseless/ast/field_value_node.rb
225
230
  - lib/noiseless/ast/filter.rb
226
231
  - lib/noiseless/ast/hybrid.rb
227
232
  - lib/noiseless/ast/image_query.rb