search-engine-for-typesense 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,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