noiseless 0.0.0 → 0.1.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 (71) 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 +313 -0
  6. data/lib/noiseless/adapters/elasticsearch.rb +70 -0
  7. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +188 -0
  8. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +377 -0
  9. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
  10. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
  11. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +472 -0
  12. data/lib/noiseless/adapters/open_search.rb +208 -0
  13. data/lib/noiseless/adapters/postgresql.rb +171 -0
  14. data/lib/noiseless/adapters/typesense.rb +70 -0
  15. data/lib/noiseless/adapters.rb +14 -0
  16. data/lib/noiseless/ast/aggregation.rb +56 -0
  17. data/lib/noiseless/ast/bool.rb +16 -0
  18. data/lib/noiseless/ast/bulk.rb +18 -0
  19. data/lib/noiseless/ast/collapse.rb +16 -0
  20. data/lib/noiseless/ast/combined_fields.rb +33 -0
  21. data/lib/noiseless/ast/conversation.rb +29 -0
  22. data/lib/noiseless/ast/filter.rb +15 -0
  23. data/lib/noiseless/ast/hybrid.rb +35 -0
  24. data/lib/noiseless/ast/image_query.rb +29 -0
  25. data/lib/noiseless/ast/join.rb +31 -0
  26. data/lib/noiseless/ast/match.rb +15 -0
  27. data/lib/noiseless/ast/multi_match.rb +24 -0
  28. data/lib/noiseless/ast/paginate.rb +15 -0
  29. data/lib/noiseless/ast/prefix.rb +15 -0
  30. data/lib/noiseless/ast/range.rb +18 -0
  31. data/lib/noiseless/ast/root.rb +69 -0
  32. data/lib/noiseless/ast/search_after.rb +14 -0
  33. data/lib/noiseless/ast/sort.rb +15 -0
  34. data/lib/noiseless/ast/vector.rb +27 -0
  35. data/lib/noiseless/ast/wildcard.rb +15 -0
  36. data/lib/noiseless/ast.rb +30 -0
  37. data/lib/noiseless/bulk_importer.rb +195 -0
  38. data/lib/noiseless/callbacks.rb +138 -0
  39. data/lib/noiseless/connection_manager.rb +26 -0
  40. data/lib/noiseless/document_manager.rb +137 -0
  41. data/lib/noiseless/dsl.rb +107 -0
  42. data/lib/noiseless/generators/application_search_generator.rb +24 -0
  43. data/lib/noiseless/instrumentation.rb +174 -0
  44. data/lib/noiseless/introspection/console.rb +228 -0
  45. data/lib/noiseless/introspection/query_visualizer.rb +533 -0
  46. data/lib/noiseless/introspection.rb +221 -0
  47. data/lib/noiseless/mapping.rb +253 -0
  48. data/lib/noiseless/mapping_definition_processor.rb +231 -0
  49. data/lib/noiseless/model.rb +111 -0
  50. data/lib/noiseless/model_registry.rb +77 -0
  51. data/lib/noiseless/multi_search.rb +244 -0
  52. data/lib/noiseless/pagination.rb +375 -0
  53. data/lib/noiseless/query_builder.rb +284 -0
  54. data/lib/noiseless/railtie.rb +35 -0
  55. data/lib/noiseless/response/aggregations.rb +46 -0
  56. data/lib/noiseless/response/empty.rb +20 -0
  57. data/lib/noiseless/response/records.rb +94 -0
  58. data/lib/noiseless/response/results.rb +110 -0
  59. data/lib/noiseless/response/suggestions.rb +55 -0
  60. data/lib/noiseless/response.rb +98 -0
  61. data/lib/noiseless/response_factory.rb +32 -0
  62. data/lib/noiseless/runtime_reset_middleware.rb +15 -0
  63. data/lib/noiseless/search_index_update_job.rb +84 -0
  64. data/lib/noiseless/test_case.rb +230 -0
  65. data/lib/noiseless/test_helper.rb +295 -0
  66. data/lib/noiseless/version.rb +2 -2
  67. data/lib/noiseless.rb +130 -2
  68. data/lib/tasks/benchmark.rake +35 -0
  69. data/lib/tasks/release.rake +22 -0
  70. data/lib/tasks/test.rake +11 -0
  71. metadata +260 -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.1.0"
5
+ end
data/lib/noiseless.rb CHANGED
@@ -1,8 +1,136 @@
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
+ class Configuration
21
+ attr_accessor :connections_config, :default_connection, :default_adapter, :config_path
22
+
23
+ def initialize
24
+ @connections_config = {}
25
+ @default_connection = :primary
26
+ @default_adapter = :opensearch
27
+ @config_path = lambda do
28
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
29
+ Rails.root.join("config/noiseless.yml")
30
+ else
31
+ File.expand_path("config/noiseless.yml", Dir.pwd)
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ def self.reset_config!
42
+ @config = Configuration.new
43
+ end
44
+
45
+ def self.load_configuration!
46
+ path = config.config_path.respond_to?(:call) ? config.config_path.call : config.config_path
47
+ return unless File.exist?(path)
48
+
49
+ # Use Rails config_for if available and using standard config path, otherwise use ActiveSupport's YAML with ERB
50
+ rails_available = defined?(Rails) && Rails.respond_to?(:application) && Rails.respond_to?(:root) && Rails.respond_to?(:env)
51
+ standard_path = rails_available ? Rails.root.join("config/noiseless.yml") : nil
52
+
53
+ if rails_available && Rails.application && path.to_s == standard_path.to_s
54
+ # config_for already returns environment-specific config with ERB processed
55
+ env_config = Rails.application.config_for(:noiseless)
56
+ else
57
+ # Use YAML.safe_load for custom config files, with ERB processing
58
+ file_content = File.read(path)
59
+ processed_content = ERB.new(file_content).result
60
+ raw = YAML.safe_load(processed_content, aliases: true)
61
+ environment = rails_available ? Rails.env.to_s : ENV.fetch("RAILS_ENV", "development")
62
+ env_config = raw[environment] || {}
63
+ end
64
+
65
+ config.default_connection = env_config["default"].to_sym if env_config && env_config["default"]
66
+ config.connections_config = ((env_config && env_config["connections"]) || {}).transform_keys(&:to_sym)
67
+ .transform_values { |v| v.transform_keys(&:to_sym) }
68
+
69
+ # Register all connections statically from YAML - no runtime registration
70
+ config.connections_config.each do |name, params|
71
+ adapter_name = params[:adapter]
72
+ hosts = params[:hosts] || []
73
+ connections.register(name, adapter: adapter_name, hosts: hosts)
74
+ end
75
+ end
76
+
77
+ def self.configure
78
+ yield(config) if block_given?
79
+ end
80
+
81
+ # Global connection manager instance
82
+ def self.connections
83
+ @connections ||= ConnectionManager.new
84
+ end
85
+
86
+ # Setup Zeitwerk autoloader
87
+ loader = Zeitwerk::Loader.for_gem
88
+ loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch")
89
+ loader.ignore("#{__dir__}/application_search.rb")
90
+ loader.ignore("#{__dir__}/noiseless/test_helper.rb")
91
+ loader.ignore("#{__dir__}/noiseless/test_case.rb")
92
+ loader.setup
93
+ loader.eager_load if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
94
+
95
+ # Manually require response classes since they're in a subdirectory
96
+ require_relative "noiseless/response"
97
+ require_relative "noiseless/response_factory"
98
+
99
+ # Global registry instance
100
+ def self.registry
101
+ ModelRegistry.instance
102
+ end
103
+
104
+ # Convenience methods
105
+ def self.register_model(model_class, **options)
106
+ registry.register(model_class, options)
107
+ end
108
+
109
+ def self.all_models
110
+ registry.all_models
111
+ end
112
+
113
+ def self.searchable_models
114
+ registry.searchable_models
115
+ end
116
+
117
+ def self.multi_search(models: nil, indexes: nil, connection: nil, &block)
118
+ search_instance = MultiSearch.new(
119
+ models: models,
120
+ indexes: indexes,
121
+ connection: connection
122
+ )
123
+
124
+ if block
125
+ search_instance.search(&block)
126
+ else
127
+ search_instance
128
+ end
129
+ end
130
+ end
131
+
132
+ # Load Railtie
133
+ require "noiseless/railtie"
134
+
135
+ # Test helpers must be manually required in test_helper.rb
136
+ # This prevents VCR LoadError in production environments
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :benchmark do
4
+ desc "Benchmark search performance across Elasticsearch, OpenSearch, and PostgreSQL"
5
+ task search: :environment do
6
+ require "benchmark/ips"
7
+ require "async"
8
+
9
+ # Use configured connections from Rails
10
+ engines = {
11
+ elasticsearch: :primary,
12
+ opensearch: :secondary,
13
+ postgresql: :postgresql
14
+ }
15
+
16
+ puts "=" * 80
17
+ puts "MULTI-ENGINE SEARCH BENCHMARK"
18
+ puts "=" * 80
19
+ puts "Records: 10k benchmark articles"
20
+ puts "Engines: #{engines.keys.join(', ')}"
21
+ puts "=" * 80
22
+
23
+ # Load PostgreSQL data
24
+ puts "\nšŸ“Š Loading PostgreSQL..."
25
+ Article.delete_all
26
+ fixtures_path = Rails.root.join("../../test/fixtures")
27
+ ActiveRecord::FixtureSet.create_fixtures(fixtures_path, ["benchmark_articles"])
28
+ puts "āœ… Loaded #{Article.count} records"
29
+
30
+ # Register PostgreSQL model
31
+ Noiseless.connections.client(:postgresql).register_model(Article, index_name: "articles")
32
+
33
+ puts "\nšŸš€ Benchmark ready. Run with: bundle exec rake benchmark:search"
34
+ end
35
+ end