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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ # Deletion helpers bound to a relation instance.
6
+ #
7
+ # Provides `delete_all` which deletes documents that match the current
8
+ # relation predicates. When no predicates are present, it deletes all
9
+ # documents from the collection by using a safe match-all filter.
10
+ module Deletion
11
+ # Delete all documents matching the current relation filters.
12
+ #
13
+ # When the relation has no filters, deletes all documents from the
14
+ # collection using a safe match-all filter (`id:!=null`).
15
+ #
16
+ # @param into [String, nil] override physical collection name
17
+ # @param partition [Object, nil] partition token for resolvers
18
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
19
+ # @return [Integer] number of deleted documents
20
+ def delete_all(into: nil, partition: nil, timeout_ms: nil)
21
+ ast_nodes = Array(@state[:ast]).flatten.compact
22
+ filter = compiled_filter_by(ast_nodes)
23
+
24
+ # Fallback to a safe match-all filter when no predicates are present
25
+ filter = 'id:!=null' if filter.to_s.strip.empty?
26
+
27
+ SearchEngine::Deletion.delete_by(
28
+ klass: @klass,
29
+ filter: filter,
30
+ into: into,
31
+ partition: partition,
32
+ timeout_ms: timeout_ms
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,624 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ module DSL
6
+ # Filter-related chainers and normalizers.
7
+ # These methods are mixed into Relation's DSL and must preserve copy-on-write semantics.
8
+ module Filters
9
+ # AR-style where.not support via a small chain proxy.
10
+ class WhereChain
11
+ def initialize(relation)
12
+ @relation = relation
13
+ end
14
+
15
+ # Replace positive predicates with negated form.
16
+ # Supports Hash, String templates, Arrays (delegated to parser with a negation flag).
17
+ # @param args [Array<Object>]
18
+ # @return [SearchEngine::Relation]
19
+ def not(*args)
20
+ nodes = Array(@relation.send(:build_ast_with_empty_array_rewrites, args, negated: true))
21
+
22
+ # Invert non-hidden predicates (Eq, In) returned by the builder
23
+ negated = nodes.map do |node|
24
+ case node
25
+ when SearchEngine::AST::Eq
26
+ SearchEngine::AST.not_eq(node.field, node.value)
27
+ when SearchEngine::AST::In
28
+ SearchEngine::AST.not_in(node.field, node.values)
29
+ else
30
+ node
31
+ end
32
+ end
33
+
34
+ @relation.send(:spawn) do |s|
35
+ s[:ast] = Array(s[:ast]) + negated
36
+ s[:filters] = Array(s[:filters])
37
+ end
38
+ end
39
+ end
40
+
41
+ # Add filters to the relation.
42
+ # When called without arguments, it's a no-op and returns the relation (idempotent).
43
+ # @param args [Array<Object>] filter arguments
44
+ # @return [SearchEngine::Relation, WhereChain]
45
+ def where(*args)
46
+ return self if args.nil? || args.empty?
47
+
48
+ ast_nodes = build_ast_with_empty_array_rewrites(args, negated: false)
49
+ fragments = normalize_where(args)
50
+ spawn do |s|
51
+ s[:ast] = Array(s[:ast]) + Array(ast_nodes)
52
+ s[:filters] = Array(s[:filters]) + fragments
53
+ end
54
+ end
55
+
56
+ # AR-style `.where.not(...)` support directly on the relation to keep
57
+ # `.where` with no args as a no-op (per project tests).
58
+ # @param args [Array<Object>]
59
+ # @return [SearchEngine::Relation]
60
+ def not(*args)
61
+ nodes = Array(build_ast_with_empty_array_rewrites(args, negated: true))
62
+
63
+ negated = nodes.map do |node|
64
+ case node
65
+ when SearchEngine::AST::Eq
66
+ SearchEngine::AST.not_eq(node.field, node.value)
67
+ when SearchEngine::AST::In
68
+ SearchEngine::AST.not_in(node.field, node.values)
69
+ else
70
+ node
71
+ end
72
+ end
73
+
74
+ spawn do |s|
75
+ s[:ast] = Array(s[:ast]) + negated
76
+ s[:filters] = Array(s[:filters])
77
+ end
78
+ end
79
+
80
+ # Replace all predicates with a new where input.
81
+ # @param input [Hash, String, Array, Symbol]
82
+ # @param args [Array<Object>]
83
+ # @return [SearchEngine::Relation]
84
+ def rewhere(input, *args)
85
+ if input.nil? || (input.respond_to?(:empty?) && input.empty?) || (input.is_a?(String) && input.strip.empty?)
86
+ raise ArgumentError, 'rewhere: provide a new predicate input'
87
+ end
88
+
89
+ nodes = SearchEngine::DSL::Parser.parse(input, klass: @klass, args: args, joins: joins_list)
90
+ list = Array(nodes).flatten.compact
91
+ raise ArgumentError, 'rewhere: produced no predicates' if list.empty?
92
+
93
+ spawn do |s|
94
+ s[:ast] = list
95
+ s[:filters] = []
96
+ end
97
+ end
98
+
99
+ # Merge another relation or join-scope into this relation.
100
+ #
101
+ # - When merging a relation for a joined model, the association must be
102
+ # applied via `joins(:assoc)` and the scope predicates are rewritten
103
+ # into joined filters.
104
+ # - When merging a Hash, it is treated as a join-scope shorthand:
105
+ # `merge(authors: :published)` mirrors `where(authors: :published)`.
106
+ #
107
+ # @param other [SearchEngine::Relation, Hash]
108
+ # @param assoc [Symbol, String, nil] explicit association for joined relations
109
+ # @return [SearchEngine::Relation]
110
+ def merge(other = nil, assoc: nil)
111
+ raise ArgumentError, 'merge: provide a relation or join-scope Hash' if other.nil?
112
+
113
+ case other
114
+ when SearchEngine::Relation
115
+ merge_relation(other, assoc: assoc)
116
+ when Hash
117
+ merge_join_scopes(other)
118
+ else
119
+ raise ArgumentError, "merge: unsupported input #{other.class}"
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def merge_relation(other, assoc: nil)
126
+ return self if other.nil?
127
+
128
+ return merge_same_model_relation(other) if other.klass == @klass
129
+
130
+ assoc_sym = resolve_merge_assoc_for_relation(other, assoc: assoc)
131
+ SearchEngine::Joins::Guard.ensure_join_applied!(joins_list, assoc_sym, context: 'merging')
132
+ cfg = @klass.join_for(assoc_sym)
133
+
134
+ nodes = Array(other.send(:ast)).flatten.compact
135
+ return self if nodes.empty?
136
+
137
+ rewritten = rewrite_join_scope_nodes(nodes, assoc_sym, cfg)
138
+ spawn do |s|
139
+ s[:ast] = Array(s[:ast]) + Array(rewritten)
140
+ s[:filters] = Array(s[:filters])
141
+ end
142
+ end
143
+
144
+ def merge_same_model_relation(other)
145
+ nodes = Array(other.send(:ast)).flatten.compact
146
+ fragments = merge_relation_filters(other)
147
+ return self if nodes.empty? && fragments.empty?
148
+
149
+ spawn do |s|
150
+ s[:ast] = Array(s[:ast]) + nodes
151
+ s[:filters] = Array(s[:filters]) + fragments
152
+ end
153
+ end
154
+
155
+ def merge_relation_filters(other)
156
+ state = other.instance_variable_get(:@state)
157
+ Array(state ? state[:filters] : [])
158
+ rescue StandardError
159
+ []
160
+ end
161
+
162
+ def resolve_merge_assoc_for_relation(other, assoc: nil)
163
+ return assoc.to_sym unless assoc.nil?
164
+
165
+ cfgs = @klass.respond_to?(:joins_config) ? (@klass.joins_config || {}) : {}
166
+ target_collection = other.klass.collection if other.klass.respond_to?(:collection)
167
+ collection_name = target_collection.to_s
168
+ if collection_name.strip.empty?
169
+ raise SearchEngine::Errors::InvalidParams.new(
170
+ "merge: cannot infer association for #{other.klass}",
171
+ hint: 'Declare a collection on the joined model or pass assoc: :name',
172
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#troubleshooting'
173
+ )
174
+ end
175
+
176
+ matches = cfgs.values.select { |cfg| cfg[:collection].to_s == collection_name }
177
+ if matches.empty?
178
+ available = cfgs.keys.map { |k| ":#{k}" }.join(', ')
179
+ hint = available.empty? ? 'Declare a join on the base model.' : "Available joins: #{available}."
180
+ raise SearchEngine::Errors::InvalidParams.new(
181
+ "merge: no join association for #{collection_name} on #{klass_name_for_inspect}",
182
+ hint: "#{hint} Pass assoc: :name to disambiguate.",
183
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#troubleshooting',
184
+ details: { target_collection: collection_name, available: cfgs.keys }
185
+ )
186
+ end
187
+
188
+ if matches.length > 1
189
+ names = matches.map { |cfg| cfg[:name].to_sym }
190
+ raise SearchEngine::Errors::InvalidParams.new(
191
+ "merge: ambiguous association for #{collection_name} on #{klass_name_for_inspect}",
192
+ hint: "Pass assoc: :#{names.first} (available: #{names.map(&:inspect).join(', ')})",
193
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#troubleshooting',
194
+ details: { target_collection: collection_name, matches: names }
195
+ )
196
+ end
197
+
198
+ matches.first[:name].to_sym
199
+ end
200
+
201
+ def merge_join_scopes(hash)
202
+ raise ArgumentError, 'merge: join-scope Hash must be non-empty' if hash.empty?
203
+
204
+ out_nodes = []
205
+ hash.each do |assoc, scope_value|
206
+ unless join_scope_value?(scope_value)
207
+ raise SearchEngine::Errors::InvalidParams.new(
208
+ "merge: expected a join scope Symbol or Array<Symbol> for #{assoc.inspect}",
209
+ hint: 'Use merge(assoc: :scope) or merge(assoc: [:scope1, :scope2])',
210
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#join-scope',
211
+ details: { assoc: assoc, value: scope_value }
212
+ )
213
+ end
214
+ process_join_scope(assoc.to_sym, scope_value, out_nodes)
215
+ end
216
+
217
+ return self if out_nodes.empty?
218
+
219
+ spawn do |s|
220
+ s[:ast] = Array(s[:ast]) + out_nodes
221
+ s[:filters] = Array(s[:filters])
222
+ end
223
+ end
224
+
225
+ # Build AST nodes, rewriting:
226
+ # - empty-array predicates to hidden *_empty flags when enabled (existing behavior)
227
+ # - nil predicates to hidden *_blank flags when `optional` is enabled (new behavior)
228
+ # Delegates other inputs to the DSL parser.
229
+ def build_ast_with_empty_array_rewrites(args, negated: false)
230
+ items = Array(args).flatten.compact
231
+ return [] if items.empty?
232
+
233
+ out_nodes = []
234
+ non_hash_items = []
235
+
236
+ items.each do |entry|
237
+ if entry.is_a?(Hash)
238
+ process_hash_entry(entry, out_nodes, negated)
239
+ else
240
+ non_hash_items << entry
241
+ end
242
+ end
243
+
244
+ unless non_hash_items.empty?
245
+ out_nodes.concat(
246
+ Array(SearchEngine::DSL::Parser.parse_list(non_hash_items, klass: @klass, joins: joins_list))
247
+ )
248
+ end
249
+
250
+ out_nodes.flatten.compact
251
+ end
252
+
253
+ def process_hash_entry(entry, out_nodes, negated)
254
+ entry.each do |k, v|
255
+ # Join-scope shorthand: where(assoc: :scope) or where(assoc: [:s1, :s2])
256
+ if join_scope_value?(v) && join_assoc?(k)
257
+ process_join_scope(k.to_sym, v, out_nodes)
258
+ elsif v.is_a?(Hash)
259
+ process_join_predicate(k, v, out_nodes, negated)
260
+ else
261
+ process_base_predicate(k, v, out_nodes, negated)
262
+ end
263
+ end
264
+ end
265
+
266
+ def process_join_predicate(assoc_key, values_hash, out_nodes, negated)
267
+ assoc = assoc_key.to_sym
268
+ values_hash.each do |inner_field, inner_value|
269
+ field_sym = inner_field.to_sym
270
+ if inner_value.nil?
271
+ emit_nil_flags_for_join(out_nodes, assoc, field_sym, negated)
272
+ elsif array_like?(inner_value)
273
+ arr = Array(inner_value).flatten(1).compact
274
+ if arr.empty?
275
+ if joined_empty_filtering_enabled?(assoc, field_sym)
276
+ emit_empty_array_flag(out_nodes, "$#{assoc}.#{field_sym}_empty", negated)
277
+ else
278
+ raise_empty_array_type!(field_sym)
279
+ end
280
+ else
281
+ out_nodes << SearchEngine::DSL::Parser.parse(
282
+ { assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
283
+ )
284
+ end
285
+ else
286
+ out_nodes << SearchEngine::DSL::Parser.parse(
287
+ { assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
288
+ )
289
+ end
290
+ end
291
+ end
292
+
293
+ def process_base_predicate(field_key, value, out_nodes, negated)
294
+ field = field_key.to_sym
295
+ if value.nil?
296
+ emit_nil_flags_for_base(out_nodes, field, negated)
297
+ elsif array_like?(value)
298
+ arr = Array(value).flatten(1).compact
299
+ if arr.empty?
300
+ if base_empty_filtering_enabled?(field)
301
+ emit_empty_array_flag(out_nodes, "#{field}_empty", negated)
302
+ else
303
+ raise_empty_array_type!(field)
304
+ end
305
+ else
306
+ out_nodes << SearchEngine::DSL::Parser.parse({ field => value }, klass: @klass, joins: joins_list)
307
+ end
308
+ else
309
+ out_nodes << SearchEngine::DSL::Parser.parse({ field => value }, klass: @klass, joins: joins_list)
310
+ end
311
+ end
312
+
313
+ # -- join-scope support -------------------------------------------------
314
+
315
+ # True when the given where value is a Symbol or an Array of Symbols.
316
+ # Accepts [:scope1, :scope2] and :scope forms only.
317
+ def join_scope_value?(value)
318
+ return true if value.is_a?(Symbol)
319
+
320
+ value.is_a?(Array) && value.all? { |el| el.is_a?(Symbol) }
321
+ end
322
+
323
+ # True when the key refers to a declared join association on @klass.
324
+ # Returns the association config Hash when present; falsey otherwise.
325
+ def join_assoc?(key)
326
+ @klass.join_for(key)
327
+ rescue StandardError
328
+ nil
329
+ end
330
+
331
+ # Process where(assoc: :scope) or where(assoc: [:s1, :s2]) by taking the AST
332
+ # produced by the target model's scope(s) and rewriting their fields into
333
+ # joined predicates (e.g., "$assoc.field"). Supports all comparison/node types;
334
+ # nested joins inside the scope are rejected and raw fragments are wrapped
335
+ # into join-scoped expressions when safe.
336
+ def process_join_scope(assoc_sym, scope_value, out_nodes)
337
+ assoc = assoc_sym.to_sym
338
+
339
+ # Validate join exists and is applied on this relation
340
+ cfg = @klass.join_for(assoc)
341
+ SearchEngine::Joins::Guard.ensure_join_applied!(joins_list, assoc, context: 'where join-scope')
342
+
343
+ collection = cfg[:collection]
344
+ target_klass = SearchEngine.collection_for(collection)
345
+
346
+ scope_names = Array(scope_value).flatten.compact
347
+ scope_names.each do |sname|
348
+ sym = sname.to_sym
349
+
350
+ unless target_klass.respond_to?(sym)
351
+ raise SearchEngine::Errors::InvalidParams.new(
352
+ %(Unknown join-scope :#{sym} on association :#{assoc} for #{target_klass}),
353
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#join-scope'
354
+ )
355
+ end
356
+
357
+ rel = target_klass.public_send(sym)
358
+ unless rel.is_a?(SearchEngine::Relation)
359
+ raise SearchEngine::Errors::InvalidParams.new(
360
+ %(join-scope :#{sym} on :#{assoc} must return a SearchEngine::Relation (got #{rel.class})),
361
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#join-scope'
362
+ )
363
+ end
364
+
365
+ nodes = Array(rel.send(:ast)).flatten.compact
366
+ next if nodes.empty?
367
+
368
+ rewritten = rewrite_join_scope_nodes(nodes, assoc, cfg)
369
+ out_nodes.concat(Array(rewritten))
370
+ end
371
+ end
372
+
373
+ # Rewrite a list of AST nodes so that any base-field predicate like
374
+ # field OP value
375
+ # becomes a joined predicate
376
+ # "$assoc.field" OP value
377
+ # Boolean/grouping nodes are rewritten recursively. Raw fragments are
378
+ # wrapped into a join-scoped expression when safe; pre-joined fields
379
+ # inside the scope are rejected.
380
+ def rewrite_join_scope_nodes(nodes, assoc_sym, assoc_cfg)
381
+ Array(nodes).flatten.compact.map { |n| rewrite_join_scope_node(n, assoc_sym, assoc_cfg) }
382
+ end
383
+
384
+ def rewrite_join_scope_node(node, assoc_sym, assoc_cfg)
385
+ case node
386
+ when SearchEngine::AST::And
387
+ children = node.children.map { |ch| rewrite_join_scope_node(ch, assoc_sym, assoc_cfg) }
388
+ SearchEngine::AST.and_(*children)
389
+ when SearchEngine::AST::Or
390
+ children = node.children.map { |ch| rewrite_join_scope_node(ch, assoc_sym, assoc_cfg) }
391
+ SearchEngine::AST.or_(*children)
392
+ when SearchEngine::AST::Group
393
+ inner = Array(node.children).first
394
+ SearchEngine::AST.group(rewrite_join_scope_node(inner, assoc_sym, assoc_cfg))
395
+ when SearchEngine::AST::Raw
396
+ fragment = node.fragment
397
+ if fragment.include?('$')
398
+ raise SearchEngine::Errors::InvalidParams.new(
399
+ 'join-scope raw fragments must use base fields only (no nested join paths)',
400
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#join-scope',
401
+ details: { fragment: fragment, assoc: assoc_sym }
402
+ )
403
+ end
404
+ SearchEngine::AST.raw("$#{assoc_sym}(#{fragment})")
405
+ when SearchEngine::AST::Eq,
406
+ SearchEngine::AST::NotEq,
407
+ SearchEngine::AST::Gt,
408
+ SearchEngine::AST::Gte,
409
+ SearchEngine::AST::Lt,
410
+ SearchEngine::AST::Lte,
411
+ SearchEngine::AST::In,
412
+ SearchEngine::AST::NotIn,
413
+ SearchEngine::AST::Matches,
414
+ SearchEngine::AST::Prefix
415
+ lhs = node.field.to_s
416
+ if lhs.start_with?('$') || lhs.include?('.')
417
+ raise SearchEngine::Errors::InvalidParams.new(
418
+ %(join-scope cannot reference nested join field #{lhs.inspect}; use base fields only),
419
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#join-scope',
420
+ details: { field: lhs, assoc: assoc_sym }
421
+ )
422
+ end
423
+
424
+ # Best-effort field validation against target collection
425
+ begin
426
+ SearchEngine::Joins::Guard.validate_joined_field!(assoc_cfg, lhs, source_klass: @klass)
427
+ rescue StandardError
428
+ nil
429
+ end
430
+
431
+ joined_lhs = "$#{assoc_sym}.#{lhs}"
432
+ builder = case node
433
+ when SearchEngine::AST::Eq then :eq
434
+ when SearchEngine::AST::NotEq then :not_eq
435
+ when SearchEngine::AST::Gt then :gt
436
+ when SearchEngine::AST::Gte then :gte
437
+ when SearchEngine::AST::Lt then :lt
438
+ when SearchEngine::AST::Lte then :lte
439
+ when SearchEngine::AST::In then :in_
440
+ when SearchEngine::AST::NotIn then :not_in
441
+ when SearchEngine::AST::Matches then :matches
442
+ when SearchEngine::AST::Prefix then :prefix
443
+ end
444
+
445
+ SearchEngine::AST.public_send(builder, joined_lhs, node.right)
446
+ else
447
+ # Unknown node type: keep as-is (defensive)
448
+ node
449
+ end
450
+ end
451
+
452
+ def emit_empty_array_flag(out_nodes, lhs, negated)
453
+ out_nodes << SearchEngine::AST.raw("#{lhs}:=#{negated ? 'false' : 'true'}")
454
+ end
455
+
456
+ def base_empty_filtering_enabled?(field_sym)
457
+ opts = @klass.respond_to?(:attribute_options) ? (@klass.attribute_options || {}) : {}
458
+ o = opts[field_sym]
459
+ o.is_a?(Hash) && o[:empty_filtering]
460
+ rescue StandardError
461
+ false
462
+ end
463
+
464
+ def joined_empty_filtering_enabled?(assoc_sym, field_sym)
465
+ cfg = @klass.join_for(assoc_sym)
466
+ collection = cfg[:collection]
467
+ return false if collection.nil? || collection.to_s.strip.empty?
468
+
469
+ target_klass = SearchEngine.collection_for(collection)
470
+ return false unless target_klass.respond_to?(:attribute_options)
471
+
472
+ o = (target_klass.attribute_options || {})[field_sym]
473
+ o.is_a?(Hash) && o[:empty_filtering]
474
+ rescue StandardError
475
+ false
476
+ end
477
+
478
+ def base_optional_enabled?(field_sym)
479
+ opts = @klass.respond_to?(:attribute_options) ? (@klass.attribute_options || {}) : {}
480
+ o = opts[field_sym]
481
+ o.is_a?(Hash) && o[:optional]
482
+ rescue StandardError
483
+ false
484
+ end
485
+
486
+ def joined_optional_enabled?(assoc_sym, field_sym)
487
+ cfg = @klass.join_for(assoc_sym)
488
+ collection = cfg[:collection]
489
+ return false if collection.nil? || collection.to_s.strip.empty?
490
+
491
+ target_klass = SearchEngine.collection_for(collection)
492
+ return false unless target_klass.respond_to?(:attribute_options)
493
+
494
+ o = (target_klass.attribute_options || {})[field_sym]
495
+ o.is_a?(Hash) && o[:optional]
496
+ rescue StandardError
497
+ false
498
+ end
499
+
500
+ def emit_nil_flags_for_base(out_nodes, field_sym, negated)
501
+ has_empty = base_empty_filtering_enabled?(field_sym)
502
+ has_blank = base_optional_enabled?(field_sym)
503
+ fragment = nil
504
+ if has_empty && has_blank
505
+ fragment = if negated
506
+ "(#{field_sym}_empty:=false && #{field_sym}_blank:=false)"
507
+ else
508
+ "(#{field_sym}_empty:=true || #{field_sym}_blank:=true)"
509
+ end
510
+ elsif has_blank
511
+ fragment = "#{field_sym}_blank:=#{negated ? 'false' : 'true'}"
512
+ elsif has_empty
513
+ fragment = "#{field_sym}_empty:=#{negated ? 'false' : 'true'}"
514
+ end
515
+
516
+ out_nodes << if fragment
517
+ SearchEngine::AST.raw(fragment)
518
+ else
519
+ SearchEngine::DSL::Parser.parse({ field_sym => nil }, klass: @klass, joins: joins_list)
520
+ end
521
+ end
522
+
523
+ def emit_nil_flags_for_join(out_nodes, assoc_sym, field_sym, negated)
524
+ has_empty = joined_empty_filtering_enabled?(assoc_sym, field_sym)
525
+ has_blank = joined_optional_enabled?(assoc_sym, field_sym)
526
+ lhs_empty = "$#{assoc_sym}.#{field_sym}_empty"
527
+ lhs_blank = "$#{assoc_sym}.#{field_sym}_blank"
528
+ fragment = nil
529
+ if has_empty && has_blank
530
+ fragment = if negated
531
+ "(#{lhs_empty}:=false && #{lhs_blank}:=false)"
532
+ else
533
+ "(#{lhs_empty}:=true || #{lhs_blank}:=true)"
534
+ end
535
+ elsif has_blank
536
+ fragment = "#{lhs_blank}:=#{negated ? 'false' : 'true'}"
537
+ elsif has_empty
538
+ fragment = "#{lhs_empty}:=#{negated ? 'false' : 'true'}"
539
+ end
540
+
541
+ if fragment
542
+ out_nodes << SearchEngine::AST.raw(fragment)
543
+ else
544
+ parsed = { assoc_sym => { field_sym => nil } }
545
+ out_nodes << SearchEngine::DSL::Parser.parse(parsed, klass: @klass, joins: joins_list)
546
+ end
547
+ end
548
+
549
+ def array_like?(value)
550
+ value.is_a?(Array)
551
+ end
552
+
553
+ def raise_empty_array_type!(field_sym)
554
+ raise SearchEngine::Errors::InvalidType.new(
555
+ %(expected #{field_sym.inspect} to be a non-empty Array),
556
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
557
+ details: { field: field_sym }
558
+ )
559
+ end
560
+
561
+ # Normalize where arguments into an array of string fragments safe for Typesense.
562
+ def normalize_where(args)
563
+ list = Array(args).flatten.compact
564
+ return [] if list.empty?
565
+
566
+ fragments = []
567
+ i = 0
568
+ known_attrs = safe_attributes_map
569
+
570
+ while i < list.length
571
+ entry = list[i]
572
+ case entry
573
+ when Hash
574
+ # Validate only base-like keys here; assoc keys (values as Hash) are handled via AST/Parser
575
+ # and assoc keys with join-scope shorthand (values as Symbol/Array<Symbol>) are ignored for fragments.
576
+ base_like_pairs = entry.reject { |_, v| v.is_a?(Hash) || join_scope_value?(v) }
577
+ validate_hash_keys!(base_like_pairs, known_attrs)
578
+ # Build fragments from base scalar/array pairs only; skip assoc=>{...} and assoc=>:scope
579
+ base_pairs = base_like_pairs
580
+ unless base_pairs.empty?
581
+ fragments.concat(
582
+ SearchEngine::Filters::Sanitizer.build_from_hash(base_pairs, known_attrs)
583
+ )
584
+ end
585
+ i += 1
586
+ when String
587
+ i = normalize_where_process_string!(fragments, entry, list, i)
588
+ when Symbol
589
+ fragments << entry.to_s
590
+ i += 1
591
+ when Array
592
+ nested = normalize_where(entry)
593
+ fragments.concat(nested)
594
+ i += 1
595
+ else
596
+ raise ArgumentError, "unsupported where argument of type #{entry.class}"
597
+ end
598
+ end
599
+
600
+ fragments
601
+ end
602
+
603
+ def normalize_where_process_string!(fragments, entry, list, i)
604
+ if entry.match?(/(?<!\\)\?/) # has unescaped placeholders
605
+ tail = list[(i + 1)..] || []
606
+ needed = SearchEngine::Filters::Sanitizer.count_placeholders(entry)
607
+ args_for_template = tail.first(needed)
608
+ if args_for_template.length != needed
609
+ raise ArgumentError, "expected #{needed} args for #{needed} placeholders, got #{args_for_template.length}"
610
+ end
611
+
612
+ fragments << SearchEngine::Filters::Sanitizer.apply_placeholders(entry, args_for_template)
613
+ i + 1 + needed
614
+ else
615
+ fragments << entry.to_s
616
+ i + 1
617
+ end
618
+ end
619
+
620
+ # (no-op helpers; reference field coercion is handled by the compiler and schema)
621
+ end
622
+ end
623
+ end
624
+ end