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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "async"
5
+ require "set" # rubocop:disable Lint/RedundantRequireStatement
6
+ require_relative "test_helper"
7
+
8
+ module Noiseless
9
+ # The Ultimate Search Test Case
10
+ #
11
+ # Automatically includes TestHelper and provides seamless VCR integration.
12
+ # Every test method automatically gets its own VCR cassette.
13
+ #
14
+ # Usage:
15
+ # class Search::ProductTest < Noiseless::TestCase
16
+ # def test_searching_by_name
17
+ # # Automatically uses cassette: search/product/searching_by_name
18
+ # results = Search::Product.by_name("Ruby").execute
19
+ # assert results.any?
20
+ # end
21
+ #
22
+ # def test_with_custom_options
23
+ # # Override VCR options for this test
24
+ # noiseless_cassette(record: :new_episodes) do
25
+ # results = Search::Product.featured.execute
26
+ # assert results.any?
27
+ # end
28
+ # end
29
+ # end
30
+ class TestCase < Minitest::Test
31
+ include Noiseless::TestHelper
32
+
33
+ # 🎭 Auto-wrap every test method with VCR cassette
34
+ def self.method_added(method_name)
35
+ return unless method_name.to_s.start_with?("test_")
36
+ return if @__noiseless_wrapped_methods&.include?(method_name)
37
+
38
+ # Track wrapped methods to avoid infinite recursion
39
+ @__noiseless_wrapped_methods ||= Set.new
40
+ @__noiseless_wrapped_methods << method_name
41
+
42
+ # Store original method
43
+ original_method = instance_method(method_name)
44
+
45
+ # Remove original method
46
+ remove_method(method_name)
47
+
48
+ # Define wrapped method
49
+ define_method(method_name) do
50
+ # Generate cassette name based on class and method
51
+ cassette_name = generate_test_cassette_name(method_name)
52
+
53
+ # Auto-wrap with VCR cassette, passing the actual test method name
54
+ noiseless_cassette(cassette_name: cassette_name, test_method: method_name) do
55
+ original_method.bind_call(self)
56
+ end
57
+ end
58
+
59
+ super
60
+ end
61
+
62
+ # 🏗️ Test setup and teardown
63
+ def setup
64
+ super
65
+ setup_noiseless_test_environment
66
+ end
67
+
68
+ def teardown
69
+ cleanup_noiseless_test_environment
70
+ super
71
+ end
72
+
73
+ private
74
+
75
+ def generate_test_cassette_name(method_name)
76
+ # Extract test class path (e.g., "Search::ProductTest" -> "search/product")
77
+ class_path = self.class.name
78
+ .gsub(/Test$/, "")
79
+ .underscore
80
+ .gsub("::", "/")
81
+
82
+ # Extract method name (e.g., "test_searching_products" -> "searching_products")
83
+ clean_method_name = method_name.to_s.gsub(/^test_/, "")
84
+
85
+ "#{class_path}/#{clean_method_name}"
86
+ end
87
+
88
+ def setup_noiseless_test_environment
89
+ # Set verbose mode if requested
90
+ @original_verbose = ENV.fetch("NOISELESS_VERBOSE", nil)
91
+
92
+ # Ensure test-friendly configuration
93
+ configure_test_connections if respond_to?(:configure_test_connections)
94
+
95
+ # Print test start info if verbose
96
+ puts "\n[TEST] Starting: #{self.class.name}##{name}" if verbose_mode?
97
+ end
98
+
99
+ def cleanup_noiseless_test_environment
100
+ # Restore original verbose setting
101
+ if @original_verbose
102
+ ENV["NOISELESS_VERBOSE"] = @original_verbose
103
+ else
104
+ ENV.delete("NOISELESS_VERBOSE")
105
+ end
106
+
107
+ # Print test completion info if verbose
108
+ puts "[PASS] Completed: #{self.class.name}##{name}" if verbose_mode?
109
+ end
110
+
111
+ # Enhanced cassette method with auto-naming
112
+ def noiseless_cassette(options = {}, **kwargs, &)
113
+ # Use the provided cassette name or generate one from test context
114
+ # Parent method (TestHelper#noiseless_cassette) will handle the actual VCR setup
115
+ super
116
+ end
117
+
118
+ # 🔧 Test-specific configuration helpers
119
+ def configure_test_connections
120
+ # Override in subclasses to set up test-specific connections
121
+ # Example:
122
+ # Noiseless.configure do |config|
123
+ # config.connections_config[:test] = {
124
+ # adapter: :elasticsearch,
125
+ # hosts: ['http://localhost:9201']
126
+ # }
127
+ # end
128
+ end
129
+
130
+ # Enhanced assertion helpers
131
+ def assert_search_results(search, expected_count = nil, message = nil)
132
+ # Noiseless is now 100% async - execute returns Async::Task
133
+ results = if search.respond_to?(:execute)
134
+ task = search.execute
135
+ Sync { task.wait }
136
+ else
137
+ search
138
+ end
139
+
140
+ assert results.respond_to?(:size) || results.respond_to?(:count),
141
+ "Expected search results to be enumerable, got #{results.class}"
142
+
143
+ result_count = results.respond_to?(:size) ? results.size : results.count
144
+
145
+ if expected_count
146
+ assert_equal expected_count, result_count,
147
+ message || "Expected #{expected_count} results, got #{result_count}"
148
+ else
149
+ assert result_count.positive?,
150
+ message || "Expected search results to not be empty"
151
+ end
152
+ end
153
+
154
+ def assert_search_empty(search, message = nil)
155
+ # Noiseless is now 100% async - execute returns Async::Task
156
+ results = if search.respond_to?(:execute)
157
+ task = search.execute
158
+ Sync { task.wait }
159
+ else
160
+ search
161
+ end
162
+ result_count = results.respond_to?(:size) ? results.size : results.count
163
+
164
+ assert_equal 0, result_count,
165
+ message || "Expected search results to be empty, got #{result_count}"
166
+ end
167
+
168
+ def assert_search_includes(search, expected_item, message = nil)
169
+ # Noiseless is now 100% async - execute returns Async::Task
170
+ results = if search.respond_to?(:execute)
171
+ task = search.execute
172
+ Sync { task.wait }
173
+ else
174
+ search
175
+ end
176
+
177
+ assert results.respond_to?(:include?) || results.respond_to?(:any?),
178
+ "Expected search results to be enumerable"
179
+
180
+ if results.respond_to?(:include?)
181
+ assert results.include?(expected_item),
182
+ message || "Expected search results to include #{expected_item}"
183
+ else
184
+ assert results.any?(expected_item),
185
+ message || "Expected search results to include #{expected_item}"
186
+ end
187
+ end
188
+
189
+ # 🚀 Performance assertion helpers
190
+ def assert_search_performance(search, max_duration_ms = 1000)
191
+ start_time = Time.current
192
+
193
+ result = if block_given?
194
+ yield
195
+ elsif search.respond_to?(:execute)
196
+ # Noiseless is now 100% async - execute returns Async::Task
197
+ task = search.execute
198
+ Sync { task.wait }
199
+ else
200
+ search
201
+ end
202
+
203
+ duration_ms = ((Time.current - start_time) * 1000).round(2)
204
+
205
+ assert duration_ms <= max_duration_ms,
206
+ "Expected search to complete within #{max_duration_ms}ms, took #{duration_ms}ms"
207
+
208
+ puts "[PERF] Search completed in #{duration_ms}ms (limit: #{max_duration_ms}ms)" if verbose_mode?
209
+
210
+ result
211
+ end
212
+
213
+ # 🎯 Data setup helpers
214
+ class << self
215
+ def setup_test_data(index_name, records, adapter: :primary)
216
+ define_method :setup do
217
+ super()
218
+ seed_data!(index_name, records, adapter: adapter) unless under_vcr_playback?
219
+ end
220
+ end
221
+
222
+ def reset_test_indexes(*index_names)
223
+ define_method :setup do
224
+ super()
225
+ index_names.each { |name| reset_index!(name) } unless under_vcr_playback?
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vcr"
4
+
5
+ module Noiseless
6
+ # The Ultimate Search Testing Experience
7
+ #
8
+ # Provides automatic VCR cassette management, index reset helpers,
9
+ # debug utilities, and seamless test integration for Noiseless searches.
10
+ #
11
+ # Usage:
12
+ # class MySearchTest < Minitest::Test
13
+ # include Noiseless::TestHelper
14
+ #
15
+ # def test_searching_products
16
+ # noiseless_cassette do
17
+ # results = Search::Product.by_name("Ruby").execute
18
+ # assert results.any?
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # Or even simpler:
24
+ # class MySearchTest < Noiseless::TestCase
25
+ # def test_searching_products
26
+ # results = Search::Product.by_name("Ruby").execute
27
+ # assert results.any?
28
+ # end
29
+ # end
30
+ module TestHelper
31
+ def self.included(base)
32
+ base.extend(ClassMethods)
33
+ setup_vcr_configuration
34
+ end
35
+
36
+ def self.setup_vcr_configuration
37
+ return if @vcr_configured
38
+
39
+ require "webmock"
40
+ WebMock.disable_net_connect!(allow_localhost: true)
41
+
42
+ VCR.configure do |config|
43
+ config.cassette_library_dir = "test/cassettes"
44
+ config.hook_into :webmock
45
+ config.default_cassette_options = {
46
+ record: :once,
47
+ match_requests_on: %i[method uri body]
48
+ }
49
+
50
+ # Allow HTTP connections when no cassette is in use (local only in CI)
51
+ config.allow_http_connections_when_no_cassette = !ENV["CI"]
52
+
53
+ # Ignore localhost and CI service hostname connections for tests
54
+ config.ignore_hosts "localhost", "127.0.0.1", "0.0.0.0",
55
+ "elasticsearch", "opensearch", "typesense", "postgres"
56
+
57
+ # Filter sensitive data - disabled for localhost testing
58
+ # config.filter_sensitive_data('<OPENSEARCH_HOST>') do |interaction|
59
+ # uri = URI(interaction.request.uri)
60
+ # # Only filter non-localhost hosts to avoid test issues
61
+ # uri.host unless %w[localhost 127.0.0.1 0.0.0.0].include?(uri.host)
62
+ # end
63
+ end
64
+
65
+ @vcr_configured = true
66
+ end
67
+
68
+ # Auto-VCR Integration
69
+ # Automatically generates cassette names from test class and method
70
+ def noiseless_cassette(options = {}, **kwargs, &)
71
+ # Use provided cassette name or generate one
72
+ cassette_name = kwargs[:cassette_name] || generate_cassette_name(test_method: kwargs[:test_method])
73
+
74
+ # Extract VCR options from the hash
75
+ vcr_options = options.except(:cassette_name, :test_method)
76
+ vcr_options = default_vcr_options.merge(vcr_options)
77
+
78
+ instrument_test_execution(cassette_name) do
79
+ VCR.use_cassette(cassette_name, vcr_options, &)
80
+ end
81
+ end
82
+
83
+ # Alternative method name for those who prefer it
84
+ alias use_noiseless_cassette noiseless_cassette
85
+
86
+ # 🔧 Index Management Helpers
87
+ def reset_index!(index_name, adapter: :primary)
88
+ return if under_vcr_playback?
89
+
90
+ client = Noiseless.connections.client(adapter)
91
+ client.indices.delete(index: index_name) if client.indices.exists(index: index_name)
92
+ puts "[RESET] Reset index: #{index_name}" if verbose_mode?
93
+ rescue StandardError => e
94
+ warn "[WARN] Failed to reset index #{index_name}: #{e.message}" if verbose_mode?
95
+ end
96
+
97
+ def reset_all_indexes!(adapter: :primary)
98
+ return if under_vcr_playback?
99
+
100
+ # Find all registered search classes
101
+ search_classes = find_search_classes
102
+ search_classes.each do |klass|
103
+ reset_index!(klass.index_name, adapter: adapter) if klass.respond_to?(:index_name) && klass.index_name
104
+ end
105
+ puts "[RESET] Reset all indexes (#{search_classes.size} classes)" if verbose_mode?
106
+ end
107
+
108
+ # Data Seeding Helpers
109
+ def seed_data!(index_name, records, adapter: :primary)
110
+ return if under_vcr_playback?
111
+
112
+ client = Noiseless.connections.client(adapter)
113
+
114
+ # Convert records to bulk format
115
+ bulk_body = records.flat_map.with_index do |record, index|
116
+ doc_id = record.respond_to?(:id) ? record.id : index
117
+ [
118
+ { index: { _index: index_name, _id: doc_id } },
119
+ record.respond_to?(:to_h) ? record.to_h : record
120
+ ]
121
+ end
122
+
123
+ response = client.bulk(body: bulk_body)
124
+
125
+ # Refresh index to make documents searchable immediately
126
+ client.indices.refresh(index: index_name)
127
+
128
+ puts "[SEED] Seeded #{records.size} records to #{index_name}" if verbose_mode?
129
+ response
130
+ rescue StandardError => e
131
+ warn "[WARN] Failed to seed data to #{index_name}: #{e.message}" if verbose_mode?
132
+ end
133
+
134
+ # Debug Utilities
135
+ def print_curl(search_or_ast, adapter: :primary)
136
+ return if under_vcr_playback?
137
+
138
+ client = Noiseless.connections.client(adapter)
139
+ ast = search_or_ast.respond_to?(:to_ast) ? search_or_ast.to_ast : search_or_ast
140
+
141
+ # Convert AST to query hash
142
+ query_hash = client.send(:ast_to_hash, ast)
143
+ index_name = ast.indexes.first || "unknown_index"
144
+ host = client.instance_variable_get(:@hosts).first
145
+
146
+ # Generate curl command
147
+ curl_command = build_curl_command(host, index_name, query_hash)
148
+
149
+ puts "\n[CURL] Debug cURL Command:"
150
+ puts "=" * 50
151
+ puts curl_command
152
+ puts "=" * 50
153
+
154
+ curl_command
155
+ end
156
+
157
+ def print_query(search_or_ast)
158
+ ast = search_or_ast.respond_to?(:to_ast) ? search_or_ast.to_ast : search_or_ast
159
+
160
+ puts "\n[DEBUG] Generated Query AST:"
161
+ puts "=" * 30
162
+ puts "Indexes: #{ast.indexes}"
163
+ puts "Must clauses: #{ast.bool.must.size}"
164
+ puts "Filter clauses: #{ast.bool.filter.size}"
165
+ puts "Sort clauses: #{ast.sort.size}"
166
+ puts "Pagination: #{ast.paginate ? "#{ast.paginate.page}/#{ast.paginate.per_page}" : 'default'}"
167
+ puts "=" * 30
168
+ end
169
+
170
+ # Test Instrumentation
171
+ def with_search_instrumentation
172
+ events = []
173
+ subscription = ActiveSupport::Notifications.subscribe(/noiseless/) do |name, start, finish, _id, payload|
174
+ events << {
175
+ event: name,
176
+ duration: ((finish - start) * 1000).round(2),
177
+ payload: payload
178
+ }
179
+ end
180
+
181
+ result = yield
182
+
183
+ if verbose_mode? && events.any?
184
+ puts "\n[EVENTS] Search Events:"
185
+ events.each do |event|
186
+ puts " #{event[:event]}: #{event[:duration]}ms"
187
+ end
188
+ end
189
+
190
+ result
191
+ ensure
192
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
193
+ end
194
+
195
+ private
196
+
197
+ # 🏷️ Cassette Name Generation
198
+ def generate_cassette_name(test_method: nil)
199
+ # Extract test class path (e.g., "Search::ProductTest" -> "search/product")
200
+ class_path = self.class.name
201
+ .gsub(/Test$/, "")
202
+ .underscore
203
+ .gsub("::", "/")
204
+
205
+ # Use provided test method or find from call stack
206
+ if test_method
207
+ method_name = test_method.to_s.gsub(/^test_/, "")
208
+ else
209
+ # Extract method name from call stack, looking for test method
210
+ test_method_name = find_test_method_name
211
+ method_name = test_method_name.gsub(/^test_/, "") if test_method_name
212
+
213
+ # Fallback to caller method if test method not found
214
+ unless method_name
215
+ caller_method = caller_locations(1, 1).first.label
216
+ method_name = caller_method.gsub(/^test_/, "").gsub(/[^a-zA-Z0-9_]/, "_")
217
+ end
218
+ end
219
+
220
+ "#{class_path}/#{method_name}"
221
+ end
222
+
223
+ def find_test_method_name
224
+ # Look through call stack for test method
225
+ caller_locations.each do |location|
226
+ method_name = location.label
227
+ return method_name if method_name&.start_with?("test_")
228
+ end
229
+ nil
230
+ end
231
+
232
+ def instrument_test_execution(cassette_name, &)
233
+ start_time = Time.current
234
+
235
+ puts "\n[VCR] Running test with cassette: #{cassette_name}" if verbose_mode?
236
+
237
+ result = with_search_instrumentation(&)
238
+
239
+ if verbose_mode?
240
+ duration = ((Time.current - start_time) * 1000).round(2)
241
+ puts "[DONE] Test completed in #{duration}ms"
242
+ end
243
+
244
+ result
245
+ end
246
+
247
+ def build_curl_command(host, index_name, query_hash)
248
+ json_body = JSON.pretty_generate(query_hash)
249
+
250
+ <<~CURL
251
+ curl -X POST "#{host}/#{index_name}/_search" \\
252
+ -H "Content-Type: application/json" \\
253
+ -d '#{json_body}'
254
+ CURL
255
+ end
256
+
257
+ def find_search_classes
258
+ # Find all classes that inherit from Noiseless::Model
259
+ ObjectSpace.each_object(Class).select do |klass|
260
+ klass < Noiseless::Model
261
+ rescue StandardError
262
+ false
263
+ end
264
+ end
265
+
266
+ def under_vcr_playback?
267
+ VCR.current_cassette&.recording? == false
268
+ rescue StandardError
269
+ false
270
+ end
271
+
272
+ def verbose_mode?
273
+ ENV["NOISELESS_VERBOSE"] == "true" || ENV["VERBOSE"] == "true"
274
+ end
275
+
276
+ def default_vcr_options
277
+ {
278
+ record: :once,
279
+ match_requests_on: %i[method uri body],
280
+ allow_unused_http_interactions: false
281
+ }
282
+ end
283
+
284
+ module ClassMethods
285
+ # 🎭 Class-level test helpers
286
+ def reset_all_test_indexes!
287
+ new.reset_all_indexes!
288
+ end
289
+
290
+ def seed_test_data!(index_name, records, adapter: :primary)
291
+ new.seed_data!(index_name, records, adapter: adapter)
292
+ end
293
+ end
294
+ end
295
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noiseless
4
- VERSION = "0.0.0"
5
- end
4
+ VERSION = "0.2.0"
5
+ end
data/lib/noiseless.rb CHANGED
@@ -1,8 +1,152 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "noiseless/version"
3
+ require "active_support"
4
+ require "active_support/core_ext"
5
+ require "active_support/notifications"
6
+ require "zeitwerk"
7
+ require "yaml"
8
+ require "erb"
9
+ require "json"
10
+ require "singleton"
11
+ require "async"
12
+ require "async/http/endpoint"
13
+ require "async/http/client"
14
+ require "async/pool"
15
+ require_relative "noiseless/version"
4
16
 
5
17
  module Noiseless
6
18
  class Error < StandardError; end
7
19
 
8
- end
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
+
35
+ class Configuration
36
+ attr_accessor :connections_config, :default_connection, :default_adapter, :config_path
37
+
38
+ def initialize
39
+ @connections_config = {}
40
+ @default_connection = :primary
41
+ @default_adapter = :opensearch
42
+ @config_path = lambda do
43
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
44
+ Rails.root.join("config/noiseless.yml")
45
+ else
46
+ File.expand_path("config/noiseless.yml", Dir.pwd)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.config
53
+ @config ||= Configuration.new
54
+ end
55
+
56
+ def self.reset_config!
57
+ @config = Configuration.new
58
+ end
59
+
60
+ def self.load_configuration!
61
+ path = config.config_path.respond_to?(:call) ? config.config_path.call : config.config_path
62
+ return unless File.exist?(path)
63
+
64
+ # Use Rails config_for if available and using standard config path, otherwise use ActiveSupport's YAML with ERB
65
+ rails_available = defined?(Rails) && Rails.respond_to?(:application) && Rails.respond_to?(:root) && Rails.respond_to?(:env)
66
+ standard_path = rails_available ? Rails.root.join("config/noiseless.yml") : nil
67
+
68
+ if rails_available && Rails.application && path.to_s == standard_path.to_s
69
+ # config_for already returns environment-specific config with ERB processed
70
+ env_config = Rails.application.config_for(:noiseless)
71
+ else
72
+ # Use YAML.safe_load for custom config files, with ERB processing
73
+ file_content = File.read(path)
74
+ processed_content = ERB.new(file_content).result
75
+ raw = YAML.safe_load(processed_content, aliases: true)
76
+ environment = rails_available ? Rails.env.to_s : ENV.fetch("RAILS_ENV", "development")
77
+ env_config = raw[environment] || {}
78
+ end
79
+
80
+ config.default_connection = env_config["default"].to_sym if env_config && env_config["default"]
81
+ config.connections_config = ((env_config && env_config["connections"]) || {}).transform_keys(&:to_sym)
82
+ .transform_values { |v| v.transform_keys(&:to_sym) }
83
+
84
+ # Register all connections statically from YAML - no runtime registration
85
+ config.connections_config.each do |name, params|
86
+ adapter_name = params[:adapter]
87
+ hosts = params[:hosts] || []
88
+ connections.register(name, adapter: adapter_name, hosts: hosts)
89
+ end
90
+ end
91
+
92
+ def self.configure
93
+ yield(config) if block_given?
94
+ end
95
+
96
+ # Global connection manager instance
97
+ def self.connections
98
+ @connections ||= ConnectionManager.new
99
+ end
100
+
101
+ # Setup Zeitwerk autoloader
102
+ loader = Zeitwerk::Loader.for_gem
103
+ loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch",
104
+ "cluster_api" => "ClusterAPI", "indices_api" => "IndicesAPI")
105
+ loader.ignore("#{__dir__}/application_search.rb")
106
+ loader.ignore("#{__dir__}/noiseless/test_helper.rb")
107
+ loader.ignore("#{__dir__}/noiseless/test_case.rb")
108
+ loader.setup
109
+ loader.eager_load if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
110
+
111
+ # Manually require response classes since they're in a subdirectory
112
+ require_relative "noiseless/response"
113
+ require_relative "noiseless/response_factory"
114
+
115
+ # Global registry instance
116
+ def self.registry
117
+ ModelRegistry.instance
118
+ end
119
+
120
+ # Convenience methods
121
+ def self.register_model(model_class, **options)
122
+ registry.register(model_class, options)
123
+ end
124
+
125
+ def self.all_models
126
+ registry.all_models
127
+ end
128
+
129
+ def self.searchable_models
130
+ registry.searchable_models
131
+ end
132
+
133
+ def self.multi_search(models: nil, indexes: nil, connection: nil, &block)
134
+ search_instance = MultiSearch.new(
135
+ models: models,
136
+ indexes: indexes,
137
+ connection: connection
138
+ )
139
+
140
+ if block
141
+ search_instance.search(&block)
142
+ else
143
+ search_instance
144
+ end
145
+ end
146
+ end
147
+
148
+ # Load Railtie
149
+ require "noiseless/railtie"
150
+
151
+ # Test helpers must be manually required in test_helper.rb
152
+ # This prevents VCR LoadError in production environments