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.
- checksums.yaml +4 -4
- data/LICENSE.txt +28 -0
- data/README.md +214 -0
- data/lib/application_search.rb +15 -0
- data/lib/noiseless/adapter.rb +313 -0
- data/lib/noiseless/adapters/elasticsearch.rb +70 -0
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +188 -0
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +377 -0
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +472 -0
- data/lib/noiseless/adapters/open_search.rb +208 -0
- data/lib/noiseless/adapters/postgresql.rb +171 -0
- data/lib/noiseless/adapters/typesense.rb +70 -0
- data/lib/noiseless/adapters.rb +14 -0
- data/lib/noiseless/ast/aggregation.rb +56 -0
- data/lib/noiseless/ast/bool.rb +16 -0
- data/lib/noiseless/ast/bulk.rb +18 -0
- data/lib/noiseless/ast/collapse.rb +16 -0
- data/lib/noiseless/ast/combined_fields.rb +33 -0
- data/lib/noiseless/ast/conversation.rb +29 -0
- data/lib/noiseless/ast/filter.rb +15 -0
- data/lib/noiseless/ast/hybrid.rb +35 -0
- data/lib/noiseless/ast/image_query.rb +29 -0
- data/lib/noiseless/ast/join.rb +31 -0
- data/lib/noiseless/ast/match.rb +15 -0
- data/lib/noiseless/ast/multi_match.rb +24 -0
- data/lib/noiseless/ast/paginate.rb +15 -0
- data/lib/noiseless/ast/prefix.rb +15 -0
- data/lib/noiseless/ast/range.rb +18 -0
- data/lib/noiseless/ast/root.rb +69 -0
- data/lib/noiseless/ast/search_after.rb +14 -0
- data/lib/noiseless/ast/sort.rb +15 -0
- data/lib/noiseless/ast/vector.rb +27 -0
- data/lib/noiseless/ast/wildcard.rb +15 -0
- data/lib/noiseless/ast.rb +30 -0
- data/lib/noiseless/bulk_importer.rb +195 -0
- data/lib/noiseless/callbacks.rb +138 -0
- data/lib/noiseless/connection_manager.rb +26 -0
- data/lib/noiseless/document_manager.rb +137 -0
- data/lib/noiseless/dsl.rb +107 -0
- data/lib/noiseless/generators/application_search_generator.rb +24 -0
- data/lib/noiseless/instrumentation.rb +174 -0
- data/lib/noiseless/introspection/console.rb +228 -0
- data/lib/noiseless/introspection/query_visualizer.rb +533 -0
- data/lib/noiseless/introspection.rb +221 -0
- data/lib/noiseless/mapping.rb +253 -0
- data/lib/noiseless/mapping_definition_processor.rb +231 -0
- data/lib/noiseless/model.rb +111 -0
- data/lib/noiseless/model_registry.rb +77 -0
- data/lib/noiseless/multi_search.rb +244 -0
- data/lib/noiseless/pagination.rb +375 -0
- data/lib/noiseless/query_builder.rb +284 -0
- data/lib/noiseless/railtie.rb +35 -0
- data/lib/noiseless/response/aggregations.rb +46 -0
- data/lib/noiseless/response/empty.rb +20 -0
- data/lib/noiseless/response/records.rb +94 -0
- data/lib/noiseless/response/results.rb +110 -0
- data/lib/noiseless/response/suggestions.rb +55 -0
- data/lib/noiseless/response.rb +98 -0
- data/lib/noiseless/response_factory.rb +32 -0
- data/lib/noiseless/runtime_reset_middleware.rb +15 -0
- data/lib/noiseless/search_index_update_job.rb +84 -0
- data/lib/noiseless/test_case.rb +230 -0
- data/lib/noiseless/test_helper.rb +295 -0
- data/lib/noiseless/version.rb +2 -2
- data/lib/noiseless.rb +130 -2
- data/lib/tasks/benchmark.rake +35 -0
- data/lib/tasks/release.rake +22 -0
- data/lib/tasks/test.rake +11 -0
- 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
|
data/lib/noiseless/version.rb
CHANGED
data/lib/noiseless.rb
CHANGED
|
@@ -1,8 +1,136 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
-
|
|
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
|