search-engine-for-typesense 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/app/search_engine/search_engine/app_info.rb +11 -0
- data/app/search_engine/search_engine/index_partition_job.rb +170 -0
- data/lib/generators/search_engine/install/install_generator.rb +20 -0
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
- data/lib/generators/search_engine/model/model_generator.rb +86 -0
- data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
- data/lib/search-engine-for-typesense.rb +12 -0
- data/lib/search_engine/active_record_syncable.rb +247 -0
- data/lib/search_engine/admin/stopwords.rb +125 -0
- data/lib/search_engine/admin/synonyms.rb +125 -0
- data/lib/search_engine/admin.rb +12 -0
- data/lib/search_engine/ast/and.rb +52 -0
- data/lib/search_engine/ast/binary_op.rb +75 -0
- data/lib/search_engine/ast/eq.rb +19 -0
- data/lib/search_engine/ast/group.rb +18 -0
- data/lib/search_engine/ast/gt.rb +12 -0
- data/lib/search_engine/ast/gte.rb +12 -0
- data/lib/search_engine/ast/in.rb +28 -0
- data/lib/search_engine/ast/lt.rb +12 -0
- data/lib/search_engine/ast/lte.rb +12 -0
- data/lib/search_engine/ast/matches.rb +55 -0
- data/lib/search_engine/ast/node.rb +176 -0
- data/lib/search_engine/ast/not_eq.rb +13 -0
- data/lib/search_engine/ast/not_in.rb +24 -0
- data/lib/search_engine/ast/or.rb +52 -0
- data/lib/search_engine/ast/prefix.rb +51 -0
- data/lib/search_engine/ast/raw.rb +41 -0
- data/lib/search_engine/ast/unary_op.rb +43 -0
- data/lib/search_engine/ast.rb +101 -0
- data/lib/search_engine/base/creation.rb +727 -0
- data/lib/search_engine/base/deletion.rb +80 -0
- data/lib/search_engine/base/display_coercions.rb +36 -0
- data/lib/search_engine/base/hydration.rb +312 -0
- data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
- data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
- data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
- data/lib/search_engine/base/index_maintenance.rb +459 -0
- data/lib/search_engine/base/indexing_dsl.rb +255 -0
- data/lib/search_engine/base/joins.rb +479 -0
- data/lib/search_engine/base/model_dsl.rb +472 -0
- data/lib/search_engine/base/presets.rb +43 -0
- data/lib/search_engine/base/pretty_printer.rb +315 -0
- data/lib/search_engine/base/relation_delegation.rb +42 -0
- data/lib/search_engine/base/scopes.rb +113 -0
- data/lib/search_engine/base/updating.rb +92 -0
- data/lib/search_engine/base.rb +38 -0
- data/lib/search_engine/bulk.rb +284 -0
- data/lib/search_engine/cache.rb +33 -0
- data/lib/search_engine/cascade.rb +531 -0
- data/lib/search_engine/cli/doctor.rb +631 -0
- data/lib/search_engine/cli/support.rb +217 -0
- data/lib/search_engine/cli.rb +222 -0
- data/lib/search_engine/client/http_adapter.rb +63 -0
- data/lib/search_engine/client/request_builder.rb +92 -0
- data/lib/search_engine/client/services/base.rb +74 -0
- data/lib/search_engine/client/services/collections.rb +161 -0
- data/lib/search_engine/client/services/documents.rb +214 -0
- data/lib/search_engine/client/services/operations.rb +152 -0
- data/lib/search_engine/client/services/search.rb +190 -0
- data/lib/search_engine/client/services.rb +29 -0
- data/lib/search_engine/client.rb +765 -0
- data/lib/search_engine/client_options.rb +20 -0
- data/lib/search_engine/collection_resolver.rb +191 -0
- data/lib/search_engine/collections_graph.rb +330 -0
- data/lib/search_engine/compiled_params.rb +143 -0
- data/lib/search_engine/compiler.rb +383 -0
- data/lib/search_engine/config/observability.rb +27 -0
- data/lib/search_engine/config/presets.rb +92 -0
- data/lib/search_engine/config/selection.rb +16 -0
- data/lib/search_engine/config/typesense.rb +48 -0
- data/lib/search_engine/config/validators.rb +97 -0
- data/lib/search_engine/config.rb +917 -0
- data/lib/search_engine/console_helpers.rb +130 -0
- data/lib/search_engine/deletion.rb +103 -0
- data/lib/search_engine/dispatcher.rb +125 -0
- data/lib/search_engine/dsl/parser.rb +582 -0
- data/lib/search_engine/engine.rb +167 -0
- data/lib/search_engine/errors.rb +290 -0
- data/lib/search_engine/filters/sanitizer.rb +189 -0
- data/lib/search_engine/hydration/materializers.rb +808 -0
- data/lib/search_engine/hydration/selection_context.rb +96 -0
- data/lib/search_engine/indexer/batch_planner.rb +76 -0
- data/lib/search_engine/indexer/bulk_import.rb +626 -0
- data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
- data/lib/search_engine/indexer/retry_policy.rb +103 -0
- data/lib/search_engine/indexer.rb +747 -0
- data/lib/search_engine/instrumentation.rb +308 -0
- data/lib/search_engine/joins/guard.rb +202 -0
- data/lib/search_engine/joins/resolver.rb +95 -0
- data/lib/search_engine/logging/color.rb +78 -0
- data/lib/search_engine/logging/format_helpers.rb +92 -0
- data/lib/search_engine/logging/partition_progress.rb +53 -0
- data/lib/search_engine/logging_subscriber.rb +388 -0
- data/lib/search_engine/mapper.rb +785 -0
- data/lib/search_engine/multi.rb +286 -0
- data/lib/search_engine/multi_result.rb +186 -0
- data/lib/search_engine/notifications/compact_logger.rb +675 -0
- data/lib/search_engine/observability.rb +162 -0
- data/lib/search_engine/operations.rb +58 -0
- data/lib/search_engine/otel.rb +227 -0
- data/lib/search_engine/partitioner.rb +128 -0
- data/lib/search_engine/ranking_plan.rb +118 -0
- data/lib/search_engine/registry.rb +158 -0
- data/lib/search_engine/relation/compiler.rb +711 -0
- data/lib/search_engine/relation/deletion.rb +37 -0
- data/lib/search_engine/relation/dsl/filters.rb +624 -0
- data/lib/search_engine/relation/dsl/selection.rb +240 -0
- data/lib/search_engine/relation/dsl.rb +903 -0
- data/lib/search_engine/relation/dx/dry_run.rb +59 -0
- data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
- data/lib/search_engine/relation/dx.rb +231 -0
- data/lib/search_engine/relation/materializers.rb +118 -0
- data/lib/search_engine/relation/options.rb +138 -0
- data/lib/search_engine/relation/state.rb +274 -0
- data/lib/search_engine/relation/updating.rb +44 -0
- data/lib/search_engine/relation.rb +623 -0
- data/lib/search_engine/result.rb +664 -0
- data/lib/search_engine/schema.rb +1083 -0
- data/lib/search_engine/sources/active_record_source.rb +185 -0
- data/lib/search_engine/sources/base.rb +62 -0
- data/lib/search_engine/sources/lambda_source.rb +55 -0
- data/lib/search_engine/sources/sql_source.rb +196 -0
- data/lib/search_engine/sources.rb +71 -0
- data/lib/search_engine/stale_rules.rb +160 -0
- data/lib/search_engine/test/minitest_assertions.rb +57 -0
- data/lib/search_engine/test/offline_client.rb +134 -0
- data/lib/search_engine/test/rspec_matchers.rb +77 -0
- data/lib/search_engine/test/stub_client.rb +201 -0
- data/lib/search_engine/test.rb +66 -0
- data/lib/search_engine/test_autoload.rb +8 -0
- data/lib/search_engine/update.rb +35 -0
- data/lib/search_engine/version.rb +7 -0
- data/lib/search_engine.rb +332 -0
- data/lib/tasks/search_engine.rake +501 -0
- data/lib/tasks/search_engine_doctor.rake +16 -0
- metadata +225 -0
|
@@ -0,0 +1,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
|