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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b80fb21fd8ce9ead0c11e07f58073c6e2d6ffeb725c12b942900232a869562a
4
- data.tar.gz: dd05691108e09285328159ec96892fd1564bfbe17461ea3665d947aedfe81b0e
3
+ metadata.gz: 7fb5d9afa49be27d359d92a2fdf24999d488f8a7d865547452ea10b7aae67850
4
+ data.tar.gz: da2fb4b847d7c1b53bb17b7b0a588d2a1660006cbe9413af0b657282087ad681
5
5
  SHA512:
6
- metadata.gz: 4babc5689c8e21b325c3cf6bc595f8b92f91c8ce364bb102ffa779ce5332e9fe4e221d613e805e1bfcd782f9e3f8dd2d7caa39a11de5df89703feb16086c20f7
7
- data.tar.gz: a62dd755a2bf87f96ac17c920e9e350cabad4bfb8384bba4a96a5db90c71fcb8510ed1c2467717087824e6d0981c0e6670ff4ada99ea95101b3e61dec192d637
6
+ metadata.gz: 32b345fcc0ac56e25765f628c1ab8dd7dd03740e9a1a12bbcf5e13e9ce5078ef9ad57581f2b0a0f445f4d4b543559e01ceb50dd1a0dae86e7db5d35440b8e3c8
7
+ data.tar.gz: da9518b7381e61a16d7efef22e2d8d585b895b2e644820099403781c86ee9c7b541cf2ee00bad6cac7c40830764fd30aa841dd685dd9c63cb4ed227a1ed7ce8a
data/LICENSE.txt CHANGED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Abdelkader Boudih
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md CHANGED
@@ -0,0 +1,214 @@
1
+ # Noiseless
2
+
3
+ Async-first search abstraction for Rails with multi-backend support (OpenSearch, Elasticsearch, Typesense, PostgreSQL).
4
+
5
+ ## Features
6
+
7
+ - **Chainable DSL** — fluent query builder with runtime validation
8
+ - **Multi-backend** — OpenSearch, Elasticsearch, Typesense, PostgreSQL adapters
9
+ - **Async-first** — built on Ruby 3.4+ fiber scheduler with non-blocking I/O
10
+ - **HTTP/2 connection pooling** — persistent connections via `Async::Pool`
11
+ - **Rails integration** — Railtie with log subscriber and controller runtime tracking
12
+ - **Lazy loading** — adapters loaded on-demand, test files excluded from production
13
+
14
+ ## Installation
15
+
16
+ ```ruby
17
+ gem "noiseless"
18
+ ```
19
+
20
+ `noiseless` is a Rails gem. It requires Ruby >= 3.4 and Rails >= 8.1.
21
+
22
+ ## Configuration
23
+
24
+ Create `config/noiseless.yml`:
25
+
26
+ ```yaml
27
+ development:
28
+ default: primary
29
+ connections:
30
+ primary:
31
+ adapter: elasticsearch
32
+ hosts:
33
+ - http://localhost:9201
34
+ opensearch:
35
+ adapter: open_search
36
+ hosts:
37
+ - http://localhost:9202
38
+ typesense:
39
+ adapter: typesense
40
+ hosts:
41
+ - http://localhost:8109
42
+ postgresql:
43
+ adapter: postgresql
44
+
45
+ production:
46
+ default: primary
47
+ connections:
48
+ primary:
49
+ adapter: opensearch
50
+ hosts:
51
+ - <%= ENV['OPENSEARCH_URL'] %>
52
+ typesense:
53
+ adapter: typesense
54
+ hosts:
55
+ - <%= ENV['TYPESENSE_URL'] %>
56
+ postgresql:
57
+ adapter: postgresql
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### Defining a Search
63
+
64
+ ```ruby
65
+ class Company::Search < Noiseless::Model
66
+ index_name 'companies'
67
+
68
+ def by_name(name)
69
+ multi_match(name, [:name, :name_aliases])
70
+ end
71
+
72
+ def suppliers_only
73
+ filter(:company_type, 'supplier')
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### Executing Searches
79
+
80
+ All `.execute` calls return `Async::Task` objects. Use `Sync` to wait for results, or use the `_sync` convenience methods:
81
+
82
+ ```ruby
83
+ # Convenience method (recommended for simple cases)
84
+ results = Company::Search.new.by_name('tech').execute_sync
85
+
86
+ # Class-level convenience
87
+ results = Company::Search.search_sync do |s|
88
+ s.match(:name, 'tech')
89
+ s.limit(10)
90
+ end
91
+
92
+ # Explicit Sync block
93
+ results = Sync do
94
+ Company::Search.new
95
+ .by_name('technology')
96
+ .suppliers_only
97
+ .limit(20)
98
+ .execute
99
+ .wait
100
+ end
101
+ ```
102
+
103
+ ### Concurrent Searches
104
+
105
+ ```ruby
106
+ Async do |task|
107
+ companies_task = Company::Search.new.match(:name, 'tech').execute
108
+ products_task = Product::Search.new.match(:name, 'tech').execute
109
+
110
+ companies = companies_task.wait
111
+ products = products_task.wait
112
+ end
113
+ ```
114
+
115
+ For best performance, run independent searches concurrently within a single `Async` block rather than creating separate `Sync` blocks per search.
116
+
117
+ ### Advanced Queries
118
+
119
+ ```ruby
120
+ results = Company::Search.new
121
+ .match(:name, 'electronics')
122
+ .filter(:status, 'active')
123
+ .geo_distance(:location, lat: 40.7128, lon: -74.0060, distance: '50km')
124
+ .sort(:created_at, :desc)
125
+ .paginate(page: 1, per_page: 10)
126
+ .execute_sync
127
+ ```
128
+
129
+ ### Rails Integration
130
+
131
+ ```ruby
132
+ class CompaniesController < ApplicationController
133
+ def search
134
+ @results = Company::Search.new
135
+ .by_name(params[:q])
136
+ .limit(20)
137
+ .execute_sync
138
+
139
+ render json: @results
140
+ end
141
+ end
142
+ ```
143
+
144
+ ## Testing
145
+
146
+ Add to `test/test_helper.rb`:
147
+
148
+ ```ruby
149
+ require 'noiseless/test_helper'
150
+ require 'noiseless/test_case'
151
+ ```
152
+
153
+ ### With Noiseless::TestCase (automatic VCR cassettes)
154
+
155
+ ```ruby
156
+ class CompanySearchTest < Noiseless::TestCase
157
+ def test_search_by_name
158
+ # Cassette auto-named: company_search/search_by_name
159
+ search = Company::Search.new.by_name('test')
160
+ assert_search_results(search)
161
+ end
162
+ end
163
+ ```
164
+
165
+ ### With manual VCR control
166
+
167
+ ```ruby
168
+ class CompanySearchTest < ActiveSupport::TestCase
169
+ include Noiseless::TestHelper
170
+
171
+ def test_custom_search
172
+ noiseless_cassette(record: :new_episodes) do
173
+ results = Company::Search.new.by_name('test').execute_sync
174
+ assert results.any?
175
+ end
176
+ end
177
+ end
178
+ ```
179
+
180
+ ### Running Tests Locally
181
+
182
+ ```bash
183
+ docker compose up -d postgres elasticsearch opensearch typesense
184
+ bin/test
185
+ ```
186
+
187
+ `bin/test` expects all four local services from `docker-compose.yml`, including PostgreSQL on `:5432`.
188
+ Default ports match `docker-compose.yml`: PostgreSQL `:5432`, Elasticsearch `:9201`, OpenSearch `:9202`, Typesense `:8109`. Override via env vars:
189
+
190
+ ```bash
191
+ ELASTICSEARCH_PORT=9200 OPENSEARCH_PORT=9201 TYPESENSE_PORT=8108 bin/test
192
+ ```
193
+
194
+ For a release smoke test that does not require the dummy app or local services:
195
+
196
+ ```bash
197
+ bundle exec rake release:check
198
+ ```
199
+
200
+ ## Debug Mode
201
+
202
+ ```ruby
203
+ ENV['NOISELESS_VERBOSE'] = 'true'
204
+ ```
205
+
206
+ ## Contributing
207
+
208
+ 1. Follow Rails conventions for code organization
209
+ 2. Test helpers must remain separate from core functionality
210
+ 3. Add tests for new features using the provided test utilities
211
+
212
+ ## License
213
+
214
+ BSD 3-Clause License — See [LICENSE.txt](LICENSE.txt)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abstract base class for all search models
4
+ class ApplicationSearch < Noiseless::Model
5
+ # Mark as abstract - concrete search models like Product::Search inherit from this
6
+ def self.abstract!
7
+ @abstract = true
8
+ end
9
+
10
+ def self.abstract?
11
+ @abstract == true
12
+ end
13
+
14
+ abstract!
15
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "json"
5
+ require_relative "introspection"
6
+
7
+ module Noiseless
8
+ class Adapter
9
+ include Instrumentation
10
+ include Introspection
11
+
12
+ def initialize(hosts: [], **connection_params)
13
+ @hosts = hosts
14
+ @connection_params = connection_params.dup
15
+ @connection_params.delete(:async)
16
+ end
17
+
18
+ def async_context?
19
+ true
20
+ end
21
+
22
+ # Convert AST to Hash/JSON before execution
23
+ def search(ast_node, model_class: nil, response_type: nil, **)
24
+ query_hash = ast_to_hash(ast_node)
25
+
26
+ Async do
27
+ raw_response = instrument(:search, indexes: ast_node.indexes, query: query_hash) do
28
+ execute_search(query_hash, indexes: ast_node.indexes, **)
29
+ end
30
+
31
+ ResponseFactory.create(
32
+ raw_response,
33
+ model_class: model_class,
34
+ response_type: response_type,
35
+ query_hash: query_hash
36
+ )
37
+ end
38
+ end
39
+
40
+ def bulk(actions, **)
41
+ Async do
42
+ instrument(:bulk, actions_count: actions.size) do
43
+ execute_bulk(actions, **)
44
+ end
45
+ end
46
+ end
47
+
48
+ def create_index(index_name, **)
49
+ Async do
50
+ instrument(:create_index, index: index_name) do
51
+ execute_create_index(index_name, **)
52
+ end
53
+ end
54
+ end
55
+
56
+ def delete_index(index_name, **)
57
+ Async do
58
+ instrument(:delete_index, index: index_name) do
59
+ execute_delete_index(index_name, **)
60
+ end
61
+ end
62
+ end
63
+
64
+ def index_exists?(index_name)
65
+ Async do
66
+ execute_index_exists?(index_name)
67
+ end
68
+ end
69
+
70
+ def index_document(index:, id:, document:, **)
71
+ Async do
72
+ instrument(:index_document, index: index, id: id) do
73
+ execute_index_document(index, id, document, **)
74
+ end
75
+ end
76
+ end
77
+
78
+ def update_document(index:, id:, changes:, **)
79
+ Async do
80
+ instrument(:update_document, index: index, id: id, changes_count: changes.size) do
81
+ execute_update_document(index, id, changes, **)
82
+ end
83
+ end
84
+ end
85
+
86
+ def delete_document(index:, id:, **)
87
+ Async do
88
+ instrument(:delete_document, index: index, id: id) do
89
+ execute_delete_document(index, id, **)
90
+ end
91
+ end
92
+ end
93
+
94
+ def document_exists?(index:, id:)
95
+ Async do
96
+ execute_document_exists?(index, id)
97
+ end
98
+ end
99
+
100
+ # Raw search method for backward compatibility
101
+ def search_raw(query_body, indexes: [], **)
102
+ Async do
103
+ instrument(:search, indexes: indexes, query: query_body) do
104
+ execute_search(query_body, indexes: indexes, **)
105
+ end
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ # Convert AST to Hash - override in subclasses for adapter-specific format
112
+ def ast_to_hash(ast_node)
113
+ result = {}
114
+
115
+ query_hash = build_query_hash(ast_node.bool)
116
+ result[:query] = query_hash unless query_hash.empty?
117
+
118
+ sort_hash = build_sort_hash(ast_node.sort)
119
+ result[:sort] = sort_hash unless sort_hash.empty?
120
+
121
+ # Handle search_after (cursor pagination) vs offset pagination
122
+ if ast_node.search_after
123
+ result[:search_after] = ast_node.search_after.values
124
+ result[:size] = ast_node.paginate&.per_page || 20
125
+ else
126
+ pagination = build_pagination_hash(ast_node.paginate)
127
+ result[:from] = pagination[:from]
128
+ result[:size] = pagination[:size]
129
+ end
130
+
131
+ # Field collapsing
132
+ result[:collapse] = build_collapse_hash(ast_node.collapse) if ast_node.collapse
133
+
134
+ # Aggregations
135
+ result[:aggs] = build_aggregations_hash(ast_node.aggregations) if ast_node.aggregations.any?
136
+
137
+ # Vector/kNN search (OpenSearch/Elasticsearch compatible)
138
+ result[:knn] = build_knn_query(ast_node.vector) if ast_node.vector_search?
139
+
140
+ # Hybrid search (combines text + vector with RRF or weighted scoring)
141
+ if ast_node.hybrid_search?
142
+ hybrid_config = build_hybrid_query(ast_node.hybrid)
143
+ result.merge!(hybrid_config)
144
+ end
145
+
146
+ # Search pipeline (OpenSearch only)
147
+ result[:search_pipeline] = ast_node.pipeline if ast_node.has_pipeline?
148
+
149
+ result
150
+ end
151
+
152
+ def build_knn_query(vector_node)
153
+ {
154
+ field: vector_node.field.to_s,
155
+ query_vector: vector_node.embedding,
156
+ k: vector_node.k,
157
+ num_candidates: vector_node.k * 10
158
+ }
159
+ end
160
+
161
+ # Build hybrid query using RRF (Reciprocal Rank Fusion) for OpenSearch/Elasticsearch
162
+ def build_hybrid_query(hybrid_node)
163
+ {
164
+ query: {
165
+ bool: {
166
+ should: [
167
+ {
168
+ match: {
169
+ _all: hybrid_node.text_query
170
+ }
171
+ }
172
+ ]
173
+ }
174
+ },
175
+ knn: build_knn_query(hybrid_node.vector),
176
+ rank: {
177
+ rrf: {
178
+ window_size: hybrid_node.vector.k * 2
179
+ }
180
+ }
181
+ }
182
+ end
183
+
184
+ def build_query_hash(bool_node)
185
+ return {} if bool_node.must.empty? && bool_node.filter.empty?
186
+
187
+ must_queries = bool_node.must.filter_map { |node| build_must_clause(node) }
188
+
189
+ {
190
+ bool: {
191
+ must: must_queries,
192
+ filter: bool_node.filter.map { |f| { term: { f.field => f.value } } }
193
+ }.reject { |_, v| v.empty? }
194
+ }
195
+ end
196
+
197
+ def build_sort_hash(sort_nodes)
198
+ return [] if sort_nodes.empty?
199
+
200
+ sort_nodes.map { |s| { s.field => { order: s.direction } } }
201
+ end
202
+
203
+ def build_pagination_hash(paginate_node)
204
+ return { from: 0, size: 20 } unless paginate_node
205
+
206
+ {
207
+ from: (paginate_node.page - 1) * paginate_node.per_page,
208
+ size: paginate_node.per_page
209
+ }
210
+ end
211
+
212
+ # Parses a backend HTTP response, raising when the backend reported an error.
213
+ # Success responses return the parsed JSON payload; error responses raise
214
+ # `error_class` with the backend's error type/reason so failures are never
215
+ # silently converted into empty results.
216
+ def parse_json_response!(response, error_class: Noiseless::RequestError, context: nil)
217
+ body = response.read
218
+ return JSON.parse(body) if response.success?
219
+
220
+ payload = begin
221
+ JSON.parse(body)
222
+ rescue JSON::ParserError, TypeError
223
+ nil
224
+ end
225
+ error = payload.is_a?(Hash) ? payload["error"] : nil
226
+ reason = if error.is_a?(Hash)
227
+ [ error["type"], error["reason"] ].compact.join(": ")
228
+ elsif error
229
+ error.to_s
230
+ else
231
+ "HTTP #{response.status}"
232
+ end
233
+ message = context ? "#{context}: #{reason}" : reason
234
+ raise error_class.new(message, status: response.status, error_type: error.is_a?(Hash) ? error["type"] : nil)
235
+ end
236
+
237
+ # Override in subclasses
238
+ def execute_search(_query_hash, **_opts)
239
+ {
240
+ took: 1,
241
+ hits: {
242
+ total: { value: 0 },
243
+ hits: []
244
+ }
245
+ }
246
+ end
247
+
248
+ def execute_bulk(actions, **_opts)
249
+ {
250
+ items: actions.map { |_action| { index: { status: 201 } } }
251
+ }
252
+ end
253
+
254
+ def execute_create_index(_index_name, **_opts)
255
+ { acknowledged: true }
256
+ end
257
+
258
+ def execute_delete_index(_index_name, **_opts)
259
+ { acknowledged: true }
260
+ end
261
+
262
+ def execute_index_exists?(_index_name)
263
+ true
264
+ end
265
+
266
+ def execute_index_document(index, id, _document, **_opts)
267
+ { _index: index, _id: id, result: "created" }
268
+ end
269
+
270
+ def execute_update_document(index, id, _changes, **_opts)
271
+ { _index: index, _id: id, result: "updated" }
272
+ end
273
+
274
+ def execute_delete_document(index, id, **_opts)
275
+ { _index: index, _id: id, result: "deleted" }
276
+ end
277
+
278
+ def execute_document_exists?(_index, _id)
279
+ true
280
+ end
281
+
282
+ def build_must_clause(node)
283
+ case node
284
+ when AST::Match
285
+ { match: { node.field => node.value } }
286
+ when AST::MultiMatch
287
+ { multi_match: { query: node.query, fields: node.fields }.merge(node.options) }
288
+ when AST::CombinedFields
289
+ { combined_fields: { query: node.query, fields: node.fields }.merge(node.options) }
290
+ when AST::Wildcard
291
+ { wildcard: { node.field => node.value } }
292
+ when AST::Range
293
+ range_options = {
294
+ gte: node.gte,
295
+ lte: node.lte,
296
+ gt: node.gt,
297
+ lt: node.lt
298
+ }.compact
299
+ { range: { node.field => range_options } }
300
+ when AST::Prefix
301
+ { prefix: { node.field => node.value } }
302
+ else
303
+ node.to_hash
304
+ end
305
+ end
306
+
307
+ def build_collapse_hash(collapse_node)
308
+ result = { field: collapse_node.field }
309
+ result[:inner_hits] = collapse_node.inner_hits if collapse_node.inner_hits
310
+ if collapse_node.max_concurrent_group_searches
311
+ result[:max_concurrent_group_searches] =
312
+ collapse_node.max_concurrent_group_searches
313
+ end
314
+ result
315
+ end
316
+
317
+ def build_aggregations_hash(aggregations)
318
+ aggregations.each_with_object({}) do |agg, hash|
319
+ hash[agg.name] = build_single_aggregation(agg)
320
+ end
321
+ end
322
+
323
+ def build_single_aggregation(agg)
324
+ result = {}
325
+
326
+ # Build the aggregation type hash
327
+ agg_body = {}
328
+ agg_body[:field] = agg.field if agg.field
329
+ agg_body.merge!(agg.options)
330
+
331
+ result[agg.type] = agg_body
332
+
333
+ # Add sub-aggregations if any
334
+ result[:aggs] = build_aggregations_hash(agg.sub_aggregations) if agg.sub_aggregations.any?
335
+
336
+ result
337
+ end
338
+ end
339
+ end
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_modules/elasticsearch_execution"
4
+
5
+ module Noiseless
6
+ module Adapters
7
+ class Elasticsearch < Adapter
8
+ include ExecutionModules::ElasticsearchExecution
9
+
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
12
+
13
+ # Cluster health API - needed for Rails healthcheck
14
+ def cluster
15
+ @cluster ||= ClusterAPI.new(self)
16
+ end
17
+
18
+ # Indices API - needed for index management operations
19
+ def indices
20
+ @indices ||= IndicesAPI.new(self)
21
+ end
22
+
23
+ private
24
+
25
+ def default_port
26
+ ENV["ELASTICSEARCH_PORT"] || 9200
27
+ end
28
+ end
29
+ end
30
+ end