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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/app/search_engine/search_engine/app_info.rb +11 -0
- data/app/search_engine/search_engine/index_partition_job.rb +170 -0
- data/lib/generators/search_engine/install/install_generator.rb +20 -0
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
- data/lib/generators/search_engine/model/model_generator.rb +86 -0
- data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
- data/lib/search-engine-for-typesense.rb +12 -0
- data/lib/search_engine/active_record_syncable.rb +247 -0
- data/lib/search_engine/admin/stopwords.rb +125 -0
- data/lib/search_engine/admin/synonyms.rb +125 -0
- data/lib/search_engine/admin.rb +12 -0
- data/lib/search_engine/ast/and.rb +52 -0
- data/lib/search_engine/ast/binary_op.rb +75 -0
- data/lib/search_engine/ast/eq.rb +19 -0
- data/lib/search_engine/ast/group.rb +18 -0
- data/lib/search_engine/ast/gt.rb +12 -0
- data/lib/search_engine/ast/gte.rb +12 -0
- data/lib/search_engine/ast/in.rb +28 -0
- data/lib/search_engine/ast/lt.rb +12 -0
- data/lib/search_engine/ast/lte.rb +12 -0
- data/lib/search_engine/ast/matches.rb +55 -0
- data/lib/search_engine/ast/node.rb +176 -0
- data/lib/search_engine/ast/not_eq.rb +13 -0
- data/lib/search_engine/ast/not_in.rb +24 -0
- data/lib/search_engine/ast/or.rb +52 -0
- data/lib/search_engine/ast/prefix.rb +51 -0
- data/lib/search_engine/ast/raw.rb +41 -0
- data/lib/search_engine/ast/unary_op.rb +43 -0
- data/lib/search_engine/ast.rb +101 -0
- data/lib/search_engine/base/creation.rb +727 -0
- data/lib/search_engine/base/deletion.rb +80 -0
- data/lib/search_engine/base/display_coercions.rb +36 -0
- data/lib/search_engine/base/hydration.rb +312 -0
- data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
- data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
- data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
- data/lib/search_engine/base/index_maintenance.rb +459 -0
- data/lib/search_engine/base/indexing_dsl.rb +255 -0
- data/lib/search_engine/base/joins.rb +479 -0
- data/lib/search_engine/base/model_dsl.rb +472 -0
- data/lib/search_engine/base/presets.rb +43 -0
- data/lib/search_engine/base/pretty_printer.rb +315 -0
- data/lib/search_engine/base/relation_delegation.rb +42 -0
- data/lib/search_engine/base/scopes.rb +113 -0
- data/lib/search_engine/base/updating.rb +92 -0
- data/lib/search_engine/base.rb +38 -0
- data/lib/search_engine/bulk.rb +284 -0
- data/lib/search_engine/cache.rb +33 -0
- data/lib/search_engine/cascade.rb +531 -0
- data/lib/search_engine/cli/doctor.rb +631 -0
- data/lib/search_engine/cli/support.rb +217 -0
- data/lib/search_engine/cli.rb +222 -0
- data/lib/search_engine/client/http_adapter.rb +63 -0
- data/lib/search_engine/client/request_builder.rb +92 -0
- data/lib/search_engine/client/services/base.rb +74 -0
- data/lib/search_engine/client/services/collections.rb +161 -0
- data/lib/search_engine/client/services/documents.rb +214 -0
- data/lib/search_engine/client/services/operations.rb +152 -0
- data/lib/search_engine/client/services/search.rb +190 -0
- data/lib/search_engine/client/services.rb +29 -0
- data/lib/search_engine/client.rb +765 -0
- data/lib/search_engine/client_options.rb +20 -0
- data/lib/search_engine/collection_resolver.rb +191 -0
- data/lib/search_engine/collections_graph.rb +330 -0
- data/lib/search_engine/compiled_params.rb +143 -0
- data/lib/search_engine/compiler.rb +383 -0
- data/lib/search_engine/config/observability.rb +27 -0
- data/lib/search_engine/config/presets.rb +92 -0
- data/lib/search_engine/config/selection.rb +16 -0
- data/lib/search_engine/config/typesense.rb +48 -0
- data/lib/search_engine/config/validators.rb +97 -0
- data/lib/search_engine/config.rb +917 -0
- data/lib/search_engine/console_helpers.rb +130 -0
- data/lib/search_engine/deletion.rb +103 -0
- data/lib/search_engine/dispatcher.rb +125 -0
- data/lib/search_engine/dsl/parser.rb +582 -0
- data/lib/search_engine/engine.rb +167 -0
- data/lib/search_engine/errors.rb +290 -0
- data/lib/search_engine/filters/sanitizer.rb +189 -0
- data/lib/search_engine/hydration/materializers.rb +808 -0
- data/lib/search_engine/hydration/selection_context.rb +96 -0
- data/lib/search_engine/indexer/batch_planner.rb +76 -0
- data/lib/search_engine/indexer/bulk_import.rb +626 -0
- data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
- data/lib/search_engine/indexer/retry_policy.rb +103 -0
- data/lib/search_engine/indexer.rb +747 -0
- data/lib/search_engine/instrumentation.rb +308 -0
- data/lib/search_engine/joins/guard.rb +202 -0
- data/lib/search_engine/joins/resolver.rb +95 -0
- data/lib/search_engine/logging/color.rb +78 -0
- data/lib/search_engine/logging/format_helpers.rb +92 -0
- data/lib/search_engine/logging/partition_progress.rb +53 -0
- data/lib/search_engine/logging_subscriber.rb +388 -0
- data/lib/search_engine/mapper.rb +785 -0
- data/lib/search_engine/multi.rb +286 -0
- data/lib/search_engine/multi_result.rb +186 -0
- data/lib/search_engine/notifications/compact_logger.rb +675 -0
- data/lib/search_engine/observability.rb +162 -0
- data/lib/search_engine/operations.rb +58 -0
- data/lib/search_engine/otel.rb +227 -0
- data/lib/search_engine/partitioner.rb +128 -0
- data/lib/search_engine/ranking_plan.rb +118 -0
- data/lib/search_engine/registry.rb +158 -0
- data/lib/search_engine/relation/compiler.rb +711 -0
- data/lib/search_engine/relation/deletion.rb +37 -0
- data/lib/search_engine/relation/dsl/filters.rb +624 -0
- data/lib/search_engine/relation/dsl/selection.rb +240 -0
- data/lib/search_engine/relation/dsl.rb +903 -0
- data/lib/search_engine/relation/dx/dry_run.rb +59 -0
- data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
- data/lib/search_engine/relation/dx.rb +231 -0
- data/lib/search_engine/relation/materializers.rb +118 -0
- data/lib/search_engine/relation/options.rb +138 -0
- data/lib/search_engine/relation/state.rb +274 -0
- data/lib/search_engine/relation/updating.rb +44 -0
- data/lib/search_engine/relation.rb +623 -0
- data/lib/search_engine/result.rb +664 -0
- data/lib/search_engine/schema.rb +1083 -0
- data/lib/search_engine/sources/active_record_source.rb +185 -0
- data/lib/search_engine/sources/base.rb +62 -0
- data/lib/search_engine/sources/lambda_source.rb +55 -0
- data/lib/search_engine/sources/sql_source.rb +196 -0
- data/lib/search_engine/sources.rb +71 -0
- data/lib/search_engine/stale_rules.rb +160 -0
- data/lib/search_engine/test/minitest_assertions.rb +57 -0
- data/lib/search_engine/test/offline_client.rb +134 -0
- data/lib/search_engine/test/rspec_matchers.rb +77 -0
- data/lib/search_engine/test/stub_client.rb +201 -0
- data/lib/search_engine/test.rb +66 -0
- data/lib/search_engine/test_autoload.rb +8 -0
- data/lib/search_engine/update.rb +35 -0
- data/lib/search_engine/version.rb +7 -0
- data/lib/search_engine.rb +332 -0
- data/lib/tasks/search_engine.rake +501 -0
- data/lib/tasks/search_engine_doctor.rake +16 -0
- 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
|