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,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
|
|
7
|
+
module SearchEngine
|
|
8
|
+
module Cli
|
|
9
|
+
# Small, pure helpers shared by CLI tasks/commands.
|
|
10
|
+
#
|
|
11
|
+
# Side‑effect free and safe to require in task context.
|
|
12
|
+
# All helpers return strings/values; callers are responsible for printing.
|
|
13
|
+
module Support
|
|
14
|
+
# Common UI constants (kept minimal to avoid behavior drift)
|
|
15
|
+
DOCS_ROOT = 'docs/'
|
|
16
|
+
SEP = ' - '
|
|
17
|
+
BULLET = '-'
|
|
18
|
+
CHECK = '✓'
|
|
19
|
+
WARN = '⚠'
|
|
20
|
+
CROSS = '✖'
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# --- JSON helpers ------------------------------------------------------
|
|
25
|
+
# @param str [Object]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def json_string?(str)
|
|
28
|
+
s = str.to_s.strip
|
|
29
|
+
return false if s.empty?
|
|
30
|
+
|
|
31
|
+
JSON.parse(s)
|
|
32
|
+
true
|
|
33
|
+
rescue StandardError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parse JSON safely.
|
|
38
|
+
# @param str [Object]
|
|
39
|
+
# @return [Object, nil] parsed JSON or nil when invalid
|
|
40
|
+
def parse_json_safe(str)
|
|
41
|
+
s = str.to_s
|
|
42
|
+
return nil if s.strip.empty?
|
|
43
|
+
|
|
44
|
+
JSON.parse(s)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse JSON or return original string, without raising.
|
|
50
|
+
# @param str [Object]
|
|
51
|
+
# @return [Object] parsed value or the original string
|
|
52
|
+
def parse_json_or_string(str)
|
|
53
|
+
parsed = parse_json_safe(str)
|
|
54
|
+
return parsed unless parsed.nil?
|
|
55
|
+
|
|
56
|
+
str.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether JSON output is requested via FORMAT=json.
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def json_output?
|
|
62
|
+
(ENV['FORMAT'] || '').to_s.strip.downcase == 'json'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns true when ENV[name] is a truthy flag (1/true/yes/on).
|
|
66
|
+
# @param name [String]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def boolean_env?(name)
|
|
69
|
+
truthy_env?(ENV[name])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- Console formatting ------------------------------------------------
|
|
73
|
+
# Respect NO_COLOR and TTY.
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def color?
|
|
76
|
+
no_color = ENV['NO_COLOR']
|
|
77
|
+
return false if truthy_env?(no_color)
|
|
78
|
+
|
|
79
|
+
$stdout.respond_to?(:tty?) ? $stdout.tty? : false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether emoji are enabled (disabled via NO_EMOJI=1).
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def emoji?
|
|
85
|
+
return false if truthy_env?(ENV['NO_EMOJI'])
|
|
86
|
+
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Heading formatter (no decoration by default to preserve existing output).
|
|
91
|
+
# @param text [String]
|
|
92
|
+
# @return [String]
|
|
93
|
+
def fmt_heading(text)
|
|
94
|
+
text.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Bullet line formatter.
|
|
98
|
+
# @param text [String]
|
|
99
|
+
# @return [String]
|
|
100
|
+
def fmt_bullet(text)
|
|
101
|
+
"#{BULLET} #{text}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Key/Value formatter (key: value)
|
|
105
|
+
# @param key [String]
|
|
106
|
+
# @param value [Object]
|
|
107
|
+
# @return [String]
|
|
108
|
+
def fmt_kv(key, value)
|
|
109
|
+
"#{key}: #{value}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Green success line.
|
|
113
|
+
# @param text [String]
|
|
114
|
+
# @return [String]
|
|
115
|
+
def fmt_ok(text)
|
|
116
|
+
base = emoji? ? "#{CHECK} #{text}" : "OK #{text}"
|
|
117
|
+
color? ? colorize(base, 32) : base
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Yellow warning line.
|
|
121
|
+
# @param text [String]
|
|
122
|
+
# @return [String]
|
|
123
|
+
def fmt_warn(text)
|
|
124
|
+
base = emoji? ? "#{WARN} #{text}" : "WARN #{text}"
|
|
125
|
+
color? ? colorize(base, 33) : base
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Red error line.
|
|
129
|
+
# @param text [String]
|
|
130
|
+
# @return [String]
|
|
131
|
+
def fmt_err(text)
|
|
132
|
+
base = emoji? ? "#{CROSS} #{text}" : "ERROR #{text}"
|
|
133
|
+
color? ? colorize(base, 31) : base
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Simple text wrap (hard break by width).
|
|
137
|
+
# @param text [String]
|
|
138
|
+
# @param width [Integer]
|
|
139
|
+
# @return [String]
|
|
140
|
+
def wrap(text, width: 80)
|
|
141
|
+
s = text.to_s
|
|
142
|
+
return s if width.to_i <= 0
|
|
143
|
+
|
|
144
|
+
lines = []
|
|
145
|
+
s.split(/\r?\n/).each do |line|
|
|
146
|
+
lines << line.slice!(0, width) while line.length > width
|
|
147
|
+
lines << line
|
|
148
|
+
end
|
|
149
|
+
lines.join("\n")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Indent text by level (2 spaces per level by default).
|
|
153
|
+
# @param text [String]
|
|
154
|
+
# @param level [Integer]
|
|
155
|
+
# @param spaces [Integer]
|
|
156
|
+
# @return [String]
|
|
157
|
+
def indent(text, level: 1, spaces: 2)
|
|
158
|
+
pad = ' ' * (level.to_i * spaces.to_i)
|
|
159
|
+
text.to_s.split(/\r?\n/).map { |l| pad + l }.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# --- Path helpers ------------------------------------------------------
|
|
163
|
+
# Expand a path (supporting ~).
|
|
164
|
+
# @param path [String]
|
|
165
|
+
# @return [String]
|
|
166
|
+
def expand(path)
|
|
167
|
+
File.expand_path(path.to_s)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Render a relative path from base (defaults to Dir.pwd).
|
|
171
|
+
# @param path [String]
|
|
172
|
+
# @param base [String]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def rel(path, base: Dir.pwd)
|
|
175
|
+
p = Pathname.new(File.expand_path(path.to_s))
|
|
176
|
+
b = Pathname.new(File.expand_path(base.to_s))
|
|
177
|
+
p.relative_path_from(b).to_s
|
|
178
|
+
rescue ArgumentError
|
|
179
|
+
p.to_s
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Safe read file contents; returns nil if missing/unreadable.
|
|
183
|
+
# @param path [String]
|
|
184
|
+
# @return [String, nil]
|
|
185
|
+
def safe_read(path)
|
|
186
|
+
p = path.to_s
|
|
187
|
+
return nil unless File.file?(p)
|
|
188
|
+
|
|
189
|
+
File.read(p)
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Generate a tmp file path (not created).
|
|
195
|
+
# @param prefix [String]
|
|
196
|
+
# @return [String]
|
|
197
|
+
def tmp_path(prefix: 'se')
|
|
198
|
+
t = Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
199
|
+
rand_s = rand(36 ** 6).to_s(36)
|
|
200
|
+
File.join(Dir.tmpdir, "search_engine-#{prefix}-#{t}-#{rand_s}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- Internals ---------------------------------------------------------
|
|
204
|
+
def colorize(text, code)
|
|
205
|
+
"\e[#{code}m#{text}\e[0m"
|
|
206
|
+
end
|
|
207
|
+
private_class_method :colorize
|
|
208
|
+
|
|
209
|
+
def truthy_env?(val)
|
|
210
|
+
return false if val.nil?
|
|
211
|
+
|
|
212
|
+
%w[1 true yes on].include?(val.to_s.strip.downcase)
|
|
213
|
+
end
|
|
214
|
+
private_class_method :truthy_env?
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'search_engine/cli/support'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
# Internal helpers for operator-facing CLI/Rake tasks.
|
|
7
|
+
#
|
|
8
|
+
# This module is intentionally minimal and only used by task definitions
|
|
9
|
+
# located under `lib/tasks/search_engine.rake`. It avoids changing the
|
|
10
|
+
# library require graph by being required from the Rake file.
|
|
11
|
+
module Cli
|
|
12
|
+
class << self
|
|
13
|
+
# Resolve a collection argument into a model class.
|
|
14
|
+
#
|
|
15
|
+
# Attempts to constantize when the input looks like a class name; falls back
|
|
16
|
+
# to the registry via {SearchEngine.collection_for} for logical identifiers.
|
|
17
|
+
#
|
|
18
|
+
# @param arg [#to_s] collection argument (e.g., "SearchEngine::Product" or "products")
|
|
19
|
+
# @return [Class] subclass of {SearchEngine::Base}
|
|
20
|
+
# @raise [ArgumentError] when resolution fails
|
|
21
|
+
def resolve_collection!(arg)
|
|
22
|
+
input = arg.to_s
|
|
23
|
+
raise ArgumentError, 'collection argument required' if input.strip.empty?
|
|
24
|
+
|
|
25
|
+
klass = try_constantize(input)
|
|
26
|
+
klass ||= safe_collection_for(input)
|
|
27
|
+
|
|
28
|
+
unless klass.ancestors.include?(SearchEngine::Base)
|
|
29
|
+
raise ArgumentError, "#{klass.name} must inherit from SearchEngine::Base"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
klass
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse a partition argument into a typed value.
|
|
36
|
+
#
|
|
37
|
+
# Numeric strings are converted to Integer; blank values return nil.
|
|
38
|
+
# Any other value is returned as-is (String).
|
|
39
|
+
#
|
|
40
|
+
# @param arg [Object]
|
|
41
|
+
# @return [Integer, String, nil]
|
|
42
|
+
def parse_partition(arg)
|
|
43
|
+
return nil if arg.nil?
|
|
44
|
+
|
|
45
|
+
str = arg.to_s
|
|
46
|
+
return nil if str.strip.empty? || str.strip.casecmp('nil').zero?
|
|
47
|
+
|
|
48
|
+
return Integer(str) if str.match?(/\A-?\d+\z/)
|
|
49
|
+
|
|
50
|
+
str
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
str
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run a task with small, structured instrumentation.
|
|
56
|
+
#
|
|
57
|
+
# Emits three events when ActiveSupport::Notifications is available:
|
|
58
|
+
# - `search_engine.cli.started`
|
|
59
|
+
# - `search_engine.cli.finished`
|
|
60
|
+
# - `search_engine.cli.error`
|
|
61
|
+
#
|
|
62
|
+
# @param task [String] task name (e.g., "schema:diff")
|
|
63
|
+
# @param payload [Hash] small JSON-safe payload (e.g., { collection: "products" })
|
|
64
|
+
# @yield executes the task body
|
|
65
|
+
# @return [Object] the block's return value
|
|
66
|
+
def with_task_instrumentation(task, payload = {})
|
|
67
|
+
started = monotonic_ms
|
|
68
|
+
instrument('search_engine.cli.started', payload.merge(task: task))
|
|
69
|
+
result = yield
|
|
70
|
+
duration = (monotonic_ms - started).round(1)
|
|
71
|
+
instrument('search_engine.cli.finished', payload.merge(task: task, duration_ms: duration, status: 'ok'))
|
|
72
|
+
result
|
|
73
|
+
rescue StandardError => error
|
|
74
|
+
duration = (monotonic_ms - started).round(1)
|
|
75
|
+
instrument(
|
|
76
|
+
'search_engine.cli.error',
|
|
77
|
+
payload.merge(task: task, duration_ms: duration, status: 'error', error_class: error.class.name,
|
|
78
|
+
message_truncated: error.message.to_s[0, 200]
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Resolve the effective dispatch mode from ENV override or config.
|
|
85
|
+
# @param env_value [String, Symbol, nil]
|
|
86
|
+
# @return [Symbol] :inline or :active_job (falls back to :inline if AJ unavailable)
|
|
87
|
+
def resolve_dispatch_mode(env_value)
|
|
88
|
+
val = (env_value || ENV['DISPATCH'] || SearchEngine.config.indexer.dispatch || :inline).to_s.downcase
|
|
89
|
+
case val
|
|
90
|
+
when 'active_job', 'activejob', 'aj'
|
|
91
|
+
return :active_job if defined?(::ActiveJob::Base)
|
|
92
|
+
end
|
|
93
|
+
:inline
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Return true when ENV[name] is a truthy flag (1/true/yes/on).
|
|
97
|
+
# @param name [String]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def boolean_env?(name)
|
|
100
|
+
SearchEngine::Cli::Support.boolean_env?(name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Whether JSON output is requested via FORMAT=json.
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def json_output?
|
|
106
|
+
SearchEngine::Cli::Support.json_output?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build an Enumerator that yields a single mapped documents batch for dry-run preview.
|
|
110
|
+
#
|
|
111
|
+
# @param klass [Class]
|
|
112
|
+
# @param partition [Object, nil]
|
|
113
|
+
# @return [Enumerable<Array<Hash>>>]
|
|
114
|
+
# @raise [ArgumentError] when mapper/source is missing
|
|
115
|
+
def docs_enum_for_first_batch(klass, partition)
|
|
116
|
+
mapper = SearchEngine::Mapper.for(klass)
|
|
117
|
+
raise ArgumentError, "mapper is not defined for #{klass.name}" unless mapper
|
|
118
|
+
|
|
119
|
+
rows_enum = rows_enumerator_for(klass, partition)
|
|
120
|
+
first_rows = next_from_enum(rows_enum)
|
|
121
|
+
first_rows ||= []
|
|
122
|
+
|
|
123
|
+
docs, _report = mapper.map_batch!(first_rows, batch_index: 0)
|
|
124
|
+
[docs]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Resolve the physical collection name to import into, mirroring Indexer semantics.
|
|
128
|
+
#
|
|
129
|
+
# @param klass [Class]
|
|
130
|
+
# @param partition [Object, nil]
|
|
131
|
+
# @param into [String, nil]
|
|
132
|
+
# @return [String]
|
|
133
|
+
def resolve_into!(klass, partition: nil, into: nil)
|
|
134
|
+
return into if into && !into.to_s.strip.empty?
|
|
135
|
+
|
|
136
|
+
resolver = SearchEngine.config.partitioning&.default_into_resolver
|
|
137
|
+
if resolver.respond_to?(:arity)
|
|
138
|
+
case resolver.arity
|
|
139
|
+
when 1
|
|
140
|
+
val = resolver.call(klass)
|
|
141
|
+
return val if val && !val.to_s.strip.empty?
|
|
142
|
+
when 2, -1
|
|
143
|
+
val = resolver.call(klass, partition)
|
|
144
|
+
return val if val && !val.to_s.strip.empty?
|
|
145
|
+
end
|
|
146
|
+
elsif resolver
|
|
147
|
+
val = resolver.call(klass)
|
|
148
|
+
return val if val && !val.to_s.strip.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Fallback to logical name (alias)
|
|
152
|
+
if klass.respond_to?(:collection)
|
|
153
|
+
klass.collection.to_s
|
|
154
|
+
else
|
|
155
|
+
klass.name.to_s
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Enumerate partitions if DSL is present; otherwise a single nil partition.
|
|
160
|
+
# @param klass [Class]
|
|
161
|
+
# @return [Enumerable]
|
|
162
|
+
def partitions_for(klass)
|
|
163
|
+
compiled = SearchEngine::Partitioner.for(klass)
|
|
164
|
+
return [nil] unless compiled
|
|
165
|
+
|
|
166
|
+
compiled.partitions
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def try_constantize(input)
|
|
172
|
+
return nil unless looks_like_constant?(input)
|
|
173
|
+
|
|
174
|
+
Object.const_get(input)
|
|
175
|
+
rescue NameError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def looks_like_constant?(str)
|
|
180
|
+
str.include?('::') || str[0] =~ /[A-Z]/
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def safe_collection_for(name)
|
|
184
|
+
SearchEngine.collection_for(name)
|
|
185
|
+
rescue ArgumentError
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"Unknown collection '#{name}'. Provide a fully qualified class name or a registered collection."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def rows_enumerator_for(klass, partition)
|
|
191
|
+
compiled_partitioner = SearchEngine::Partitioner.for(klass)
|
|
192
|
+
return compiled_partitioner.partition_fetch_enum(partition) if compiled_partitioner
|
|
193
|
+
|
|
194
|
+
dsl = klass.instance_variable_get(:@__mapper_dsl__) if klass.instance_variable_defined?(:@__mapper_dsl__)
|
|
195
|
+
source_def = dsl && dsl[:source]
|
|
196
|
+
unless source_def
|
|
197
|
+
raise ArgumentError, 'No partition_fetch defined and no source adapter provided. Define one in the DSL.'
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
adapter = SearchEngine::Sources.build(source_def[:type], **(source_def[:options] || {}), &source_def[:block])
|
|
201
|
+
adapter.each_batch(partition: partition)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def next_from_enum(enum)
|
|
205
|
+
return enum.first if enum.respond_to?(:first)
|
|
206
|
+
|
|
207
|
+
e = enum.to_enum
|
|
208
|
+
e.next
|
|
209
|
+
rescue StopIteration
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def instrument(event, payload)
|
|
214
|
+
SearchEngine::Instrumentation.instrument(event, payload) {}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def monotonic_ms
|
|
218
|
+
SearchEngine::Instrumentation.monotonic_ms
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Client
|
|
5
|
+
# HttpAdapter is a minimal transport wrapper around the injected Typesense::Client.
|
|
6
|
+
#
|
|
7
|
+
# Provides a very small surface to execute normalized requests produced by
|
|
8
|
+
# {SearchEngine::Client::RequestBuilder}, delegating to the official Typesense
|
|
9
|
+
# client without re-implementing low-level HTTP. It stays transport-only and
|
|
10
|
+
# does not parse or coerce responses beyond returning a simple Response struct.
|
|
11
|
+
#
|
|
12
|
+
# @see SearchEngine::Client::RequestBuilder
|
|
13
|
+
# @see `https://typesense.org/docs/latest/api/`
|
|
14
|
+
class HttpAdapter
|
|
15
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
TYPESENSE_API_KEY_HEADER = 'X-TYPESENSE-API-KEY'
|
|
18
|
+
|
|
19
|
+
# Initialize with an injected Typesense client instance.
|
|
20
|
+
# @param typesense_client [Object] an instance compatible with Typesense::Client
|
|
21
|
+
# @return [void]
|
|
22
|
+
def initialize(typesense_client)
|
|
23
|
+
@typesense = typesense_client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Execute a normalized request produced by {SearchEngine::Client::RequestBuilder}.
|
|
27
|
+
#
|
|
28
|
+
# Delegates to the upstream Typesense client where possible, returning a
|
|
29
|
+
# transport-level {Response}. This adapter does not perform parsing or
|
|
30
|
+
# symbolization and intentionally exposes the raw upstream value in
|
|
31
|
+
# {Response#body}.
|
|
32
|
+
#
|
|
33
|
+
# Supported forms (current usage):
|
|
34
|
+
# - POST `/collections/:collection/documents/search` (with `common_params`)
|
|
35
|
+
#
|
|
36
|
+
# @param request [SearchEngine::Client::RequestBuilder::Request] normalized request
|
|
37
|
+
# @return [Response] response wrapper with status, headers and raw body
|
|
38
|
+
# @raise [ArgumentError] when the request path is not supported by this adapter
|
|
39
|
+
# @see `https://typesense.org/docs/latest/api/documents.html#search-document`
|
|
40
|
+
def perform(request)
|
|
41
|
+
method = request.http_method.to_sym
|
|
42
|
+
path = request.path.to_s
|
|
43
|
+
if method == :post && path.match?(%r{\A/collections/[^/]+/documents/search\z})
|
|
44
|
+
collection = path.split('/')[2]
|
|
45
|
+
docs = @typesense.collections[collection].documents
|
|
46
|
+
begin
|
|
47
|
+
params = docs.method(:search).parameters
|
|
48
|
+
supports_common = params.any? { |(kind, _)| %i[key keyreq keyrest].include?(kind) }
|
|
49
|
+
rescue StandardError
|
|
50
|
+
supports_common = false
|
|
51
|
+
end
|
|
52
|
+
raw = supports_common ? docs.search(request.body, common_params: request.params) : docs.search(request.body)
|
|
53
|
+
# The official client returns parsed JSON (Hash). No headers/status exposed here.
|
|
54
|
+
return Response.new(status: 200, headers: {}, body: raw)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Fallback: attempt a generic call via low-level typesense endpoint access if available.
|
|
58
|
+
# This keeps adapter permissive for future endpoints without adding Faraday here.
|
|
59
|
+
raise ArgumentError, "Unsupported request path for adapter: #{request.method} #{request.path}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Client
|
|
5
|
+
# RequestBuilder maps compiled params to concrete Typesense requests.
|
|
6
|
+
# It has no network concerns; it only assembles the request object.
|
|
7
|
+
#
|
|
8
|
+
# Produces a normalized {Request} that can be executed by
|
|
9
|
+
# {SearchEngine::Client::HttpAdapter} or directly by the client methods.
|
|
10
|
+
# Sanitizes internal-only keys before encoding to ensure the payload is
|
|
11
|
+
# compatible with the Typesense API.
|
|
12
|
+
#
|
|
13
|
+
# @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/client`
|
|
14
|
+
# @see `https://typesense.org/docs/latest/api/`
|
|
15
|
+
class RequestBuilder
|
|
16
|
+
# Normalized request container used by the transport adapter.
|
|
17
|
+
# @!attribute http_method [Symbol] one of :get, :post, :put, :delete
|
|
18
|
+
# @!attribute path [String] absolute path (no host), e.g. "/collections/products/documents/search"
|
|
19
|
+
# @!attribute params [Hash] URL/common params (e.g., { use_cache:, cache_ttl: })
|
|
20
|
+
# @!attribute headers [Hash] per-request headers (adapter may add global ones)
|
|
21
|
+
# @!attribute body [Hash, nil] JSON body as a Ruby Hash
|
|
22
|
+
# @!attribute body_json [String, nil] Deterministic JSON representation of body
|
|
23
|
+
Request = Struct.new(:http_method, :path, :params, :headers, :body, :body_json, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
COLLECTIONS_PREFIX = '/collections/'
|
|
26
|
+
DOCUMENTS_SEARCH_SUFFIX = '/documents/search'
|
|
27
|
+
# Additional endpoint fragments for internal reuse
|
|
28
|
+
COLLECTIONS_ROOT = '/collections'
|
|
29
|
+
DOCUMENTS_SUFFIX = '/documents'
|
|
30
|
+
DOCUMENTS_IMPORT_SUFFIX = '/documents/import'
|
|
31
|
+
ALIASES_PREFIX = '/aliases/'
|
|
32
|
+
SYNONYMS_SUFFIX = '/synonyms'
|
|
33
|
+
SYNONYMS_PREFIX = '/synonyms/'
|
|
34
|
+
STOPWORDS_SUFFIX = '/stopwords'
|
|
35
|
+
STOPWORDS_PREFIX = '/stopwords/'
|
|
36
|
+
HEALTH_PATH = '/health'
|
|
37
|
+
|
|
38
|
+
CONTENT_TYPE_JSON = 'application/json'
|
|
39
|
+
DEFAULT_HEADERS_JSON = { 'Content-Type' => CONTENT_TYPE_JSON }.freeze
|
|
40
|
+
|
|
41
|
+
# Centralized doc links used by client error mapping
|
|
42
|
+
DOC_CLIENT_ERRORS = 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/client#errors'
|
|
43
|
+
|
|
44
|
+
INTERNAL_ONLY_KEYS = %i[
|
|
45
|
+
_join
|
|
46
|
+
_selection
|
|
47
|
+
_preset_mode
|
|
48
|
+
_preset_pruned_keys
|
|
49
|
+
_preset_conflicts
|
|
50
|
+
_curation_conflict_type
|
|
51
|
+
_curation_conflict_count
|
|
52
|
+
_runtime_flags
|
|
53
|
+
_hits
|
|
54
|
+
_curation
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Build a single-search request for a collection.
|
|
58
|
+
#
|
|
59
|
+
# @param collection [String] logical collection name (alias)
|
|
60
|
+
# @param compiled_params [SearchEngine::CompiledParams] sanitized, deterministic params
|
|
61
|
+
# @param url_opts [Hash] URL/common params such as cache controls
|
|
62
|
+
# @return [Request] normalized request ready for transport
|
|
63
|
+
# @see `https://typesense.org/docs/latest/api/documents.html#search-document`
|
|
64
|
+
def self.build_search(collection:, compiled_params:, url_opts: {})
|
|
65
|
+
name = collection.to_s
|
|
66
|
+
body_hash = sanitize_body_params(compiled_params.to_h)
|
|
67
|
+
# Ensure deterministic ordering even after sanitization
|
|
68
|
+
body_json = SearchEngine::CompiledParams.from(body_hash).to_json
|
|
69
|
+
|
|
70
|
+
Request.new(
|
|
71
|
+
http_method: :post,
|
|
72
|
+
path: COLLECTIONS_PREFIX + name + DOCUMENTS_SEARCH_SUFFIX,
|
|
73
|
+
params: url_opts || {},
|
|
74
|
+
headers: DEFAULT_HEADERS_JSON,
|
|
75
|
+
body: body_hash,
|
|
76
|
+
body_json: body_json
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Remove internal-only keys from the HTTP payload (copied from previous client behavior).
|
|
81
|
+
#
|
|
82
|
+
# @param params [Hash] possibly containing internal keys
|
|
83
|
+
# @return [Hash] new Hash without internal-only keys
|
|
84
|
+
def self.sanitize_body_params(params)
|
|
85
|
+
payload = params.dup
|
|
86
|
+
INTERNAL_ONLY_KEYS.each { |k| payload.delete(k) }
|
|
87
|
+
payload
|
|
88
|
+
end
|
|
89
|
+
private_class_method :sanitize_body_params
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Client
|
|
5
|
+
module Services
|
|
6
|
+
# Shared helpers for service objects that delegate to {SearchEngine::Client}.
|
|
7
|
+
#
|
|
8
|
+
# Provides convenient access to the parent client and its private helpers
|
|
9
|
+
# without widening the public API surface of the client itself.
|
|
10
|
+
class Base
|
|
11
|
+
# @param client [SearchEngine::Client]
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :client
|
|
19
|
+
|
|
20
|
+
def config
|
|
21
|
+
client.__send__(:config)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def typesense
|
|
25
|
+
client.__send__(:typesense)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def typesense_for_import
|
|
29
|
+
client.__send__(:typesense_for_import)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_typesense_client_with_read_timeout(seconds)
|
|
33
|
+
client.__send__(:build_typesense_client_with_read_timeout, seconds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_typesense_client
|
|
37
|
+
client.__send__(:build_typesense_client)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def with_exception_mapping(*args, &block)
|
|
41
|
+
client.__send__(:with_exception_mapping, *args, &block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def instrument(*args)
|
|
45
|
+
client.__send__(:instrument, *args)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def log_success(*args)
|
|
49
|
+
client.__send__(:log_success, *args)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def current_monotonic_ms
|
|
53
|
+
client.__send__(:current_monotonic_ms)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def derive_cache_opts(url_opts)
|
|
57
|
+
client.__send__(:derive_cache_opts, url_opts)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def symbolize_keys_deep(payload)
|
|
61
|
+
client.__send__(:symbolize_keys_deep, payload)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_single!(*args)
|
|
65
|
+
client.__send__(:validate_single!, *args)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_multi!(*args)
|
|
69
|
+
client.__send__(:validate_multi!, *args)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|