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,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ # Model-level DSL for declaring collections, attributes, and inheritance.
8
+ module ModelDsl
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ # Get or set the Typesense collection name for this model.
13
+ #
14
+ # When setting, the name is normalized to String and the mapping is
15
+ # registered in the global collection registry.
16
+ #
17
+ # @param name [#to_s, nil]
18
+ # @return [String, Class] returns the current collection name when reading;
19
+ # returns self when setting (for macro chaining)
20
+ def collection(name = nil)
21
+ return @collection if name.nil?
22
+
23
+ normalized = name.to_s
24
+ raise ArgumentError, 'collection name must be non-empty' if normalized.strip.empty?
25
+
26
+ @collection = normalized
27
+ SearchEngine.register_collection!(@collection, self)
28
+ self
29
+ end
30
+ end
31
+
32
+ class_methods do
33
+ # Delete documents by filter for this collection's physical index.
34
+ # Accepts either a Typesense filter string (via first arg or :filter_by)
35
+ # or a hash of field=>value which will be converted to a filter string.
36
+ # Supports optional partition to cooperate with default_into_resolver.
37
+ #
38
+ # @param filter_or_str [String, nil]
39
+ # @param filter_by [String, nil]
40
+ # @param into [String, nil]
41
+ # @param partition [Object, nil]
42
+ # @param timeout_ms [Integer, nil]
43
+ # @param hash [Hash]
44
+ # @return [Integer] number of deleted documents
45
+ def delete_by(filter_or_str = nil, into: nil, partition: nil, timeout_ms: nil, filter_by: nil, **hash)
46
+ SearchEngine::Deletion.delete_by(
47
+ klass: self,
48
+ filter: filter_or_str || filter_by,
49
+ hash: (hash.empty? ? nil : hash),
50
+ into: into,
51
+ partition: partition,
52
+ timeout_ms: timeout_ms
53
+ )
54
+ end
55
+ end
56
+
57
+ class_methods do
58
+ # Set or get the per-collection default query_by fields.
59
+ #
60
+ # Accepts a String (comma-separated), a Symbol, or an Array of Strings/Symbols.
61
+ # Values are normalized into a canonical comma-separated String with single
62
+ # spaces after commas (e.g., "name, brand, description"). When called
63
+ # without arguments, returns the canonical String or nil if unset.
64
+ #
65
+ # @param values [Array<String,Symbol,Array>] zero or more field tokens; Arrays are flattened
66
+ # @return [String, Class] returns the canonical String on read; returns self on write
67
+ def query_by(*values)
68
+ return @__model_default_query_by__ if values.nil? || values.empty?
69
+
70
+ flat = values.flatten(1).compact
71
+
72
+ list = if flat.size == 1 && flat.first.is_a?(String)
73
+ flat.first.split(',').map { |s| s.to_s.strip }.reject(&:empty?)
74
+ else
75
+ flat.map do |v|
76
+ case v
77
+ when String, Symbol then v.to_s.strip
78
+ else
79
+ raise ArgumentError, 'query_by accepts Symbols, Strings, or Arrays thereof'
80
+ end
81
+ end.reject(&:empty?)
82
+ end
83
+
84
+ canonical = list.join(', ')
85
+ @__model_default_query_by__ = canonical.empty? ? nil : canonical
86
+ self
87
+ end
88
+ end
89
+
90
+ class_methods do
91
+ # Declare an attribute with an optional type (symbol preferred).
92
+ #
93
+ # @param name [#to_sym]
94
+ # @param type [Object] type descriptor (e.g., :string, :integer)
95
+ # @param index [Boolean, nil] when false, omit from compiled Typesense schema (still hydrated/displayed)
96
+ # @param locale [String, nil]
97
+ # @param optional [Boolean, nil]
98
+ # @param sort [Boolean, nil]
99
+ # @param infix [Boolean, nil]
100
+ # @param empty_filtering [Boolean, nil]
101
+ # @param nested [Hash, nil]
102
+ # @return [void]
103
+ def attribute(name, type = :string, index: nil, locale: nil, optional: nil, sort: nil, infix: nil,
104
+ empty_filtering: nil, facet: nil, nested: nil)
105
+ n = name.to_sym
106
+ __se_validate_attribute_name!(n)
107
+ __se_assign_attribute!(n, type)
108
+ __se_update_attribute_options!(n, type, locale: locale, optional: optional, sort: sort, infix: infix,
109
+ empty_filtering: empty_filtering, facet: facet, index: index
110
+ )
111
+ __se_define_reader_if_needed!(n)
112
+ __se_expand_nested_fields!(n, type, nested)
113
+ nil
114
+ end
115
+ end
116
+
117
+ class_methods do
118
+ # Validate reserved names and raise when invalid.
119
+ def __se_validate_attribute_name!(name_sym)
120
+ return unless name_sym == :id
121
+
122
+ raise SearchEngine::Errors::InvalidField,
123
+ 'The :id field is reserved; use `identify_by` to set the Typesense document id.'
124
+ end
125
+ end
126
+
127
+ class_methods do
128
+ # Assign base attribute type.
129
+ def __se_assign_attribute!(name_sym, type)
130
+ (@attributes ||= {})[name_sym] = type
131
+ end
132
+ end
133
+
134
+ class_methods do
135
+ # Update per-attribute options from keyword arguments.
136
+ def __se_update_attribute_options!(
137
+ name_sym,
138
+ type,
139
+ locale:,
140
+ optional:,
141
+ sort:,
142
+ infix:,
143
+ empty_filtering:,
144
+ facet:,
145
+ index:
146
+ )
147
+ has_opts = [locale, optional, sort, infix, empty_filtering, facet, index].any? { |v| !v.nil? }
148
+ if has_opts
149
+ @attribute_options ||= {}
150
+ new_opts = __se_build_attribute_options_for(
151
+ name_sym, type,
152
+ locale: locale, optional: optional, sort: sort, infix: infix,
153
+ empty_filtering: empty_filtering, facet: facet, index: index
154
+ )
155
+
156
+ if new_opts.empty?
157
+ @attribute_options = @attribute_options.dup
158
+ @attribute_options.delete(name_sym)
159
+ else
160
+ @attribute_options[name_sym] = new_opts
161
+ end
162
+ elsif instance_variable_defined?(:@attribute_options) && (@attribute_options || {}).key?(name_sym)
163
+ # When re-declared without options, keep prior options as-is (idempotent)
164
+ end
165
+ end
166
+ end
167
+
168
+ class_methods do
169
+ # Define an instance reader for the attribute when safe to do so.
170
+ # For boolean attributes, also defines a question-mark alias (e.g., available?).
171
+ def __se_define_reader_if_needed!(name_sym)
172
+ reader_defined = valid_attribute_reader_name?(name_sym) && !method_defined?(name_sym)
173
+ attr_reader name_sym if reader_defined
174
+
175
+ # Always check for boolean alias, even if reader was already defined
176
+ # (handles case where attribute type changes to boolean)
177
+ __se_define_boolean_alias_if_needed!(name_sym) if valid_attribute_reader_name?(name_sym)
178
+ end
179
+
180
+ # Define a question-mark alias for boolean attributes.
181
+ # @param name_sym [Symbol] attribute name
182
+ def __se_define_boolean_alias_if_needed!(name_sym)
183
+ type = (@attributes || {})[name_sym]
184
+ return unless type == :boolean
185
+
186
+ alias_name = "#{name_sym}?".to_sym
187
+ return if method_defined?(alias_name)
188
+
189
+ alias_method alias_name, name_sym
190
+ end
191
+
192
+ private :__se_define_boolean_alias_if_needed!
193
+ end
194
+
195
+ class_methods do
196
+ # Expand nested subfields for object/object[] attributes when nested: is provided.
197
+ def __se_expand_nested_fields!(name_sym, type, nested)
198
+ return if nested.nil? || (nested.respond_to?(:empty?) && nested.empty?)
199
+
200
+ unless nested.is_a?(Hash)
201
+ raise SearchEngine::Errors::InvalidOption,
202
+ '`nested` must be a Hash of field_name => type'
203
+ end
204
+
205
+ is_object = type.to_s.downcase == 'object'
206
+ is_object_array = type.is_a?(Array) && type.size == 1 && type.first.to_s.downcase == 'object'
207
+ unless is_object || is_object_array
208
+ raise SearchEngine::Errors::InvalidOption,
209
+ "`nested:` is only valid for :object or [:object] attributes (got #{type.inspect})"
210
+ end
211
+
212
+ nested.each do |child_name, child_type|
213
+ effective = __se_compute_nested_type_descriptor(child_type, array: is_object_array)
214
+ attribute("#{name_sym}.#{child_name}".to_sym, effective)
215
+ end
216
+ end
217
+ end
218
+
219
+ class_methods do
220
+ # Declare nested fields under a base object/object[] attribute.
221
+ #
222
+ # Usage:
223
+ # attribute :retail_prices, [:object]
224
+ # nested :retail_prices,
225
+ # current_price: :float,
226
+ # price_type: :string
227
+ #
228
+ # When the base is :object, nested field types are scalar (e.g., :float -> "float").
229
+ # When the base is [:object], nested field types are array (e.g., :float -> "float[]").
230
+ #
231
+ # @param base [Symbol, String] base field name that must be declared as :object or [:object]
232
+ # @param fields [Hash{Symbol=>Object}] map of nested field name => type descriptor
233
+ # @return [void]
234
+ # @raise [SearchEngine::Errors::InvalidOption] when base is not declared as object/object[]
235
+ def nested(base, **fields)
236
+ base_sym = base.to_sym
237
+ attrs = @attributes || {}
238
+ base_type = attrs[base_sym]
239
+
240
+ is_object = base_type.to_s.downcase == 'object'
241
+ is_object_array = base_type.is_a?(Array) && base_type.size == 1 && base_type.first.to_s.downcase == 'object'
242
+
243
+ unless is_object || is_object_array
244
+ raise SearchEngine::Errors::InvalidOption,
245
+ "`nested` requires base attribute #{base_sym.inspect} to be declared as :object or [:object] " \
246
+ "(got #{base_type.inspect})"
247
+ end
248
+
249
+ fields.each do |name, type_descriptor|
250
+ effective_type = __se_compute_nested_type_descriptor(type_descriptor, array: is_object_array)
251
+ # Dotted attribute name is intentional and supported by the schema compiler.
252
+ attribute("#{base_sym}.#{name}".to_sym, effective_type)
253
+ end
254
+
255
+ nil
256
+ end
257
+
258
+ # Normalize a nested type descriptor to scalar or array form depending on the parent multiplicity.
259
+ # Accepts Symbols (e.g., :float), Arrays (e.g., [:float]), or Strings (e.g., "float", "float[]").
260
+ def __se_compute_nested_type_descriptor(type_descriptor, array:)
261
+ # Already an array type in DSL form ([:float])
262
+ if type_descriptor.is_a?(Array) && type_descriptor.size == 1
263
+ return type_descriptor if array
264
+
265
+ return type_descriptor.first
266
+ end
267
+
268
+ # String forms like "float[]" or canonical names
269
+ if type_descriptor.is_a?(String)
270
+ s = type_descriptor.strip
271
+ if s.end_with?('[]')
272
+ inner = s[0..-3]
273
+ return array ? [inner.to_sym] : inner.to_sym
274
+ end
275
+ return array ? [s.to_sym] : s.to_sym
276
+ end
277
+
278
+ # Symbol or other single token
279
+ array ? [type_descriptor] : type_descriptor
280
+ end
281
+ end
282
+
283
+ class_methods do
284
+ # Validate whether an attribute name is a valid Ruby reader method name
285
+ # (skip dotted names and other invalid identifiers).
286
+ def valid_attribute_reader_name?(name)
287
+ s = name.to_s
288
+ return false if s.empty?
289
+ return false unless s.match?(/\A[a-zA-Z_]\w*\z/)
290
+
291
+ true
292
+ end
293
+
294
+ def __se_build_attribute_options_for(
295
+ n,
296
+ type,
297
+ locale:,
298
+ optional: nil,
299
+ sort: nil,
300
+ infix: nil,
301
+ empty_filtering: nil,
302
+ facet: nil,
303
+ index: nil
304
+ )
305
+ new_opts = (@attribute_options[n] || {}).dup
306
+
307
+ # locale
308
+ if locale.nil?
309
+ new_opts.delete(:locale)
310
+ else
311
+ is_string = type.to_s.downcase == 'string'
312
+ is_string_array = type.is_a?(Array) && type.size == 1 && type.first.to_s.downcase == 'string'
313
+ unless is_string || is_string_array
314
+ raise SearchEngine::Errors::InvalidOption,
315
+ "`locale` is only applicable to :string and [:string] (got #{type.inspect})"
316
+ end
317
+ new_opts[:locale] = locale.to_s
318
+ end
319
+
320
+ new_opts = __se_apply_optional_sort_empty_filtering(
321
+ new_opts,
322
+ type,
323
+ optional: optional,
324
+ sort: sort,
325
+ infix: infix,
326
+ empty_filtering: empty_filtering
327
+ )
328
+
329
+ # facet
330
+ if facet.nil?
331
+ new_opts.delete(:facet)
332
+ else
333
+ __se_ensure_boolean!(:facet, facet)
334
+ new_opts[:facet] = facet ? true : false
335
+ end
336
+
337
+ # index flag (default is true; only store when provided)
338
+ unless index.nil?
339
+ __se_ensure_boolean!(:index, index)
340
+ new_opts[:index] = index ? true : false
341
+ end
342
+
343
+ new_opts
344
+ end
345
+
346
+ private :__se_build_attribute_options_for
347
+ end
348
+
349
+ class_methods do
350
+ # optional, sort, infix, empty_filtering extracted to a separate block to
351
+ # satisfy Metrics/BlockLength without changing semantics.
352
+ def __se_ensure_boolean!(name, value)
353
+ return if [true, false].include?(value)
354
+
355
+ raise SearchEngine::Errors::InvalidOption,
356
+ "`#{name}` should be of boolean data type (currently is #{value.class})"
357
+ end
358
+
359
+ def __se_apply_optional_sort_empty_filtering(new_opts, type, optional:, sort:, infix:, empty_filtering:)
360
+ # optional
361
+ if optional.nil?
362
+ new_opts.delete(:optional)
363
+ else
364
+ __se_ensure_boolean!(:optional, optional)
365
+ new_opts[:optional] = optional ? true : false
366
+ end
367
+
368
+ # sort
369
+ if sort.nil?
370
+ new_opts.delete(:sort)
371
+ else
372
+ __se_ensure_boolean!(:sort, sort)
373
+ new_opts[:sort] = sort ? true : false
374
+ end
375
+
376
+ # infix
377
+ if infix.nil?
378
+ new_opts.delete(:infix)
379
+ else
380
+ __se_ensure_boolean!(:infix, infix)
381
+ new_opts[:infix] = infix ? true : false
382
+ end
383
+
384
+ # empty_filtering
385
+ if empty_filtering.nil?
386
+ new_opts.delete(:empty_filtering)
387
+ else
388
+ is_array_type = type.is_a?(Array) && type.size == 1
389
+ unless is_array_type
390
+ raise SearchEngine::Errors::InvalidOption,
391
+ "`empty_filtering` is only applicable to array types (e.g., [:string]); got #{type.inspect}"
392
+ end
393
+ new_opts[:empty_filtering] = empty_filtering ? true : false
394
+ end
395
+
396
+ new_opts
397
+ end
398
+
399
+ private :__se_apply_optional_sort_empty_filtering
400
+ end
401
+
402
+ class_methods do
403
+ # Read-only view of declared attributes for this class.
404
+ def attributes
405
+ (@attributes || {}).dup.freeze
406
+ end
407
+ end
408
+
409
+ class_methods do
410
+ # Read-only view of declared per-attribute options (e.g., locale).
411
+ def attribute_options
412
+ (@attribute_options || {}).dup.freeze
413
+ end
414
+ end
415
+
416
+ class_methods do
417
+ # Configure schema retention policy for this collection.
418
+ # @param keep_last [Integer] how many previous physicals to keep after swap
419
+ # @return [void]
420
+ def schema_retention(keep_last: nil)
421
+ return (@schema_retention || {}).dup.freeze if keep_last.nil?
422
+
423
+ value = Integer(keep_last)
424
+ raise ArgumentError, 'keep_last must be >= 0' if value.negative?
425
+
426
+ @schema_retention ||= {}
427
+ @schema_retention[:keep_last] = value
428
+ nil
429
+ end
430
+ end
431
+
432
+ class_methods do
433
+ # Hook to ensure subclasses inherit attributes and schema retention from their parent.
434
+ def inherited(subclass)
435
+ super
436
+ parent_attrs = @attributes || {}
437
+ subclass.instance_variable_set(:@attributes, parent_attrs.dup)
438
+
439
+ parent_attr_opts = @attribute_options || {}
440
+ subclass.instance_variable_set(:@attribute_options, parent_attr_opts.dup)
441
+
442
+ parent_retention = @schema_retention || {}
443
+ subclass.instance_variable_set(:@schema_retention, parent_retention.dup)
444
+
445
+ parent_joins = @joins_config || {}
446
+ subclass.instance_variable_set(:@joins_config, parent_joins.dup.freeze)
447
+
448
+ if instance_variable_defined?(:@__declared_default_preset__)
449
+ token = instance_variable_get(:@__declared_default_preset__)
450
+ subclass.instance_variable_set(:@__declared_default_preset__, token)
451
+ end
452
+
453
+ if instance_variable_defined?(:@__model_default_query_by__)
454
+ qb = instance_variable_get(:@__model_default_query_by__)
455
+ subclass.instance_variable_set(:@__model_default_query_by__, qb)
456
+ end
457
+
458
+ return unless instance_variable_defined?(:@identify_by_proc)
459
+
460
+ subclass.instance_variable_set(:@identify_by_proc, @identify_by_proc)
461
+ # Propagate identify_by metadata for type hints
462
+ if instance_variable_defined?(:@__identify_by_kind__)
463
+ subclass.instance_variable_set(:@__identify_by_kind__, @__identify_by_kind__)
464
+ end
465
+ return unless instance_variable_defined?(:@__identify_by_symbol__)
466
+
467
+ subclass.instance_variable_set(:@__identify_by_symbol__, @__identify_by_symbol__)
468
+ end
469
+ end
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ # Default preset declaration and resolution.
8
+ module Presets
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ # Declare a default preset token for this collection.
13
+ # @param name [#to_sym]
14
+ # @return [void]
15
+ def default_preset(name)
16
+ raise ArgumentError, 'default_preset requires a name' if name.nil?
17
+
18
+ token = name.to_sym
19
+ raise ArgumentError, 'default_preset name must be non-empty' if token.to_s.strip.empty?
20
+
21
+ instance_variable_set(:@__declared_default_preset__, token)
22
+ nil
23
+ end
24
+
25
+ # Compute the effective default preset name for this collection.
26
+ # @return [String, nil]
27
+ def default_preset_name
28
+ token = if instance_variable_defined?(:@__declared_default_preset__)
29
+ instance_variable_get(:@__declared_default_preset__)
30
+ end
31
+ return nil if token.nil?
32
+
33
+ presets_cfg = SearchEngine.config.presets
34
+ if presets_cfg.enabled && presets_cfg.namespace
35
+ +"#{presets_cfg.namespace}_#{token}"
36
+ else
37
+ token.to_s
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end