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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ # Instance-level deletion for a single hydrated record.
8
+ #
9
+ # Provides {#delete} that deletes the current document from the backing
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 Deletion
14
+ extend ActiveSupport::Concern
15
+
16
+ # Delete this record from the collection.
17
+ #
18
+ # Accepts the same optional knobs as relation-level {Relation::Deletion#delete_all}
19
+ # for consistency.
20
+ #
21
+ # @param into [String, nil] override physical collection name
22
+ # @param partition [Object, nil] partition token for resolvers
23
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
24
+ # @return [Integer] number of deleted documents (0 or 1)
25
+ # @raise [SearchEngine::Errors::InvalidParams] when the record id is unavailable
26
+ def delete(into: nil, partition: nil, timeout_ms: nil)
27
+ id_value = __se_effective_document_id_for_deletion
28
+ if id_value.nil? || id_value.to_s.strip.empty?
29
+ raise SearchEngine::Errors::InvalidParams,
30
+ "Cannot delete without document id; include 'id' in selection or provide identifiable attributes"
31
+ end
32
+
33
+ # Resolve target collection (alias or physical) consistently with relation/model helpers
34
+ collection = SearchEngine::Deletion.resolve_into(
35
+ klass: self.class,
36
+ partition: partition,
37
+ into: into
38
+ )
39
+
40
+ # Apply same timeout fallback policy as delete_by
41
+ effective_timeout = if timeout_ms&.to_i&.positive?
42
+ timeout_ms.to_i
43
+ else
44
+ begin
45
+ SearchEngine.config.stale_deletes&.timeout_ms
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ end
50
+
51
+ resp = SearchEngine.client.delete_document(
52
+ collection: collection,
53
+ id: id_value,
54
+ timeout_ms: effective_timeout
55
+ )
56
+ # The client returns a Hash or nil when 404; normalize to numeric 0/1
57
+ resp.nil? ? 0 : 1
58
+ end
59
+
60
+ private
61
+
62
+ # Determine the effective document id for deletion, preferring hydrated
63
+ # `@id` when present and falling back to the class-level identify_by.
64
+ # @return [String, nil]
65
+ def __se_effective_document_id_for_deletion
66
+ v = instance_variable_defined?(:@id) ? instance_variable_get(:@id) : nil
67
+ return v unless v.nil? || v.to_s.strip.empty?
68
+
69
+ begin
70
+ computed = self.class.compute_document_id(self)
71
+ return computed unless computed.nil? || computed.to_s.strip.empty?
72
+ rescue StandardError
73
+ # best-effort; nil means we cannot determine id
74
+ end
75
+
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ # Internal helpers for coercing values for display/formatting.
8
+ #
9
+ # This concern provides shared instance-level utilities used by
10
+ # hydration and pretty-printing. No business logic changes.
11
+ module DisplayCoercions
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ # Convert integer epoch seconds to a Time in the current zone for display.
17
+ # Falls back gracefully when value is not an Integer.
18
+ def __se_coerce_doc_updated_at_for_display(value)
19
+ int_val = begin
20
+ Integer(value)
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ return value if int_val.nil?
25
+
26
+ if defined?(Time) && defined?(Time.zone) && Time.zone
27
+ Time.zone.at(int_val)
28
+ else
29
+ Time.at(int_val)
30
+ end
31
+ rescue StandardError
32
+ value
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ # Hydration helpers for building instances from Typesense documents and
8
+ # providing attribute readers and views.
9
+ module Hydration
10
+ extend ActiveSupport::Concern
11
+
12
+ include SearchEngine::Base::DisplayCoercions
13
+
14
+ class_methods do
15
+ # Build a new instance from a Typesense document assigning only declared
16
+ # attributes and capturing any extra keys in {#unknown_attributes}.
17
+ #
18
+ # Unknown keys are preserved as a String-keyed Hash to avoid symbol bloat.
19
+ #
20
+ # @param doc [Hash] a document as returned by Typesense
21
+ # @return [Object] hydrated instance
22
+ def from_document(doc)
23
+ obj = new
24
+ declared = __se_declared_attributes
25
+ declared_joins = __se_declared_joins
26
+
27
+ hidden_local = __se_hidden_local_fields
28
+ hidden_join = __se_hidden_join_fields
29
+
30
+ unknown = __se_assign_declared_or_unknown(
31
+ obj,
32
+ doc || {},
33
+ declared: declared,
34
+ declared_joins: declared_joins,
35
+ hidden_local: hidden_local,
36
+ hidden_join: hidden_join
37
+ )
38
+
39
+ __se_apply_default_joins!(obj, declared_joins: declared_joins, declared: declared)
40
+ __se_freeze_unknown!(obj, unknown)
41
+ obj
42
+ end
43
+ end
44
+
45
+ class_methods do
46
+ # Fetch declared attributes; resilient to missing DSL
47
+ def __se_declared_attributes
48
+ attributes
49
+ rescue StandardError
50
+ {}
51
+ end
52
+
53
+ # Fetch join declarations; keep existing behavior (use self.class) to avoid logic change
54
+ def __se_declared_joins
55
+ if respond_to?(:joins_config)
56
+ joins_config || {}
57
+ else
58
+ {}
59
+ end
60
+ rescue StandardError
61
+ {}
62
+ end
63
+
64
+ private :__se_declared_attributes, :__se_declared_joins
65
+ end
66
+
67
+ class_methods do
68
+ # Build the list of hidden local fields (e.g., name_empty)
69
+ def __se_hidden_local_fields
70
+ attr_opts = begin
71
+ respond_to?(:attribute_options) ? attribute_options : {}
72
+ rescue StandardError
73
+ {}
74
+ end
75
+
76
+ hidden = []
77
+ attr_opts.each do |fname, opts|
78
+ next unless opts.is_a?(Hash)
79
+
80
+ hidden << "#{fname}_empty" if opts[:empty_filtering]
81
+ hidden << "#{fname}_blank" if opts[:optional]
82
+ end
83
+ hidden
84
+ end
85
+
86
+ private :__se_hidden_local_fields
87
+ end
88
+
89
+ class_methods do
90
+ # Build the list of hidden join fields (e.g., $assoc.field_empty)
91
+ def __se_hidden_join_fields
92
+ hidden = []
93
+ joins_cfg = begin
94
+ respond_to?(:joins_config) ? joins_config : {}
95
+ rescue StandardError
96
+ {}
97
+ end
98
+
99
+ joins_cfg.each do |assoc_name, cfg|
100
+ collection = cfg[:collection]
101
+ next if collection.nil? || collection.to_s.strip.empty?
102
+
103
+ begin
104
+ target_klass = SearchEngine.collection_for(collection)
105
+ next unless target_klass.respond_to?(:attribute_options)
106
+
107
+ opts = target_klass.attribute_options || {}
108
+ opts.each do |field_sym, o|
109
+ next unless o.is_a?(Hash)
110
+
111
+ hidden << "#$#{assoc_name}.#{field_sym}_empty".sub('#$', '$') if o[:empty_filtering]
112
+ hidden << "#$#{assoc_name}.#{field_sym}_blank".sub('#$', '$') if o[:optional]
113
+ end
114
+ rescue StandardError
115
+ # Best-effort; skip when registry/metadata unavailable
116
+ end
117
+ end
118
+
119
+ hidden
120
+ rescue StandardError
121
+ []
122
+ end
123
+
124
+ private :__se_hidden_join_fields
125
+ end
126
+
127
+ class_methods do
128
+ # Assign declared and join attributes; collect unknowns (filtering hidden fields)
129
+ def __se_assign_declared_or_unknown(obj, doc, declared:, declared_joins:, hidden_local:, hidden_join:)
130
+ unknown = {}
131
+ doc.each do |k, v|
132
+ key_str = k.to_s
133
+ key_sym = key_str.to_sym
134
+ if declared.key?(key_sym) || declared_joins.key?(key_sym)
135
+ obj.instance_variable_set("@#{key_sym}", v)
136
+ else
137
+ next if hidden_local.include?(key_str) || hidden_join.include?(key_str)
138
+
139
+ unknown[key_str] = v
140
+ obj.instance_variable_set('@id', v) if key_str == 'id'
141
+ end
142
+ end
143
+ unknown
144
+ end
145
+
146
+ # Ensure default values for missing join attributes based on local_key type
147
+ def __se_apply_default_joins!(obj, declared_joins:, declared:)
148
+ declared_joins.each do |assoc_name, cfg|
149
+ ivar = "@#{assoc_name}"
150
+ next if obj.instance_variable_defined?(ivar)
151
+
152
+ lk = cfg[:local_key]
153
+ lk_type = declared[lk]
154
+ default_val = nil
155
+ default_val = [] if lk_type.is_a?(Array) && lk_type.size == 1
156
+ obj.instance_variable_set(ivar, default_val)
157
+ end
158
+ end
159
+
160
+ def __se_freeze_unknown!(obj, unknown)
161
+ obj.instance_variable_set(:@__unknown_attributes__, unknown.freeze) unless unknown.empty?
162
+ end
163
+
164
+ private :__se_assign_declared_or_unknown, :__se_apply_default_joins!, :__se_freeze_unknown!
165
+ end
166
+
167
+ # Return a shallow copy of unknown attributes captured during hydration.
168
+ # Keys are Strings and values are as returned by the backend.
169
+ # @return [Hash{String=>Object}]
170
+ def unknown_attributes
171
+ h = instance_variable_get(:@__unknown_attributes__)
172
+ h ? h.dup : {}
173
+ end
174
+
175
+ # Return the document update timestamp coerced to Time.
176
+ #
177
+ # Prefers a declared attribute reader (when present). Falls back to the
178
+ # unknown attributes payload (as returned by the backend) when the field
179
+ # was not declared via the DSL. The value is coerced using the same logic
180
+ # used for console rendering.
181
+ #
182
+ # @return [Time, nil]
183
+ def doc_updated_at
184
+ value = if instance_variable_defined?(:@doc_updated_at)
185
+ instance_variable_get(:@doc_updated_at)
186
+ else
187
+ raw = instance_variable_get(:@__unknown_attributes__)
188
+ if raw&.key?('doc_updated_at')
189
+ raw['doc_updated_at']
190
+ elsif raw&.key?(:doc_updated_at)
191
+ raw[:doc_updated_at]
192
+ end
193
+ end
194
+
195
+ return nil if value.nil?
196
+
197
+ __se_coerce_doc_updated_at_for_display(value)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ # Return a symbol-keyed Hash of attributes for this record.
203
+ #
204
+ # - Includes declared attributes in declaration order
205
+ # - Ensures :doc_updated_at is present and coerced to Time when available
206
+ # - Includes unknown fields under :unknown_attributes (String-keyed), with
207
+ # "doc_updated_at" removed to avoid duplication
208
+ #
209
+ # @return [Hash{Symbol=>Object}]
210
+ # rubocop:disable Metrics/PerceivedComplexity
211
+ def attributes
212
+ declared = begin
213
+ self.class.respond_to?(:attributes) ? self.class.attributes : {}
214
+ rescue StandardError
215
+ {}
216
+ end
217
+
218
+ out = {}
219
+
220
+ declared.each_key do |name|
221
+ # Skip non-base (dotted) attribute names when reading ivars
222
+ begin
223
+ next unless self.class.respond_to?(:valid_attribute_reader_name?) &&
224
+ self.class.valid_attribute_reader_name?(name)
225
+ rescue StandardError
226
+ next if name.to_s.include?('.')
227
+ end
228
+
229
+ var = "@#{name}"
230
+ val = instance_variable_get(var)
231
+ out[name] =
232
+ if name.to_s == 'doc_updated_at' && !val.nil?
233
+ __se_coerce_doc_updated_at_for_display(val)
234
+ else
235
+ val
236
+ end
237
+ end
238
+
239
+ raw_unknowns = instance_variable_get(:@__unknown_attributes__)
240
+ unknowns = raw_unknowns ? raw_unknowns.dup : {}
241
+
242
+ # Ensure :id is present when available (source may be an ivar or unknowns)
243
+ unless out.key?(:id)
244
+ raw_id = if instance_variable_defined?(:@id)
245
+ instance_variable_get(:@id)
246
+ else
247
+ unknowns['id'] || unknowns[:id]
248
+ end
249
+ out[:id] = raw_id unless raw_id.nil?
250
+ end
251
+
252
+ unless out.key?(:doc_updated_at)
253
+ raw_val = unknowns['doc_updated_at']
254
+ raw_val = unknowns[:doc_updated_at] if raw_val.nil?
255
+ out[:doc_updated_at] = __se_coerce_doc_updated_at_for_display(raw_val) unless raw_val.nil?
256
+ end
257
+
258
+ # Remove duplicate source of doc_updated_at from nested unknowns
259
+ unknowns.delete('doc_updated_at')
260
+ unknowns.delete(:doc_updated_at)
261
+
262
+ out[:unknown_attributes] = unknowns unless unknowns.empty?
263
+ out
264
+ end
265
+ # rubocop:enable Metrics/PerceivedComplexity
266
+
267
+ # Return the Typesense document id if available.
268
+ #
269
+ # @return [Object, nil]
270
+ def id
271
+ value = instance_variable_defined?(:@id) ? instance_variable_get(:@id) : nil
272
+ return value unless value.nil?
273
+
274
+ raw = instance_variable_get(:@__unknown_attributes__)
275
+ return nil unless raw
276
+
277
+ raw['id'] || raw[:id]
278
+ rescue StandardError
279
+ nil
280
+ end
281
+
282
+ # Attribute lookup by key with indifferent access semantics.
283
+ # Supports symbol or string keys and falls back to unknown attributes.
284
+ #
285
+ # @param key [#to_s, #to_sym]
286
+ # @return [Object, nil]
287
+ def [](key)
288
+ attrs = attributes
289
+ return attrs.with_indifferent_access[key] if attrs.respond_to?(:with_indifferent_access)
290
+
291
+ return nil if key.nil?
292
+
293
+ # Fast path: exact match
294
+ value = attrs[key]
295
+ return value unless value.nil?
296
+
297
+ # Symbol/string coercions without depending on ActiveSupport
298
+ if key.respond_to?(:to_sym)
299
+ sym = key.to_sym
300
+ return attrs[sym] if attrs.key?(sym)
301
+ end
302
+
303
+ if key.respond_to?(:to_s)
304
+ str = key.to_s
305
+ return attrs[str] if attrs.key?(str)
306
+ end
307
+
308
+ nil
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ class Base
7
+ module IndexMaintenance
8
+ # Cleanup-related helpers for the SearchEngine DSL.
9
+ module Cleanup
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # Return a chainable Relation of stale documents compiled from `stale` rules.
14
+ #
15
+ # Compiles all `stale` entries declared in the indexing DSL for the
16
+ # given partition and returns a Relation for the merged filter (OR semantics).
17
+ # When no rules are present or the effective filter resolves to blank,
18
+ # returns an empty relation.
19
+ #
20
+ # @param partition [Object, nil] optional partition token passed to the filter block
21
+ # @return [SearchEngine::Relation]
22
+ def stale(partition: nil)
23
+ filters = SearchEngine::StaleRules.compile_filters(self, partition: partition)
24
+ merged = SearchEngine::StaleRules.merge_filters(filters)
25
+
26
+ if merged.nil? || merged.to_s.strip.empty?
27
+ # Impossible but valid predicate to ensure an empty Relation when there are no stale rules
28
+ return all.where('id:="__se_none__" && id:!="__se_none__"')
29
+ end
30
+
31
+ all.where(merged)
32
+ end
33
+
34
+ # Delete stale documents from the collection according to DSL rules.
35
+ #
36
+ # Evaluates all stale definitions declared via the indexing DSL,
37
+ # building a filter that deletes matching documents
38
+ # using {SearchEngine::Deletion.delete_by}. When no stale configuration
39
+ # is present, the method logs a skip message and returns 0.
40
+ #
41
+ # @param into [String, nil] optional physical collection override
42
+ # @param partition [Object, nil] optional partition token forwarded to resolvers
43
+ # @param clear_cache [Boolean] clear Typesense cache after cleanup
44
+ # @return [Integer] number of deleted documents
45
+ def cleanup(into: nil, partition: nil, clear_cache: false)
46
+ logical = respond_to?(:collection) ? collection.to_s : name.to_s
47
+ puts
48
+ puts(%(>>>>>> Cleanup Collection "#{logical}"))
49
+
50
+ filters = SearchEngine::StaleRules.compile_filters(self, partition: partition)
51
+ filters.compact!
52
+ filters.reject! { |f| f.to_s.strip.empty? }
53
+ if filters.empty?
54
+ puts('Cleanup — skip (no stale configuration)')
55
+ return 0
56
+ end
57
+
58
+ merged_filter = SearchEngine::StaleRules.merge_filters(filters)
59
+ puts("Cleanup — filter=#{merged_filter.inspect}")
60
+
61
+ deleted = SearchEngine::Deletion.delete_by(
62
+ klass: self,
63
+ filter: merged_filter,
64
+ into: into,
65
+ partition: partition
66
+ )
67
+
68
+ puts("Cleanup — deleted=#{deleted}")
69
+ deleted
70
+ rescue StandardError => error
71
+ warn(
72
+ "Cleanup — error=#{error.class}: #{error.message.to_s[0, 200]}"
73
+ )
74
+ 0
75
+ ensure
76
+ if clear_cache
77
+ begin
78
+ puts('Cleanup — cache clear')
79
+ SearchEngine::Cache.clear
80
+ rescue StandardError => error
81
+ warn(
82
+ "Cleanup — cache clear error=#{error.class}: #{error.message.to_s[0, 200]}"
83
+ )
84
+ end
85
+ end
86
+ puts(%(>>>>>> Cleanup Done))
87
+ end
88
+
89
+ private
90
+
91
+ def build_scope_filters(entries, partition: nil)
92
+ filters = entries
93
+ .select { |entry| entry[:type] == :scope }
94
+ .map do |entry|
95
+ scope = entry[:name]
96
+ next unless respond_to?(scope)
97
+
98
+ rel = invoke_scope(scope, partition)
99
+ next unless rel.is_a?(SearchEngine::Relation)
100
+
101
+ rel.filter_params
102
+ end
103
+ filters.compact
104
+ rescue StandardError
105
+ []
106
+ end
107
+
108
+ def build_attribute_filters(entries)
109
+ filters = entries
110
+ .select { |entry| entry[:type] == :attribute }
111
+ .map do |entry|
112
+ attr = entry[:name]
113
+ val = entry[:value]
114
+ relation_for({ attr => val })&.filter_params
115
+ end
116
+ filters.compact
117
+ rescue StandardError
118
+ []
119
+ end
120
+
121
+ def build_hash_filters(entries)
122
+ filters = entries
123
+ .select { |entry| entry[:type] == :hash }
124
+ .map { |entry| relation_for(entry[:hash])&.filter_params }
125
+ filters.compact
126
+ rescue StandardError
127
+ []
128
+ end
129
+
130
+ def build_raw_filters(entries, partition: nil)
131
+ raw = entries.select { |entry| %i[filter relation block].include?(entry[:type]) }
132
+
133
+ filters = raw.flat_map do |entry|
134
+ case entry[:type]
135
+ when :filter then entry[:value]
136
+ when :relation then entry[:relation]&.filter_params
137
+ when :block
138
+ evaluate_block_entry(entry[:block], partition: partition)
139
+ end
140
+ end
141
+ Array(filters).compact
142
+ rescue StandardError
143
+ []
144
+ end
145
+
146
+ def merge_filters(filters)
147
+ return filters.first if filters.size == 1
148
+
149
+ fragments = filters.map do |filter|
150
+ next if filter.to_s.strip.empty?
151
+
152
+ "(#{filter})"
153
+ end.compact
154
+
155
+ fragments.join(' || ')
156
+ end
157
+
158
+ def relation_for(hash)
159
+ SearchEngine::Relation.new(self).where(hash)
160
+ end
161
+
162
+ def evaluate_block_entry(block, partition: nil)
163
+ params = block.parameters
164
+ result = if params.any? { |(kind, name)| %i[key keyreq].include?(kind) && name == :partition }
165
+ instance_exec(partition: partition, &block)
166
+ elsif block.arity.positive?
167
+ instance_exec(partition, &block)
168
+ else
169
+ instance_exec(&block)
170
+ end
171
+
172
+ case result
173
+ when String then result
174
+ when Hash then relation_for(result)&.filter_params
175
+ when SearchEngine::Relation then result.filter_params
176
+ end
177
+ rescue StandardError
178
+ nil
179
+ end
180
+
181
+ def invoke_scope(scope, partition)
182
+ method_obj = method(scope)
183
+ params = method_obj.parameters
184
+ if params.empty?
185
+ public_send(scope)
186
+ elsif params.any? do |(kind, name)|
187
+ %i[key keyreq].include?(kind) && %i[partition _partition].include?(name)
188
+ end
189
+ public_send(scope, partition: partition)
190
+ elsif params.first && %i[req opt].include?(params.first.first)
191
+ public_send(scope, partition)
192
+ else
193
+ public_send(scope)
194
+ end
195
+ rescue ArgumentError
196
+ public_send(scope)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end