search-engine-for-typesense 1.0.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 (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Compile partition-aware stale cleanup rules into Typesense filter strings.
5
+ #
6
+ # Evaluates entries declared via the indexing DSL `stale ...` and returns a
7
+ # list of filter fragments or a merged OR expression suitable for delete-by
8
+ # or Relation.where. Resilient to errors in individual entries.
9
+ module StaleRules
10
+ module_function
11
+
12
+ # Check whether any stale configuration is defined for this model.
13
+ #
14
+ # @param klass [Class]
15
+ # @return [Boolean]
16
+ def defined_for?(klass)
17
+ entries = begin
18
+ klass.respond_to?(:stale_entries) ? Array(klass.stale_entries) : []
19
+ rescue StandardError
20
+ []
21
+ end
22
+ return true if entries.any?
23
+
24
+ false
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ # Build an Array of Typesense filter fragments from stale rules.
30
+ #
31
+ # @param klass [Class]
32
+ # @param partition [Object, nil]
33
+ # @return [Array<String>]
34
+ def compile_filters(klass, partition: nil)
35
+ entries = begin
36
+ klass.respond_to?(:stale_entries) ? Array(klass.stale_entries) : []
37
+ rescue StandardError
38
+ []
39
+ end
40
+
41
+ filters = []
42
+ filters.concat(build_scope_filters(klass, entries, partition: partition))
43
+ filters.concat(build_attribute_filters(klass, entries))
44
+ filters.concat(build_hash_filters(klass, entries))
45
+ filters.concat(build_raw_filters(klass, entries, partition: partition))
46
+
47
+ filters.compact!
48
+ filters.reject { |f| f.to_s.strip.empty? }
49
+ rescue StandardError
50
+ []
51
+ end
52
+
53
+ # Merge multiple filter fragments with OR semantics.
54
+ #
55
+ # @param filters [Array<String>]
56
+ # @return [String, nil]
57
+ def merge_filters(filters)
58
+ list = Array(filters).compact.reject { |f| f.to_s.strip.empty? }
59
+ return nil if list.empty?
60
+ return list.first if list.size == 1
61
+
62
+ list.map { |f| "(#{f})" }.join(' || ')
63
+ end
64
+
65
+ # --- helpers ------------------------------------------------------------
66
+
67
+ def build_scope_filters(klass, entries, partition: nil)
68
+ entries
69
+ .select { |entry| entry[:type] == :scope }
70
+ .map do |entry|
71
+ scope = entry[:name]
72
+ next unless klass.respond_to?(scope)
73
+
74
+ rel = invoke_scope(klass, scope, partition)
75
+ next unless defined?(SearchEngine::Relation) && rel.is_a?(SearchEngine::Relation)
76
+
77
+ rel.filter_params
78
+ end
79
+ .compact
80
+ rescue StandardError
81
+ []
82
+ end
83
+
84
+ def build_attribute_filters(klass, entries)
85
+ entries
86
+ .select { |entry| entry[:type] == :attribute }
87
+ .map do |entry|
88
+ attr = entry[:name]
89
+ val = entry[:value]
90
+ relation_for(klass, { attr => val })&.filter_params
91
+ end
92
+ .compact
93
+ rescue StandardError
94
+ []
95
+ end
96
+
97
+ def build_hash_filters(klass, entries)
98
+ entries
99
+ .select { |entry| entry[:type] == :hash }
100
+ .map { |entry| relation_for(klass, entry[:hash])&.filter_params }
101
+ .compact
102
+ rescue StandardError
103
+ []
104
+ end
105
+
106
+ def build_raw_filters(klass, entries, partition: nil)
107
+ raw = entries.select { |entry| %i[filter relation block].include?(entry[:type]) }
108
+ Array(
109
+ raw.flat_map do |entry|
110
+ case entry[:type]
111
+ when :filter then entry[:value]
112
+ when :relation then entry[:relation]&.filter_params
113
+ when :block then evaluate_block_entry(klass, entry[:block], partition: partition)
114
+ end
115
+ end
116
+ ).compact
117
+ rescue StandardError
118
+ []
119
+ end
120
+
121
+ def relation_for(klass, hash)
122
+ SearchEngine::Relation.new(klass).where(hash)
123
+ end
124
+
125
+ def evaluate_block_entry(klass, block, partition: nil)
126
+ params = block.parameters
127
+ result = if params.any? { |(kind, name)| %i[key keyreq].include?(kind) && name == :partition }
128
+ klass.instance_exec(partition: partition, &block)
129
+ elsif block.arity.positive?
130
+ klass.instance_exec(partition, &block)
131
+ else
132
+ klass.instance_exec(&block)
133
+ end
134
+
135
+ case result
136
+ when String then result
137
+ when Hash then relation_for(klass, result)&.filter_params
138
+ when SearchEngine::Relation then result.filter_params
139
+ end
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ def invoke_scope(klass, scope, partition)
145
+ method_obj = klass.method(scope)
146
+ params = method_obj.parameters
147
+ if params.empty?
148
+ klass.public_send(scope)
149
+ elsif params.any? { |(kind, name)| %i[key keyreq].include?(kind) && %i[partition _partition].include?(name) }
150
+ klass.public_send(scope, partition: partition)
151
+ elsif params.first && %i[req opt].include?(params.first.first)
152
+ klass.public_send(scope, partition)
153
+ else
154
+ klass.public_send(scope)
155
+ end
156
+ rescue ArgumentError
157
+ klass.public_send(scope)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module Test
5
+ # Minitest assertion helpers for SearchEngine event testing.
6
+ #
7
+ # Include this module in your test case to use `assert_emits` and `capture_events`.
8
+ module MinitestAssertions
9
+ # Assert that a named event is emitted within the provided block.
10
+ # Optional payload matcher may be a Hash, Proc, or object responding to :matches?.
11
+ # @param name [String, Regexp]
12
+ # @param payload [Object, nil]
13
+ # @yield block to execute
14
+ def assert_emits(name, payload: nil, &block)
15
+ captured = SearchEngine::Test.capture_events(/^search_engine\./, &block)
16
+ matches = filter_by_name(captured, name)
17
+ refute_empty(matches, "expected block to emit #{name.inspect}, but none matched")
18
+ if payload
19
+ ok = matches.any? { |ev| payload_matches?(ev[:payload], payload) }
20
+ detail = matches.map { |e| e[:payload] }.inspect
21
+ assert(ok, "expected an event payload matching #{payload.inspect}, got: #{detail}")
22
+ end
23
+ true
24
+ end
25
+
26
+ # Capture events emitted within the block and return the Array.
27
+ # @yield block to execute
28
+ # @return [Array<Hash>]
29
+ def capture_events(&block)
30
+ SearchEngine::Test.capture_events(/^search_engine\./, &block)
31
+ end
32
+
33
+ private
34
+
35
+ def filter_by_name(events, name)
36
+ case name
37
+ when Regexp
38
+ events.select { |ev| ev[:name] =~ name }
39
+ else
40
+ events.select { |ev| ev[:name].to_s == name.to_s }
41
+ end
42
+ end
43
+
44
+ def payload_matches?(payload, matcher)
45
+ if matcher.respond_to?(:matches?)
46
+ matcher.matches?(payload)
47
+ elsif matcher.is_a?(Proc)
48
+ matcher.call(payload)
49
+ else
50
+ payload == matcher
51
+ end
52
+ rescue StandardError
53
+ false
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/UnusedMethodArgument
4
+
5
+ require 'search_engine/result'
6
+
7
+ module SearchEngine
8
+ module Test
9
+ # No-op client that mirrors SearchEngine::Client without network I/O.
10
+ # Returns safe empty/ok responses for all operations.
11
+ class OfflineClient
12
+ SUCCESS_JSONL = "{\"success\":true}\n"
13
+ EMPTY_SEARCH = { 'hits' => [], 'found' => 0, 'out_of' => 0 }.freeze
14
+
15
+ def search(collection:, params:, url_opts: {})
16
+ SearchEngine::Result.new(EMPTY_SEARCH, klass: nil)
17
+ end
18
+
19
+ def multi_search(searches:, url_opts: {})
20
+ { 'results' => [] }
21
+ end
22
+
23
+ def import_documents(collection:, jsonl:, action: :upsert)
24
+ SUCCESS_JSONL
25
+ end
26
+
27
+ def delete_documents_by_filter(collection:, filter_by:, timeout_ms: nil)
28
+ { 'num_deleted' => 0 }
29
+ end
30
+
31
+ def delete_document(collection:, id:, timeout_ms: nil)
32
+ {}
33
+ end
34
+
35
+ def retrieve_document(collection:, id:, timeout_ms: nil)
36
+ nil
37
+ end
38
+
39
+ def update_document(collection:, id:, fields:, timeout_ms: nil)
40
+ fields.merge('id' => id.to_s)
41
+ end
42
+
43
+ def update_documents_by_filter(collection:, filter_by:, fields:, timeout_ms: nil)
44
+ { 'num_updated' => 0 }
45
+ end
46
+
47
+ def create_document(collection:, document:)
48
+ document
49
+ end
50
+
51
+ def resolve_alias(logical_name, timeout_ms: nil)
52
+ nil
53
+ end
54
+
55
+ def retrieve_collection_schema(collection_name, timeout_ms: nil)
56
+ nil
57
+ end
58
+
59
+ def upsert_alias(alias_name, physical_name)
60
+ {}
61
+ end
62
+
63
+ def create_collection(schema)
64
+ schema
65
+ end
66
+
67
+ def update_collection(name, schema)
68
+ schema
69
+ end
70
+
71
+ def delete_collection(name, timeout_ms: nil)
72
+ {}
73
+ end
74
+
75
+ def list_collections(timeout_ms: nil)
76
+ []
77
+ end
78
+
79
+ def health
80
+ { 'ok' => true }
81
+ end
82
+
83
+ def metrics
84
+ {}
85
+ end
86
+
87
+ def stats
88
+ {}
89
+ end
90
+
91
+ def list_api_keys
92
+ []
93
+ end
94
+
95
+ def synonyms_upsert(collection:, id:, terms:)
96
+ {}
97
+ end
98
+
99
+ def synonyms_list(collection:)
100
+ []
101
+ end
102
+
103
+ def synonyms_get(collection:, id:)
104
+ nil
105
+ end
106
+
107
+ def synonyms_delete(collection:, id:)
108
+ {}
109
+ end
110
+
111
+ def stopwords_upsert(collection:, id:, terms:)
112
+ {}
113
+ end
114
+
115
+ def stopwords_list(collection:)
116
+ []
117
+ end
118
+
119
+ def stopwords_get(collection:, id:)
120
+ nil
121
+ end
122
+
123
+ def stopwords_delete(collection:, id:)
124
+ {}
125
+ end
126
+
127
+ def clear_cache
128
+ { 'success' => true }
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ # rubocop:enable Lint/UnusedMethodArgument
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'rspec/expectations'
5
+ rescue LoadError
6
+ # RSpec not available; file remains loadable but inert in non-RSpec contexts
7
+ end
8
+
9
+ if defined?(RSpec)
10
+ module SearchEngine
11
+ # Test utilities namespace. Contains RSpec matchers for event assertions.
12
+ module Test
13
+ # Internal helpers to keep matcher block concise
14
+ def self.emit_event_filter_by_name(events, name)
15
+ case name
16
+ when Regexp then events.select { |ev| ev[:name] =~ name }
17
+ else events.select { |ev| ev[:name].to_s == name.to_s }
18
+ end
19
+ end
20
+
21
+ def self.emit_event_payload_matches?(payload, matcher)
22
+ if matcher.respond_to?(:matches?)
23
+ matcher.matches?(payload)
24
+ elsif matcher.is_a?(Proc)
25
+ matcher.call(payload)
26
+ else
27
+ payload == matcher
28
+ end
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ def self.emit_event_redact_for_message(payload)
34
+ SearchEngine::Observability.redact(payload)
35
+ rescue StandardError
36
+ payload
37
+ end
38
+
39
+ def self.emit_event_failure_message(expected_name, payload_matcher, events)
40
+ lines = []
41
+ lines << "expected block to emit #{expected_name.inspect}"
42
+ lines << "with payload matching: #{payload_matcher.inspect}" if payload_matcher
43
+ if events && !events.empty?
44
+ samples = events.take(3).map { |ev| emit_event_redact_for_message(ev[:payload]) }
45
+ lines << "but got events: #{samples.inspect}"
46
+ else
47
+ lines << 'but no matching events were emitted'
48
+ end
49
+ lines.join("\n")
50
+ end
51
+
52
+ # RSpec matcher to assert that an event is emitted during a block.
53
+ # Usage:
54
+ # expect { rel.to_a }.to emit_event('search_engine.search').with(hash_including(collection: 'products'))
55
+ RSpec::Matchers.define :emit_event do |expected_name|
56
+ supports_block_expectations
57
+
58
+ chain :with do |payload_matcher|
59
+ @payload_matcher = payload_matcher
60
+ end
61
+
62
+ match do |probe|
63
+ captured = SearchEngine::Test.capture_events(/^search_engine\./) { probe.call }
64
+ @events = SearchEngine::Test.emit_event_filter_by_name(captured, expected_name)
65
+ return false if @events.empty?
66
+ return true unless @payload_matcher
67
+
68
+ @events.any? { |ev| SearchEngine::Test.emit_event_payload_matches?(ev[:payload], @payload_matcher) }
69
+ end
70
+
71
+ failure_message do
72
+ SearchEngine::Test.emit_event_failure_message(expected_name, @payload_matcher, @events)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'json'
5
+
6
+ module SearchEngine
7
+ module Test
8
+ # Test-only programmable stub client that mimics the public surface of
9
+ # SearchEngine::Client for search and multi_search. It never performs I/O.
10
+ #
11
+ # Thread-safe: queues and captures are guarded by a Monitor.
12
+ # Redaction: bodies and captured payloads are redacted for safe inspection.
13
+ #
14
+ # Usage:
15
+ # stub = SearchEngine::Test::StubClient.new
16
+ # stub.enqueue_response(:search, { 'hits' => [], 'found' => 0, 'out_of' => 0 })
17
+ # SearchEngine.configure { |c| c.client = stub }
18
+ #
19
+ # Queued responses are FIFO. You may enqueue Exceptions to simulate errors
20
+ # or Procs that receive the captured request and return a response.
21
+ #
22
+ # @since M8
23
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
24
+ class StubClient
25
+ Call = Struct.new(
26
+ :timestamp,
27
+ :correlation_id,
28
+ :verb,
29
+ :url,
30
+ :body,
31
+ :url_opts,
32
+ :redacted_body,
33
+ :redacted?,
34
+ keyword_init: true
35
+ )
36
+
37
+ def initialize
38
+ @lock = Monitor.new
39
+ @queues = { search: [], multi_search: [] }
40
+ @calls = { search: [], multi_search: [] }
41
+ end
42
+
43
+ # Enqueue a response for a given method. Accepts a Hash, Exception, or Proc.
44
+ # @param method [Symbol] :search or :multi_search
45
+ # @param value [Hash, Exception, Proc]
46
+ # @return [void]
47
+ # @since M8
48
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing#quick-start
49
+ def enqueue_response(method, value)
50
+ @lock.synchronize do
51
+ queue_for(method) << value
52
+ end
53
+ end
54
+
55
+ # Reset all internal state (queues and captures).
56
+ # @since M8
57
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing#parallel-test-safety
58
+ def reset!
59
+ @lock.synchronize do
60
+ @queues.each_value(&:clear)
61
+ @calls.each_value(&:clear)
62
+ end
63
+ end
64
+
65
+ # Return captured calls for search.
66
+ # @return [Array<Call>]
67
+ # @since M8
68
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
69
+ def search_calls
70
+ @lock.synchronize { @calls[:search].dup }
71
+ end
72
+
73
+ # Return captured calls for multi_search.
74
+ # @return [Array<Call>]
75
+ # @since M8
76
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
77
+ def multi_search_calls
78
+ @lock.synchronize { @calls[:multi_search].dup }
79
+ end
80
+
81
+ # All calls in chronological order.
82
+ # @return [Array<Call>]
83
+ # @since M8
84
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
85
+ def all_calls
86
+ @lock.synchronize { (@calls[:search] + @calls[:multi_search]).sort_by(&:timestamp) }
87
+ end
88
+
89
+ # Public API: single search. Mirrors Client#search arity. Returns Result-like object.
90
+ # @param collection [String]
91
+ # @param params [Hash]
92
+ # @param url_opts [Hash]
93
+ # @since M8
94
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
95
+ def search(collection:, params:, url_opts: {})
96
+ unless collection.is_a?(String) && !collection.strip.empty?
97
+ raise ArgumentError, 'collection must be a non-empty String'
98
+ end
99
+
100
+ params_obj = SearchEngine::CompiledParams.from(params)
101
+ params_hash = params_obj.to_h
102
+
103
+ entry = capture(:search, url: compiled_url(collection), params: params_hash, url_opts: url_opts)
104
+ payload = dequeue_or_default(:search, entry)
105
+ wrap_single(payload)
106
+ end
107
+
108
+ # Public API: multi search. Mirrors top-level helper client usage: returns raw Hash from Typesense.
109
+ # @param searches [Array<Hash>]
110
+ # @param url_opts [Hash]
111
+ # @since M8
112
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
113
+ def multi_search(searches:, url_opts: {})
114
+ unless searches.is_a?(Array) && searches.all? { |h| h.is_a?(Hash) }
115
+ raise ArgumentError, 'searches must be an Array of Hashes'
116
+ end
117
+
118
+ # Record a synthetic URL for multi endpoint; individual bodies are not posted here
119
+ entry = capture(:multi_search, url: compiled_multi_url, params: searches, url_opts: url_opts)
120
+ dequeue_or_default(:multi_search, entry).tap do |raw|
121
+ return raw
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def dequeue_or_default(method, entry)
128
+ val = @lock.synchronize { queue_for(method).shift }
129
+ case val
130
+ when nil
131
+ default_payload(method)
132
+ when Proc
133
+ val.call(entry)
134
+ when Exception
135
+ raise val
136
+ else
137
+ val
138
+ end
139
+ end
140
+
141
+ def default_payload(method)
142
+ if method == :search
143
+ { 'hits' => [], 'found' => 0, 'out_of' => 0 }
144
+ else
145
+ { 'results' => [] }
146
+ end
147
+ end
148
+
149
+ def queue_for(method)
150
+ q = @queues[method]
151
+ raise ArgumentError, "unknown method: #{method.inspect}" unless q
152
+
153
+ q
154
+ end
155
+
156
+ def compiled_url(collection)
157
+ cfg = SearchEngine.config
158
+ proto = cfg.protocol.to_s.strip.presence
159
+ base = [proto, "#{cfg.host}:#{cfg.port}"].compact.join('://')
160
+ "#{base}/collections/#{collection}/documents/search"
161
+ end
162
+
163
+ def compiled_multi_url
164
+ cfg = SearchEngine.config
165
+ proto = cfg.protocol.to_s.strip.presence
166
+ base = [proto, "#{cfg.host}:#{cfg.port}"].compact.join('://')
167
+ "#{base}/multi_search"
168
+ end
169
+
170
+ def capture(method, url:, params:, url_opts: {})
171
+ redacted = begin
172
+ SearchEngine::Observability.redact(params)
173
+ rescue StandardError
174
+ params
175
+ end
176
+ corr = begin
177
+ SearchEngine::Instrumentation.current_correlation_id
178
+ rescue StandardError
179
+ nil
180
+ end
181
+ entry = Call.new(
182
+ timestamp: SearchEngine::Instrumentation.monotonic_ms,
183
+ correlation_id: corr,
184
+ verb: method,
185
+ url: url,
186
+ body: params,
187
+ url_opts: SearchEngine::Observability.filtered_url_opts(url_opts),
188
+ redacted_body: redacted,
189
+ redacted?: true
190
+ )
191
+ @lock.synchronize { @calls[method] << entry }
192
+ entry
193
+ end
194
+
195
+ def wrap_single(payload)
196
+ # Mirror Client#search returning SearchEngine::Result
197
+ SearchEngine::Result.new(payload, klass: nil)
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Test-only utilities for offline execution and assertions.
5
+ #
6
+ # Provides:
7
+ # - SearchEngine::Test::StubClient — a programmable stub client that captures requests
8
+ # - SearchEngine::Test::OfflineClient — a no-op client for test/offline mode
9
+ # - Event capture helpers (SearchEngine::Test.capture_events)
10
+ # - Framework adapters (RSpec matcher `emit_event`, Minitest assertions)
11
+ #
12
+ # These helpers are allocation-light, thread-safe, and never perform network I/O.
13
+ #
14
+ # @since M8
15
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing
16
+ module Test
17
+ class << self
18
+ # Subscribe to `search_engine.*` for the duration of the block and return captured events.
19
+ # Each event is a Hash: { name:, payload:, time:, duration: }
20
+ # Payloads are redacted for safety.
21
+ # @param name [String, Regexp, nil]
22
+ # @yield block within which events are captured
23
+ # @return [Array<Hash>]
24
+ # @since M8
25
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/testing#event-assertions
26
+ def capture_events(name = nil)
27
+ require 'active_support/notifications'
28
+ pattern = name.is_a?(Regexp) ? name : /^search_engine\./
29
+ captured = []
30
+ handle = ActiveSupport::Notifications.subscribe(pattern) do |*args|
31
+ ev = ActiveSupport::Notifications::Event.new(*args)
32
+ payload = safe_payload(ev.payload)
33
+ captured << { name: ev.name, payload: payload, time: ev.time, duration: ev.duration }
34
+ end
35
+ yield
36
+ captured
37
+ ensure
38
+ ActiveSupport::Notifications.unsubscribe(handle) if defined?(handle)
39
+ end
40
+
41
+ # Internal: apply redaction once more to be safe
42
+ def safe_payload(payload)
43
+ p = payload.dup
44
+ p[:params] = SearchEngine::Observability.redact(p[:params]) if p.key?(:params)
45
+ p[:params_preview] = SearchEngine::Observability.redact(p[:params_preview]) if p.key?(:params_preview)
46
+ p
47
+ rescue StandardError
48
+ payload
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ require 'search_engine/test/stub_client'
55
+ require 'search_engine/test/offline_client'
56
+ # Framework adapters are optional; require only when present
57
+ begin
58
+ require 'search_engine/test/rspec_matchers'
59
+ rescue LoadError
60
+ # no-op
61
+ end
62
+ begin
63
+ require 'search_engine/test/minitest_assertions'
64
+ rescue LoadError
65
+ # no-op
66
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Trivial module used to assert lib/ autoload works as expected.
5
+ module TestAutoload
6
+ NAME = 'SearchEngine::TestAutoload'
7
+ end
8
+ end