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,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
class Base
|
|
7
|
+
# Pretty printing and inspect helpers for console output.
|
|
8
|
+
# Extracted from Base with identical behavior.
|
|
9
|
+
module PrettyPrinter
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
include SearchEngine::Base::DisplayCoercions
|
|
13
|
+
|
|
14
|
+
# Human-friendly inspect that lists declared attributes and, when present,
|
|
15
|
+
# unknown attributes captured during hydration.
|
|
16
|
+
# @return [String]
|
|
17
|
+
def inspect
|
|
18
|
+
pairs = __attribute_pairs_for_render
|
|
19
|
+
hex_id = begin
|
|
20
|
+
# Mimic Ruby's default hex object id formatting
|
|
21
|
+
format('0x%014x', object_id << 1)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
object_id
|
|
24
|
+
end
|
|
25
|
+
return "#<#{self.class.name}:#{hex_id}>" if pairs.empty?
|
|
26
|
+
|
|
27
|
+
lines = pairs.map do |(k, v)|
|
|
28
|
+
rendered = if v.is_a?(Array) || v.is_a?(Hash)
|
|
29
|
+
__se_symbolize_for_inspect(v).inspect
|
|
30
|
+
else
|
|
31
|
+
v.inspect
|
|
32
|
+
end
|
|
33
|
+
"#{k}: #{rendered}"
|
|
34
|
+
end
|
|
35
|
+
"#<#{self.class.name}:#{hex_id}\n #{lines.join(",\n ")}>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Pretty-print with attributes on separate lines for readability in consoles.
|
|
39
|
+
# Integrates with PP so arrays of models render multiline.
|
|
40
|
+
# @param pp [PP]
|
|
41
|
+
# @return [void]
|
|
42
|
+
def pretty_print(pp)
|
|
43
|
+
hex_id = begin
|
|
44
|
+
format('0x%014x', object_id << 1)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
object_id
|
|
47
|
+
end
|
|
48
|
+
pairs = __attribute_pairs_for_render
|
|
49
|
+
pp.group(2, "#<#{self.class.name}:#{hex_id} ", '>') do
|
|
50
|
+
if pairs.empty?
|
|
51
|
+
pp.breakable ''
|
|
52
|
+
else
|
|
53
|
+
pp.breakable ''
|
|
54
|
+
pairs.each_with_index do |(k, v), idx|
|
|
55
|
+
if v.is_a?(Array) || v.is_a?(Hash)
|
|
56
|
+
pp.text("#{k}:")
|
|
57
|
+
pp.nest(2) do
|
|
58
|
+
pp.breakable ' '
|
|
59
|
+
pp.pp(__se_symbolize_for_inspect(v))
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
pp.text("#{k}: ")
|
|
63
|
+
pp.pp(v)
|
|
64
|
+
end
|
|
65
|
+
if idx < pairs.length - 1
|
|
66
|
+
pp.text(',')
|
|
67
|
+
pp.breakable ' '
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Build ordered list of attribute pairs for rendering:
|
|
77
|
+
# - Declared attributes in declaration order (with id rendered first when present)
|
|
78
|
+
# - Followed by unknown attributes (when present)
|
|
79
|
+
# @return [Array<[String, Object]>]
|
|
80
|
+
def __attribute_pairs_for_render
|
|
81
|
+
declared = __se_declared_attributes
|
|
82
|
+
pairs = []
|
|
83
|
+
|
|
84
|
+
__se_append_declared_id!(pairs, declared)
|
|
85
|
+
__se_render_present_declared_attributes!(pairs, declared)
|
|
86
|
+
|
|
87
|
+
join_names = __se_declared_join_names
|
|
88
|
+
__se_render_present_declared_joins!(pairs, join_names)
|
|
89
|
+
|
|
90
|
+
__se_append_unknown_attribute_pairs(pairs, declared)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Return declared attribute map with resilience to missing APIs.
|
|
94
|
+
def __se_declared_attributes
|
|
95
|
+
self.class.respond_to?(:attributes) ? (self.class.attributes || {}) : {}
|
|
96
|
+
rescue StandardError
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Render id first if declared and present in the hydrated document
|
|
101
|
+
def __se_append_declared_id!(pairs, declared)
|
|
102
|
+
return unless declared.key?(:id) && instance_variable_defined?('@id')
|
|
103
|
+
|
|
104
|
+
pairs << ['id', __se_coerce_id_for_display(instance_variable_get('@id'))]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Render only declared attributes that were present in the hydrated document
|
|
108
|
+
def __se_render_present_declared_attributes!(pairs, declared)
|
|
109
|
+
declared.each_key do |name|
|
|
110
|
+
next if name.to_s == 'id'
|
|
111
|
+
|
|
112
|
+
begin
|
|
113
|
+
next unless self.class.respond_to?(:valid_attribute_reader_name?) &&
|
|
114
|
+
self.class.valid_attribute_reader_name?(name)
|
|
115
|
+
rescue StandardError
|
|
116
|
+
next if name.to_s.include?('.')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
ivar_name = "@#{name}"
|
|
120
|
+
next unless instance_variable_defined?(ivar_name)
|
|
121
|
+
|
|
122
|
+
raw_value = instance_variable_get(ivar_name)
|
|
123
|
+
value_for_render = if name.to_s == 'doc_updated_at' && !raw_value.nil?
|
|
124
|
+
__se_coerce_doc_updated_at_for_display(raw_value)
|
|
125
|
+
else
|
|
126
|
+
raw_value
|
|
127
|
+
end
|
|
128
|
+
pairs << [name.to_s, value_for_render]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Collect declared join names as strings.
|
|
133
|
+
def __se_declared_join_names
|
|
134
|
+
joins = self.class.respond_to?(:joins_config) ? (self.class.joins_config || {}) : {}
|
|
135
|
+
joins.keys.map(&:to_s)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Render declared joined attributes that were present in the hydrated document
|
|
141
|
+
def __se_render_present_declared_joins!(pairs, join_names)
|
|
142
|
+
join_names.each do |join_name|
|
|
143
|
+
ivar_name = "@#{join_name}"
|
|
144
|
+
next unless instance_variable_defined?(ivar_name)
|
|
145
|
+
|
|
146
|
+
value = instance_variable_get(ivar_name)
|
|
147
|
+
next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
148
|
+
|
|
149
|
+
pairs << [join_name, value]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Group "$assoc.field" unknown attributes into a nested Hash under "$assoc" for rendering.
|
|
154
|
+
# Prefers existing shapes when "$assoc" key already exists in the payload (Hash/Array).
|
|
155
|
+
# Returns [passthrough(Map<key->val>), grouped(Map<assoc->Hash>), assoc_order(Array<String>)].
|
|
156
|
+
def __se_group_join_fields_for_render(extras)
|
|
157
|
+
grouped = {}
|
|
158
|
+
assoc_order = []
|
|
159
|
+
passthrough = {}
|
|
160
|
+
|
|
161
|
+
extras.each do |k, v|
|
|
162
|
+
key = k.to_s
|
|
163
|
+
if key.start_with?('$') && key.include?('.') && !v.is_a?(Hash) && !v.is_a?(Array)
|
|
164
|
+
assoc_key, field = key.split('.', 2)
|
|
165
|
+
assoc = assoc_key.delete_prefix('$')
|
|
166
|
+
# Respect existing nested shape if present
|
|
167
|
+
if extras.key?(assoc)
|
|
168
|
+
passthrough[key] = v
|
|
169
|
+
else
|
|
170
|
+
unless grouped.key?(assoc)
|
|
171
|
+
grouped[assoc] = {}
|
|
172
|
+
assoc_order << assoc
|
|
173
|
+
end
|
|
174
|
+
grouped[assoc][field] = v
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
passthrough[key] = v
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
[passthrough, grouped, assoc_order]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Append unknown attributes, grouping join fields and preserving nested shapes.
|
|
185
|
+
def __se_append_unknown_attribute_pairs(pairs, declared)
|
|
186
|
+
extras = unknown_attributes
|
|
187
|
+
return pairs if extras.empty?
|
|
188
|
+
|
|
189
|
+
__se_maybe_render_unknown_id_first!(pairs, declared, extras)
|
|
190
|
+
|
|
191
|
+
selected_nested = __se_selected_nested_assocs_for_render
|
|
192
|
+
__se_render_existing_nested_assoc_pairs!(pairs, extras, selected_nested)
|
|
193
|
+
|
|
194
|
+
passthrough, grouped, assoc_order = __se_group_join_fields_for_render(extras)
|
|
195
|
+
__se_render_grouped_scalar_assoc_pairs!(pairs, assoc_order, grouped)
|
|
196
|
+
__se_render_passthrough_unknowns!(pairs, passthrough)
|
|
197
|
+
pairs
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Ensure id appears first when not declared but present in unknowns
|
|
201
|
+
def __se_maybe_render_unknown_id_first!(pairs, declared, extras)
|
|
202
|
+
return if declared.key?(:id)
|
|
203
|
+
|
|
204
|
+
id_v = extras['id'] || extras[:id]
|
|
205
|
+
pairs.unshift(['id', __se_coerce_id_for_display(id_v)]) unless id_v.nil?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Return selection context for nested assocs used during render
|
|
209
|
+
# @return [Array<String>]
|
|
210
|
+
def __se_selected_nested_assocs_for_render
|
|
211
|
+
Array(
|
|
212
|
+
instance_variable_defined?(:@__se_selected_nested_assocs__) &&
|
|
213
|
+
instance_variable_get(:@__se_selected_nested_assocs__)
|
|
214
|
+
).map(&:to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Render already nested structures for assoc keys (either "$assoc" or plain assoc)
|
|
218
|
+
def __se_render_existing_nested_assoc_pairs!(pairs, extras, selected_nested)
|
|
219
|
+
declared_joins = begin
|
|
220
|
+
self.class.respond_to?(:joins_config) ? (self.class.joins_config || {}) : {}
|
|
221
|
+
rescue StandardError
|
|
222
|
+
{}
|
|
223
|
+
end
|
|
224
|
+
assoc_names = declared_joins.keys.map(&:to_s)
|
|
225
|
+
|
|
226
|
+
extras.each do |k, v|
|
|
227
|
+
key = k.to_s
|
|
228
|
+
next unless v.is_a?(Array) || v.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
assoc = key.start_with?('$') ? key.delete_prefix('$') : key
|
|
231
|
+
next unless assoc_names.include?(assoc)
|
|
232
|
+
|
|
233
|
+
already = pairs.any? { |(name, _)| name == assoc }
|
|
234
|
+
next if already
|
|
235
|
+
next if selected_nested.any? && !selected_nested.include?(assoc)
|
|
236
|
+
|
|
237
|
+
pairs << [assoc, __se_symbolize_for_inspect(v)]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Render grouped scalar $assoc.field maps under assoc as an array-of-hashes
|
|
242
|
+
def __se_render_grouped_scalar_assoc_pairs!(pairs, assoc_order, grouped)
|
|
243
|
+
assoc_order.each do |assoc|
|
|
244
|
+
next if pairs.any? { |(name, _)| name == assoc }
|
|
245
|
+
|
|
246
|
+
pairs << [assoc.to_s, [__se_symbolize_for_inspect(grouped[assoc])]]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Render remaining passthrough unknowns with special handling for doc_updated_at
|
|
251
|
+
def __se_render_passthrough_unknowns!(pairs, passthrough)
|
|
252
|
+
passthrough.each do |k, v|
|
|
253
|
+
key = k.to_s
|
|
254
|
+
next if key == 'id' || key.start_with?('$', '.')
|
|
255
|
+
# Avoid duplicating assoc entries already rendered
|
|
256
|
+
next if pairs.any? { |(name, _)| name == key }
|
|
257
|
+
|
|
258
|
+
rendered = key == 'doc_updated_at' && !v.nil? ? __se_coerce_doc_updated_at_for_display(v) : v
|
|
259
|
+
pairs << [key, rendered]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Symbolize keys for inspect to avoid symbol bloat (deep).
|
|
264
|
+
def __se_symbolize_for_inspect(value)
|
|
265
|
+
case value
|
|
266
|
+
when Array
|
|
267
|
+
value.map { |element| __se_symbolize_for_inspect(element) }
|
|
268
|
+
when Hash
|
|
269
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
270
|
+
key = k.is_a?(String) || k.is_a?(Symbol) ? k.to_sym : k
|
|
271
|
+
acc[key] = __se_symbolize_for_inspect(v)
|
|
272
|
+
end
|
|
273
|
+
else
|
|
274
|
+
value
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Coerce id for display: if it's a numeric-looking string and model's id source is integer-like,
|
|
279
|
+
# render as Integer; otherwise render as-is. This does not mutate underlying value.
|
|
280
|
+
def __se_coerce_id_for_display(value)
|
|
281
|
+
v = value
|
|
282
|
+
return v if v.nil?
|
|
283
|
+
|
|
284
|
+
# Determine if this collection is ActiveRecord-sourced with integer/bigint PK and no custom identify_by
|
|
285
|
+
begin
|
|
286
|
+
dsl = self.class.instance_variable_get(:@__mapper_dsl__)
|
|
287
|
+
src = dsl&.dig(:source, :type)
|
|
288
|
+
if src.to_s == 'active_record' && !self.class.instance_variable_defined?(:@identify_by_proc)
|
|
289
|
+
model = dsl&.dig(:source, :options, :model)
|
|
290
|
+
if model.respond_to?(:columns_hash)
|
|
291
|
+
pk = begin
|
|
292
|
+
model.primary_key
|
|
293
|
+
rescue StandardError
|
|
294
|
+
'id'
|
|
295
|
+
end
|
|
296
|
+
col = begin
|
|
297
|
+
model.columns_hash[pk.to_s]
|
|
298
|
+
rescue StandardError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
if col && %i[integer bigint].include?(col.type) && v.is_a?(String) && v.match?(/^[-+]?\d+$/)
|
|
302
|
+
# Try to display numeric-looking strings as Integer
|
|
303
|
+
return Integer(v)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
rescue StandardError
|
|
308
|
+
# fall through
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
v
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
class Base
|
|
7
|
+
# Delegates class-level query methods to `.all` relation instance.
|
|
8
|
+
module RelationDelegation
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
# Return a fresh, immutable relation bound to this model class.
|
|
13
|
+
# @return [SearchEngine::Relation]
|
|
14
|
+
def all
|
|
15
|
+
SearchEngine::Relation.new(self)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Delegate materializers and query dsl to `.all` so callers can do `Model.first` etc.
|
|
19
|
+
%i[
|
|
20
|
+
where rewhere merge order preset ranking prefix search
|
|
21
|
+
pin hide curate clear_curation
|
|
22
|
+
facet_by facet_query group_by unscope
|
|
23
|
+
limit offset page per_page per options cache
|
|
24
|
+
joins use_synonyms use_stopwords
|
|
25
|
+
select include_fields exclude reselect
|
|
26
|
+
limit_hits validate_hits!
|
|
27
|
+
first last take pluck pick exists? count find_by all!
|
|
28
|
+
delete_all update_all
|
|
29
|
+
raw
|
|
30
|
+
].each { |method| delegate method, to: :all }
|
|
31
|
+
|
|
32
|
+
# Find a record by its document id (model-level only).
|
|
33
|
+
# Equivalent to `find_by(id: id)`.
|
|
34
|
+
# @param id [#to_s] Document id
|
|
35
|
+
# @return [Object, nil]
|
|
36
|
+
def find(id)
|
|
37
|
+
find_by(id: id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
class Base
|
|
7
|
+
# ActiveRecord-like named scopes for SearchEngine models.
|
|
8
|
+
#
|
|
9
|
+
# Scopes are defined on the model class and must return a
|
|
10
|
+
# {SearchEngine::Relation}. They are evaluated against a fresh
|
|
11
|
+
# relation (`all`) and are therefore fully chainable.
|
|
12
|
+
#
|
|
13
|
+
# Examples:
|
|
14
|
+
# class Product < SearchEngine::Base
|
|
15
|
+
# scope :active, -> { where(active: true) }
|
|
16
|
+
# scope :by_store, ->(id) { where(store_id: id) }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Product.active.by_store(1).search("shoes")
|
|
20
|
+
module Scopes
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
class_methods do
|
|
24
|
+
# Internal registry of declared scopes for this model.
|
|
25
|
+
# Used to apply scopes against an existing Relation (AR parity).
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
# @return [Hash{Symbol=>Proc}]
|
|
29
|
+
def __search_engine_scope_registry__
|
|
30
|
+
@__search_engine_scope_registry__ ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Normalize scope arguments to preserve Ruby 3 keyword behavior.
|
|
34
|
+
# When the scope expects keyword args only, accept a single Hash
|
|
35
|
+
# positional argument by converting it into kwargs.
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
# @return [Array<Array, Hash>] normalized [args, kwargs]
|
|
39
|
+
def __se_normalize_scope_args(impl, args, kwargs)
|
|
40
|
+
return [args, kwargs] unless kwargs.empty? && args.length == 1 && args.first.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
params = impl.parameters
|
|
43
|
+
expects_keywords = params.any? { |(type, _)| %i[key keyreq keyrest].include?(type) }
|
|
44
|
+
expects_positional = params.any? { |(type, _)| %i[req opt rest].include?(type) }
|
|
45
|
+
return [args, kwargs] if expects_positional || !expects_keywords
|
|
46
|
+
|
|
47
|
+
raw = args.first
|
|
48
|
+
coerced = raw.each_with_object({}) do |(k, v), acc|
|
|
49
|
+
key = k.respond_to?(:to_sym) ? k.to_sym : k
|
|
50
|
+
acc[key] = v
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
[[], coerced]
|
|
54
|
+
end
|
|
55
|
+
private :__se_normalize_scope_args
|
|
56
|
+
|
|
57
|
+
# Define a named, chainable scope.
|
|
58
|
+
#
|
|
59
|
+
# @param name [#to_sym] public method name for the scope
|
|
60
|
+
# @param body [#call, nil] a Proc/lambda evaluated against a fresh Relation
|
|
61
|
+
# @yield evaluated as the scope body when +body+ is nil (AR-style)
|
|
62
|
+
# @return [void]
|
|
63
|
+
#
|
|
64
|
+
# The scope body is executed with `self` set to a fresh
|
|
65
|
+
# {SearchEngine::Relation} bound to the model. It must return a
|
|
66
|
+
# Relation (or nil, which is treated as `all`).
|
|
67
|
+
#
|
|
68
|
+
# Reserved names: scope names must not conflict with core query or
|
|
69
|
+
# materializer methods (e.g., :all, :first, :last, :find_by, :pluck, :pick).
|
|
70
|
+
def scope(name, body = nil, &block)
|
|
71
|
+
raise ArgumentError, 'scope requires a name' if name.nil?
|
|
72
|
+
|
|
73
|
+
impl = body || block
|
|
74
|
+
raise ArgumentError, 'scope requires a callable (Proc/lambda)' if impl.nil? || !impl.respond_to?(:call)
|
|
75
|
+
|
|
76
|
+
method_name = name.to_sym
|
|
77
|
+
|
|
78
|
+
# Avoid overriding core query methods and relation materializers.
|
|
79
|
+
reserved = %i[
|
|
80
|
+
all first last take count exists? find find_by pluck pick delete_all update_all
|
|
81
|
+
where rewhere merge order select include_fields exclude reselect joins preset ranking prefix search
|
|
82
|
+
limit offset page per_page per options cache
|
|
83
|
+
]
|
|
84
|
+
if reserved.include?(method_name)
|
|
85
|
+
raise ArgumentError, "scope :#{method_name} conflicts with a reserved query method"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
define_singleton_method(method_name) do |*args, **kwargs, &_unused_block|
|
|
89
|
+
base = all
|
|
90
|
+
|
|
91
|
+
# Evaluate scope body directly against the fresh relation, so `self`
|
|
92
|
+
# inside the scope is a Relation and chaining behaves predictably.
|
|
93
|
+
norm_args, norm_kwargs = __se_normalize_scope_args(impl, args, kwargs)
|
|
94
|
+
result = base.instance_exec(*norm_args, **norm_kwargs, &impl)
|
|
95
|
+
|
|
96
|
+
# Coerce common mistakes to a usable Relation:
|
|
97
|
+
# - nil (AR parity) -> return a fresh relation
|
|
98
|
+
# - model class returned by accident -> return a fresh relation
|
|
99
|
+
return base if result.nil? || result.equal?(base.klass)
|
|
100
|
+
return result if result.is_a?(SearchEngine::Relation)
|
|
101
|
+
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"scope :#{method_name} must return a SearchEngine::Relation (got #{result.class})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
__search_engine_scope_registry__[method_name] = impl
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
class Base
|
|
7
|
+
# Instance-level updating for a single hydrated record.
|
|
8
|
+
#
|
|
9
|
+
# Provides {#update} that partially updates the current document in the
|
|
10
|
+
# Typesense collection using its document id. The id is obtained from the
|
|
11
|
+
# hydrated payload when available, and falls back to computing it via the
|
|
12
|
+
# class-level `identify_by` strategy.
|
|
13
|
+
module Updating
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
# Partially update this record in the collection.
|
|
17
|
+
#
|
|
18
|
+
# @param attributes [Hash, nil] fields to update (or pass as kwargs)
|
|
19
|
+
# @param into [String, nil] override physical collection name
|
|
20
|
+
# @param partition [Object, nil] partition token for resolvers
|
|
21
|
+
# @param timeout_ms [Integer, nil] optional read timeout override in ms
|
|
22
|
+
# @param cascade [Boolean, nil] when true, trigger cascade reindex for referencing collections
|
|
23
|
+
# @return [Integer] number of updated documents (0 or 1)
|
|
24
|
+
# @raise [SearchEngine::Errors::InvalidParams] when the record id is unavailable
|
|
25
|
+
def update(attributes = nil, into: nil, partition: nil, timeout_ms: nil, cascade: nil, **kwattrs)
|
|
26
|
+
attrs = __se_coalesce_update_attributes(attributes, kwattrs)
|
|
27
|
+
raise SearchEngine::Errors::InvalidParams, 'attributes must be a non-empty Hash' if attrs.nil? || attrs.empty?
|
|
28
|
+
|
|
29
|
+
id_value = __se_effective_document_id_for_update
|
|
30
|
+
if id_value.nil? || id_value.to_s.strip.empty?
|
|
31
|
+
raise SearchEngine::Errors::InvalidParams,
|
|
32
|
+
"Cannot update without document id; include 'id' in selection or provide identifiable attributes"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
collection = SearchEngine::Deletion.resolve_into(
|
|
36
|
+
klass: self.class,
|
|
37
|
+
partition: partition,
|
|
38
|
+
into: into
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
resp = SearchEngine.client.update_document(
|
|
42
|
+
collection: collection,
|
|
43
|
+
id: id_value,
|
|
44
|
+
fields: attrs,
|
|
45
|
+
timeout_ms: timeout_ms
|
|
46
|
+
)
|
|
47
|
+
updated = resp ? 1 : 0
|
|
48
|
+
|
|
49
|
+
# Best-effort cascade when requested
|
|
50
|
+
if updated.positive? && cascade
|
|
51
|
+
begin
|
|
52
|
+
SearchEngine::Cascade.cascade_reindex!(source: self.class, ids: [id_value], context: :update)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# swallow cascade errors to keep update semantics stable
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
updated
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Prefer explicit Hash argument when provided; fall back to kwargs.
|
|
64
|
+
# @param attributes [Hash, nil]
|
|
65
|
+
# @param kwattrs [Hash]
|
|
66
|
+
# @return [Hash, nil]
|
|
67
|
+
def __se_coalesce_update_attributes(attributes, kwattrs)
|
|
68
|
+
return attributes if attributes.is_a?(Hash) && !attributes.empty?
|
|
69
|
+
return kwattrs if kwattrs && !kwattrs.empty?
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Determine the effective document id for update, preferring hydrated
|
|
75
|
+
# `@id` when present and falling back to the class-level identify_by.
|
|
76
|
+
# @return [String, nil]
|
|
77
|
+
def __se_effective_document_id_for_update
|
|
78
|
+
v = instance_variable_defined?(:@id) ? instance_variable_get(:@id) : nil
|
|
79
|
+
return v unless v.nil? || v.to_s.strip.empty?
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
computed = self.class.compute_document_id(self)
|
|
83
|
+
return computed unless computed.nil? || computed.to_s.strip.empty?
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# best-effort; nil means we cannot determine id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base/display_coercions'
|
|
4
|
+
require_relative 'base/hydration'
|
|
5
|
+
require_relative 'base/index_maintenance'
|
|
6
|
+
require_relative 'base/indexing_dsl'
|
|
7
|
+
require_relative 'base/creation'
|
|
8
|
+
require_relative 'base/scopes'
|
|
9
|
+
require_relative 'base/joins'
|
|
10
|
+
require_relative 'base/model_dsl'
|
|
11
|
+
require_relative 'base/presets'
|
|
12
|
+
require_relative 'base/pretty_printer'
|
|
13
|
+
require_relative 'base/updating'
|
|
14
|
+
require_relative 'base/deletion'
|
|
15
|
+
require_relative 'base/relation_delegation'
|
|
16
|
+
|
|
17
|
+
module SearchEngine
|
|
18
|
+
# Base class for SearchEngine models.
|
|
19
|
+
#
|
|
20
|
+
# Provides lightweight macros to declare the backing Typesense collection and
|
|
21
|
+
# a schema-like list of attributes for future hydration. Attributes declared in
|
|
22
|
+
# a parent class are inherited by subclasses. Redefining an attribute in a
|
|
23
|
+
# subclass overwrites only at the subclass level.
|
|
24
|
+
class Base
|
|
25
|
+
include SearchEngine::Base::Hydration
|
|
26
|
+
include SearchEngine::Base::PrettyPrinter
|
|
27
|
+
include SearchEngine::Base::ModelDsl
|
|
28
|
+
include SearchEngine::Base::RelationDelegation
|
|
29
|
+
include SearchEngine::Base::Scopes
|
|
30
|
+
include SearchEngine::Base::IndexingDsl
|
|
31
|
+
include SearchEngine::Base::Joins
|
|
32
|
+
include SearchEngine::Base::Presets
|
|
33
|
+
include SearchEngine::Base::IndexMaintenance
|
|
34
|
+
include SearchEngine::Base::Updating
|
|
35
|
+
include SearchEngine::Base::Deletion
|
|
36
|
+
include SearchEngine::Base::Creation
|
|
37
|
+
end
|
|
38
|
+
end
|