noiseless 0.0.0 → 0.2.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +28 -0
  3. data/README.md +214 -0
  4. data/lib/application_search.rb +15 -0
  5. data/lib/noiseless/adapter.rb +339 -0
  6. data/lib/noiseless/adapters/cluster_api.rb +18 -0
  7. data/lib/noiseless/adapters/elasticsearch.rb +30 -0
  8. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +68 -0
  9. data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +83 -0
  10. data/lib/noiseless/adapters/execution_modules/http_transport.rb +83 -0
  11. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +209 -0
  12. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
  13. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
  14. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +425 -0
  15. data/lib/noiseless/adapters/indices_api.rb +26 -0
  16. data/lib/noiseless/adapters/open_search.rb +168 -0
  17. data/lib/noiseless/adapters/postgresql.rb +171 -0
  18. data/lib/noiseless/adapters/typesense.rb +36 -0
  19. data/lib/noiseless/adapters.rb +14 -0
  20. data/lib/noiseless/ast/aggregation.rb +56 -0
  21. data/lib/noiseless/ast/bool.rb +16 -0
  22. data/lib/noiseless/ast/bulk.rb +18 -0
  23. data/lib/noiseless/ast/collapse.rb +16 -0
  24. data/lib/noiseless/ast/combined_fields.rb +33 -0
  25. data/lib/noiseless/ast/conversation.rb +29 -0
  26. data/lib/noiseless/ast/field_value_node.rb +16 -0
  27. data/lib/noiseless/ast/filter.rb +8 -0
  28. data/lib/noiseless/ast/hybrid.rb +35 -0
  29. data/lib/noiseless/ast/image_query.rb +29 -0
  30. data/lib/noiseless/ast/join.rb +31 -0
  31. data/lib/noiseless/ast/match.rb +8 -0
  32. data/lib/noiseless/ast/multi_match.rb +24 -0
  33. data/lib/noiseless/ast/paginate.rb +15 -0
  34. data/lib/noiseless/ast/prefix.rb +8 -0
  35. data/lib/noiseless/ast/range.rb +18 -0
  36. data/lib/noiseless/ast/root.rb +69 -0
  37. data/lib/noiseless/ast/search_after.rb +14 -0
  38. data/lib/noiseless/ast/sort.rb +15 -0
  39. data/lib/noiseless/ast/vector.rb +27 -0
  40. data/lib/noiseless/ast/wildcard.rb +8 -0
  41. data/lib/noiseless/ast.rb +30 -0
  42. data/lib/noiseless/bulk_importer.rb +195 -0
  43. data/lib/noiseless/callbacks.rb +138 -0
  44. data/lib/noiseless/connection_manager.rb +26 -0
  45. data/lib/noiseless/document_manager.rb +137 -0
  46. data/lib/noiseless/dsl.rb +107 -0
  47. data/lib/noiseless/generators/application_search_generator.rb +24 -0
  48. data/lib/noiseless/instrumentation.rb +174 -0
  49. data/lib/noiseless/introspection/console.rb +228 -0
  50. data/lib/noiseless/introspection/query_visualizer.rb +533 -0
  51. data/lib/noiseless/introspection.rb +221 -0
  52. data/lib/noiseless/mapping.rb +253 -0
  53. data/lib/noiseless/mapping_definition_processor.rb +231 -0
  54. data/lib/noiseless/model.rb +111 -0
  55. data/lib/noiseless/model_registry.rb +77 -0
  56. data/lib/noiseless/multi_search.rb +244 -0
  57. data/lib/noiseless/pagination.rb +375 -0
  58. data/lib/noiseless/query_builder.rb +284 -0
  59. data/lib/noiseless/railtie.rb +35 -0
  60. data/lib/noiseless/response/aggregations.rb +46 -0
  61. data/lib/noiseless/response/empty.rb +20 -0
  62. data/lib/noiseless/response/records.rb +94 -0
  63. data/lib/noiseless/response/results.rb +110 -0
  64. data/lib/noiseless/response/suggestions.rb +55 -0
  65. data/lib/noiseless/response.rb +98 -0
  66. data/lib/noiseless/response_factory.rb +32 -0
  67. data/lib/noiseless/runtime_reset_middleware.rb +15 -0
  68. data/lib/noiseless/search_index_update_job.rb +84 -0
  69. data/lib/noiseless/test_case.rb +230 -0
  70. data/lib/noiseless/test_helper.rb +295 -0
  71. data/lib/noiseless/version.rb +2 -2
  72. data/lib/noiseless.rb +146 -2
  73. data/lib/tasks/benchmark.rake +35 -0
  74. data/lib/tasks/release.rake +22 -0
  75. data/lib/tasks/test.rake +11 -0
  76. metadata +265 -14
@@ -0,0 +1,425 @@
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
+ module TypesenseExecution
10
+ include HttpTransport
11
+
12
+ private
13
+
14
+ # Override AST to Hash conversion for Typesense query format
15
+ def ast_to_hash(ast_node)
16
+ result = {}
17
+
18
+ # Build search query from match nodes
19
+ query_parts = build_search_query(ast_node.bool)
20
+ result[:q] = query_parts unless query_parts.empty?
21
+
22
+ # Build query_by from multi_match nodes
23
+ query_by_fields = build_query_by_fields(ast_node.bool)
24
+ result[:query_by] = query_by_fields unless query_by_fields.empty?
25
+
26
+ # Build filter expressions from filter nodes
27
+ filter_expr = build_filter_expression(ast_node.bool)
28
+ result[:filter_by] = filter_expr unless filter_expr.empty?
29
+
30
+ # Build sort expressions from sort nodes
31
+ sort_expr = build_sort_expression(ast_node.sort)
32
+ result[:sort_by] = sort_expr unless sort_expr.empty?
33
+
34
+ # Add pagination
35
+ pagination = build_pagination_params(ast_node.paginate)
36
+ result.merge!(pagination)
37
+
38
+ # Field collapsing -> Typesense group_by
39
+ if ast_node.collapse
40
+ result[:group_by] = ast_node.collapse.field
41
+ result[:group_limit] = 1 # Collapse shows 1 per group by default
42
+ if ast_node.collapse.max_concurrent_group_searches
43
+ # Typesense v30+: improve found accuracy for grouped results up to this threshold.
44
+ result[:group_max_candidates] = ast_node.collapse.max_concurrent_group_searches
45
+ end
46
+ end
47
+
48
+ # Aggregations -> Typesense facet_by
49
+ if ast_node.aggregations.any?
50
+ facet_fields = ast_node.aggregations
51
+ .select { |agg| agg.type == :terms }
52
+ .filter_map(&:field)
53
+
54
+ result[:facet_by] = facet_fields.join(",") if facet_fields.any?
55
+ end
56
+
57
+ # Vector search -> Typesense vector_query
58
+ if ast_node.vector_search?
59
+ vector = ast_node.vector
60
+ # Typesense uses format: "field_name:([vector], k:N)"
61
+ vector_str = vector.embedding.join(",")
62
+ result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k})"
63
+ end
64
+
65
+ # Hybrid search -> Typesense native hybrid with q + vector_query
66
+ if ast_node.hybrid_search?
67
+ hybrid = ast_node.hybrid
68
+ vector = hybrid.vector
69
+ vector_str = vector.embedding.join(",")
70
+
71
+ # Typesense natively supports hybrid by combining q and vector_query
72
+ result[:q] = hybrid.text_query
73
+ result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k}, alpha:#{hybrid.vector_weight})"
74
+ end
75
+
76
+ # Image search -> Typesense image embedding search
77
+ if ast_node.image_search?
78
+ img = ast_node.image_query
79
+ # Typesense accepts image URL or base64 directly in vector_query
80
+ result[:vector_query] = "#{img.field}:(#{img.image_data}, k:#{img.k})"
81
+ end
82
+
83
+ # Conversational/RAG search
84
+ if ast_node.conversational?
85
+ conv = ast_node.conversation
86
+ result[:conversation] = true
87
+ result[:conversation_model_id] = conv.model_id
88
+ result[:conversation_id] = conv.conversation_id if conv.conversation_id
89
+ result[:system_prompt] = conv.system_prompt if conv.system_prompt
90
+ end
91
+
92
+ # JOINs across collections
93
+ if ast_node.has_joins?
94
+ include_fields = ast_node.joins.map do |join_node|
95
+ fields = join_node.include_fields.join(", ")
96
+ "$#{join_node.collection}(#{fields})"
97
+ end
98
+ result[:include_fields] = include_fields.join(", ")
99
+ end
100
+
101
+ # Union-search related options (Typesense v30+).
102
+ result[:remove_duplicates] = ast_node.remove_duplicates unless ast_node.remove_duplicates.nil?
103
+ result[:facet_sample_slope] = ast_node.facet_sample_slope unless ast_node.facet_sample_slope.nil?
104
+ result[:pinned_hits] = ast_node.pinned_hits unless ast_node.pinned_hits.nil?
105
+
106
+ result
107
+ end
108
+
109
+ def build_search_query(bool_node)
110
+ # Combine all match queries into a single search string
111
+ queries = bool_node.must.filter_map do |node|
112
+ case node
113
+ when AST::Match
114
+ "#{node.field}:#{node.value}"
115
+ when AST::MultiMatch
116
+ # For Typesense, multi_match becomes a broader search across fields
117
+ node.query
118
+ when AST::Range
119
+ # Range queries are handled in filters, not search
120
+ nil
121
+ else
122
+ node.respond_to?(:value) ? "#{node.field}:#{node.value}" : nil
123
+ end
124
+ end
125
+ queries.join(" ")
126
+ end
127
+
128
+ def build_query_by_fields(bool_node)
129
+ # Extract fields from multi_match nodes for Typesense query_by parameter
130
+ fields = bool_node.must.filter_map do |node|
131
+ case node
132
+ when AST::MultiMatch
133
+ node.fields
134
+ end
135
+ end.flatten.uniq
136
+
137
+ fields.join(",")
138
+ end
139
+
140
+ def build_filter_expression(bool_node)
141
+ # Convert filter and range nodes to Typesense filter expressions
142
+ filters = bool_node.filter.map { |filter| "#{filter.field}:=#{filter.value}" }
143
+
144
+ # Add range filters from must clause
145
+ range_filters = bool_node.must.filter_map do |node|
146
+ next unless node.is_a?(AST::Range)
147
+
148
+ conditions = []
149
+ conditions << "#{node.field}:>#{node.gt}" if node.gt
150
+ conditions << "#{node.field}:>=#{node.gte}" if node.gte
151
+ conditions << "#{node.field}:<#{node.lt}" if node.lt
152
+ conditions << "#{node.field}:<=#{node.lte}" if node.lte
153
+ conditions.join(" && ")
154
+ end
155
+
156
+ (filters + range_filters).compact.join(" && ")
157
+ end
158
+
159
+ def build_sort_expression(sort_nodes)
160
+ # Convert sort nodes to Typesense sort format
161
+ sorts = sort_nodes.map do |sort|
162
+ direction = sort.direction == :desc ? "desc" : "asc"
163
+ "#{sort.field}:#{direction}"
164
+ end
165
+ sorts.join(",")
166
+ end
167
+
168
+ def build_pagination_params(paginate_node)
169
+ return { page: 1, per_page: 20 } unless paginate_node
170
+
171
+ {
172
+ page: paginate_node.page,
173
+ per_page: paginate_node.per_page
174
+ }
175
+ end
176
+
177
+ def execute_search(query_hash, collections: [], **_opts)
178
+ collection_path = collections.any? ? "/collections/#{collections.first}/documents/search" : "/multi_search"
179
+
180
+ # Convert query_hash to URL params for Typesense
181
+ params = query_hash.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
182
+ path = "#{collection_path}?#{params}"
183
+
184
+ response = get_request(path)
185
+ result = JSON.parse(response.read)
186
+
187
+ # Convert Typesense format to Elasticsearch-like format
188
+ {
189
+ took: result["search_time_ms"] || 0,
190
+ timed_out: false,
191
+ _shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
192
+ hits: {
193
+ total: { value: result["found"] || 0, relation: "eq" },
194
+ max_score: nil,
195
+ hits: (result["hits"] || []).map do |hit|
196
+ {
197
+ _index: collections.first || "typesense",
198
+ _type: "_doc",
199
+ _id: hit["document"]["id"],
200
+ _score: hit["text_match"] || 1.0,
201
+ _source: hit["document"]
202
+ }
203
+ end
204
+ }
205
+ }
206
+ rescue StandardError => e
207
+ # Return empty response on error to maintain compatibility
208
+ {
209
+ took: 0,
210
+ timed_out: false,
211
+ _shards: { total: 0, successful: 0, skipped: 0, failed: 0 },
212
+ hits: {
213
+ total: { value: 0, relation: "eq" },
214
+ max_score: nil,
215
+ hits: []
216
+ },
217
+ error: {
218
+ type: e.class.name,
219
+ reason: e.message
220
+ }
221
+ }
222
+ ensure
223
+ response&.close
224
+ end
225
+
226
+ def execute_bulk(actions, **_opts)
227
+ # Typesense uses different endpoints for different operations
228
+ results = actions.map do |action|
229
+ if action[:index]
230
+ collection = action[:index][:_index]
231
+ id = action[:index][:_id]
232
+ document = action[:index][:data]
233
+
234
+ path = "/collections/#{collection}/documents"
235
+ body = JSON.generate(document.merge(id: id))
236
+
237
+ response = post_request(path, body)
238
+ result = JSON.parse(response.read)
239
+ response.close
240
+
241
+ { index: { _id: result["id"], status: 201, result: "created" } }
242
+ elsif action[:delete]
243
+ collection = action[:delete][:_index]
244
+ id = action[:delete][:_id]
245
+
246
+ path = "/collections/#{collection}/documents/#{id}"
247
+
248
+ response = delete_request(path)
249
+ response.close
250
+
251
+ { delete: { _id: id, status: 200, result: "deleted" } }
252
+ else
253
+ { error: { status: 400, error: "Unsupported action" } }
254
+ end
255
+ end
256
+
257
+ { items: results }
258
+ rescue StandardError => e
259
+ { items: [], errors: true, error: { type: e.class.name, reason: e.message } }
260
+ end
261
+
262
+ def execute_create_index(collection_name, mappings: nil, **_opts)
263
+ # Typesense calls indexes "collections"
264
+ schema = {
265
+ name: collection_name,
266
+ fields: []
267
+ }
268
+
269
+ # Convert mappings to Typesense schema if provided
270
+ if mappings && mappings["properties"]
271
+ schema[:fields] = mappings["properties"].map do |field_name, field_config|
272
+ {
273
+ name: field_name,
274
+ type: map_type_to_typesense(field_config["type"] || "string"),
275
+ facet: field_config["facet"] || false
276
+ }
277
+ end
278
+ end
279
+
280
+ body = JSON.generate(schema)
281
+ response = post_request("/collections", body)
282
+ result = JSON.parse(response.read)
283
+
284
+ { acknowledged: true, index: result["name"] }
285
+ rescue StandardError => e
286
+ { acknowledged: false, error: { type: e.class.name, reason: e.message } }
287
+ ensure
288
+ response&.close
289
+ end
290
+
291
+ def execute_delete_index(collection_name, **_opts)
292
+ response = delete_request("/collections/#{collection_name}")
293
+ JSON.parse(response.read)
294
+
295
+ { acknowledged: true }
296
+ rescue StandardError => e
297
+ { acknowledged: false, error: { type: e.class.name, reason: e.message } }
298
+ ensure
299
+ response&.close
300
+ end
301
+
302
+ def execute_index_exists?(collection_name)
303
+ response = head_request("/collections/#{collection_name}")
304
+ response.success?
305
+ rescue StandardError
306
+ false
307
+ ensure
308
+ response&.close
309
+ end
310
+
311
+ def execute_index_document(collection, id, document, **_opts)
312
+ path = "/collections/#{collection}/documents"
313
+ body = JSON.generate(document.merge(id: id))
314
+
315
+ response = post_request(path, body)
316
+ result = JSON.parse(response.read)
317
+
318
+ { _index: collection, _id: result["id"], result: "created" }
319
+ rescue StandardError => e
320
+ { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
321
+ ensure
322
+ response&.close
323
+ end
324
+
325
+ def execute_update_document(collection, id, changes, **_opts)
326
+ # Typesense doesn't have partial updates, so we need to fetch and merge
327
+ get_response = get_request("/collections/#{collection}/documents/#{id}")
328
+ document = JSON.parse(get_response.read)
329
+ get_response.close
330
+
331
+ updated_document = document.merge(changes).merge(id: id)
332
+ body = JSON.generate(updated_document)
333
+
334
+ response = put_request("/collections/#{collection}/documents/#{id}", body)
335
+ result = JSON.parse(response.read)
336
+
337
+ { _index: collection, _id: result["id"], result: "updated" }
338
+ rescue StandardError => e
339
+ { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
340
+ ensure
341
+ response&.close if defined?(response)
342
+ end
343
+
344
+ def execute_delete_document(collection, id, **_opts)
345
+ response = delete_request("/collections/#{collection}/documents/#{id}")
346
+
347
+ { _index: collection, _id: id, result: "deleted" }
348
+ rescue StandardError => e
349
+ { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
350
+ ensure
351
+ response&.close
352
+ end
353
+
354
+ def execute_document_exists?(collection, id)
355
+ response = head_request("/collections/#{collection}/documents/#{id}")
356
+ response.success?
357
+ rescue StandardError
358
+ false
359
+ ensure
360
+ response&.close
361
+ end
362
+
363
+ def execute_cluster_health(**_opts)
364
+ response = get_request("/health")
365
+ health_data = JSON.parse(response.read)
366
+
367
+ # Convert Typesense health format to match expected format
368
+ {
369
+ cluster_name: "typesense",
370
+ status: health_data["ok"] ? "green" : "red",
371
+ timed_out: false,
372
+ number_of_nodes: 1,
373
+ number_of_data_nodes: 1,
374
+ active_primary_shards: 0,
375
+ active_shards: 0,
376
+ typesense_ok: health_data["ok"]
377
+ }
378
+ rescue StandardError => e
379
+ {
380
+ cluster_name: "unknown",
381
+ status: "red",
382
+ timed_out: false,
383
+ number_of_nodes: 0,
384
+ number_of_data_nodes: 0,
385
+ active_primary_shards: 0,
386
+ active_shards: 0,
387
+ error: { type: e.class.name, reason: e.message }
388
+ }
389
+ ensure
390
+ response&.close
391
+ end
392
+
393
+ def default_headers
394
+ headers = super
395
+
396
+ # Add Typesense API key if configured
397
+ if @connection_params && @connection_params[:api_key]
398
+ headers << ["X-TYPESENSE-API-KEY",
399
+ @connection_params[:api_key]]
400
+ end
401
+
402
+ headers
403
+ end
404
+
405
+ # rubocop:disable Lint/DuplicateBranch
406
+ def map_type_to_typesense(elasticsearch_type)
407
+ # Map Elasticsearch types to Typesense types
408
+ case elasticsearch_type
409
+ when "text", "keyword"
410
+ "string"
411
+ when "long", "integer", "short", "byte", "date"
412
+ "int64" # date uses Unix timestamps
413
+ when "double", "float", "half_float", "scaled_float"
414
+ "float"
415
+ when "boolean"
416
+ "bool"
417
+ else
418
+ "string" # Default to string for unknown types
419
+ end
420
+ end
421
+ # rubocop:enable Lint/DuplicateBranch
422
+ end
423
+ end
424
+ end
425
+ end
@@ -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.send(:execute_refresh_index, index)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_modules/opensearch_execution"
4
+
5
+ module Noiseless
6
+ module Adapters
7
+ class OpenSearch < Adapter
8
+ include ExecutionModules::OpensearchExecution
9
+
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
12
+
13
+ # OpenSearch-specific features
14
+ def point_in_time_search(ast_node, pit_id:, **)
15
+ query_hash = ast_to_hash(ast_node)
16
+ Async do
17
+ execute_point_in_time_search(query_hash, pit_id: pit_id, **)
18
+ end
19
+ end
20
+
21
+ def search_template(template_id:, params: {}, **)
22
+ Async do
23
+ execute_search_template(template_id: template_id, params: params, **)
24
+ end
25
+ end
26
+
27
+ # Cluster health API - needed for Rails healthcheck
28
+ def cluster
29
+ @cluster ||= ClusterAPI.new(self)
30
+ end
31
+
32
+ # Indices API - needed for index management operations
33
+ def indices
34
+ @indices ||= IndicesAPI.new(self)
35
+ end
36
+
37
+ # Search Pipelines API - OpenSearch 3.x feature
38
+ def pipelines
39
+ @pipelines ||= PipelinesAPI.new(self)
40
+ end
41
+
42
+ # Query Rules API - OpenSearch 3.x feature
43
+ def rules
44
+ @rules ||= RulesAPI.new(self)
45
+ end
46
+
47
+ # Raw search for CommonShare compatibility
48
+ def search_raw(query_body, indexes: [], **)
49
+ Async do
50
+ execute_search(query_body, indexes: indexes, **)
51
+ end
52
+ end
53
+
54
+ # Search Pipelines API for OpenSearch 3.x
55
+ # Pipelines can include request and response processors for neural search, reranking, etc.
56
+ class PipelinesAPI
57
+ def initialize(adapter)
58
+ @adapter = adapter
59
+ end
60
+
61
+ # Create or update a search pipeline
62
+ # @param name [String] Pipeline name
63
+ # @param request_processors [Array<Hash>] Request phase processors
64
+ # @param response_processors [Array<Hash>] Response phase processors
65
+ # @param description [String, nil] Optional description
66
+ def create(name, request_processors: [], response_processors: [], description: nil)
67
+ Sync do
68
+ @adapter.send(:execute_create_pipeline, name,
69
+ request_processors: request_processors,
70
+ response_processors: response_processors,
71
+ description: description)
72
+ end
73
+ end
74
+
75
+ alias put create
76
+
77
+ # Get a specific pipeline
78
+ def get(name)
79
+ Sync do
80
+ @adapter.send(:execute_get_pipeline, name)
81
+ end
82
+ end
83
+
84
+ # List all pipelines
85
+ def list
86
+ Sync do
87
+ @adapter.send(:execute_list_pipelines)
88
+ end
89
+ end
90
+
91
+ alias all list
92
+
93
+ # Delete a pipeline
94
+ def delete(name)
95
+ Sync do
96
+ @adapter.send(:execute_delete_pipeline, name)
97
+ end
98
+ end
99
+
100
+ # Check if a pipeline exists
101
+ def exists?(name)
102
+ Sync do
103
+ @adapter.send(:execute_pipeline_exists?, name)
104
+ end
105
+ end
106
+ end
107
+
108
+ # Query Rules API for OpenSearch 3.x
109
+ # Rules allow pinning, boosting, or hiding specific results based on query patterns
110
+ class RulesAPI
111
+ def initialize(adapter)
112
+ @adapter = adapter
113
+ end
114
+
115
+ # Create or update a rule
116
+ # @param feature_type [String] Feature type (e.g., 'pinned_queries')
117
+ # @param rule_id [String] Unique rule identifier
118
+ # @param attributes [Hash] Rule matching attributes
119
+ # @param feature_value [Hash] The feature value to apply
120
+ def create(feature_type, rule_id, attributes:, feature_value:)
121
+ Sync do
122
+ @adapter.send(:execute_create_rule, feature_type, rule_id,
123
+ attributes: attributes,
124
+ feature_value: feature_value)
125
+ end
126
+ end
127
+
128
+ alias put create
129
+
130
+ # Get a specific rule
131
+ def get(feature_type, rule_id)
132
+ Sync do
133
+ @adapter.send(:execute_get_rule, feature_type, rule_id)
134
+ end
135
+ end
136
+
137
+ # List rules for a feature type
138
+ def list(feature_type, search_after: nil)
139
+ Sync do
140
+ @adapter.send(:execute_list_rules, feature_type, search_after: search_after)
141
+ end
142
+ end
143
+
144
+ alias all list
145
+
146
+ # Delete a rule
147
+ def delete(feature_type, rule_id)
148
+ Sync do
149
+ @adapter.send(:execute_delete_rule, feature_type, rule_id)
150
+ end
151
+ end
152
+
153
+ # Check if a rule exists
154
+ def exists?(feature_type, rule_id)
155
+ Sync do
156
+ @adapter.send(:execute_rule_exists?, feature_type, rule_id)
157
+ end
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def default_port
164
+ ENV["OPENSEARCH_PORT"] || 9200
165
+ end
166
+ end
167
+ end
168
+ end