search-engine-for-typesense 30.1.8.23 → 30.8.24
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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +37 -8
- data/lib/generators/search_engine/model/model_generator.rb +3 -1
- data/lib/generators/search_engine/model/templates/model.rb.tt +1 -0
- data/lib/search_engine/active_record_syncable.rb +2 -2
- data/lib/search_engine/base/creation.rb +3 -2
- data/lib/search_engine/hydration/materializers.rb +5 -420
- data/lib/search_engine/indexer/bulk_import.rb +15 -7
- data/lib/search_engine/mapper.rb +2 -8
- data/lib/search_engine/sources/sql_source.rb +15 -27
- data/lib/search_engine/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db1ad7dbacfe019861ce7dbe7f3511326d67f0afc8bbf22ed2929cdb300797ad
|
|
4
|
+
data.tar.gz: 5bfafebc3623ee781a1c519c799621bfb8ca62b19f21e6370e79d4d165bc3999
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1ce3108ba2a8ef685803cf06264e88a9565dfe7e640f983465f198a03ba2f5b4cf059555c00b2e1016fa38f49aadb6be6bc7bcc538b5564a46c922e103f6db5
|
|
7
|
+
data.tar.gz: 9a2ddb0b81c46d8c81ce06e622f4b16adf1f81f50a4f4ccf2ff756c3d2542cf8d90111d31b7412f77e88dd441d7429106779457033e30bfc0c8d6b12e169342f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 30.8.24
|
|
4
|
+
|
|
5
|
+
- Adopt `{typesense_major}.{gem_minor}.{gem_patch}` versioning, omitting the Typesense minor component.
|
|
6
|
+
- Include the same runtime fixes as 30.1.8.24; no additional runtime behavior changes.
|
|
7
|
+
|
|
8
|
+
## 30.1.8.24
|
|
9
|
+
|
|
10
|
+
- Preserve caller transactions during PostgreSQL streaming by rejecting active transactions before opening a cursor.
|
|
11
|
+
Apply statement timeouts inside the streaming transaction and restore connection settings on completion or failure.
|
|
12
|
+
- Synchronize both record creation and updates after commit, and propagate failed custom document identity calculations.
|
|
13
|
+
- Propagate parallel import worker failures and timeouts instead of returning successful summaries for incomplete imports.
|
|
14
|
+
- Execute ID-filtered queries through normal search materialization so text filters, selection, and memoization apply.
|
|
15
|
+
- Use native Typesense joins without incomplete client-side key lookups. Missing references and invalid search
|
|
16
|
+
configurations now raise errors instead of returning empty results. Existing joins require valid server references.
|
|
17
|
+
- Allow upserts to omit server-generated embeddings and preserve boolean coercions that produce false.
|
|
18
|
+
- Correct generated models, documented declarations, and demo collection references and timestamp mappings.
|
|
19
|
+
|
|
3
20
|
## 30.1.8.23
|
|
4
21
|
|
|
5
22
|
- Run retained-physical alias rollbacks through the same cutover guard. Rollback accepts explicit destination
|
data/README.md
CHANGED
|
@@ -8,9 +8,9 @@ Mountless Rails::Engine for [Typesense](https://typesense.org). Expressive Relat
|
|
|
8
8
|
|
|
9
9
|
## Versioning
|
|
10
10
|
|
|
11
|
-
The
|
|
11
|
+
Versions use `{typesense_major}.{gem_minor}.{gem_patch}`. The first component identifies the targeted Typesense server major version; the remaining components track gem enhancements and fixes independently of Typesense minor releases.
|
|
12
12
|
|
|
13
|
-
Example: `30.
|
|
13
|
+
Example: `30.8.24` targets Typesense `30.x`. Typesense minor versions are not encoded in the gem version.
|
|
14
14
|
|
|
15
15
|
## Quickstart
|
|
16
16
|
|
|
@@ -26,6 +26,7 @@ SearchEngine.configure do |c|
|
|
|
26
26
|
c.port = 8108
|
|
27
27
|
c.protocol = "http"
|
|
28
28
|
c.api_key = ENV.fetch("TYPESENSE_API_KEY")
|
|
29
|
+
c.default_infix = "off"
|
|
29
30
|
end
|
|
30
31
|
```
|
|
31
32
|
|
|
@@ -33,10 +34,10 @@ end
|
|
|
33
34
|
class SearchEngine::Product < SearchEngine::Base
|
|
34
35
|
collection :products
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
identify_by :id
|
|
37
38
|
attribute :name, :string
|
|
38
39
|
|
|
39
|
-
query_by
|
|
40
|
+
query_by :name
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
SearchEngine::Product.where(name: "milk").select(:id, :name).limit(5).to_a
|
|
@@ -58,22 +59,50 @@ SearchEngine.configure do |c|
|
|
|
58
59
|
end
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
SQL sources stream PostgreSQL queries in their own read-only transaction. Start streaming
|
|
63
|
+
outside an existing transaction; an active transaction is rejected before cursor SQL runs.
|
|
64
|
+
The configured statement timeout applies only to the streaming transaction.
|
|
65
|
+
|
|
66
|
+
Joined filters execute as native Typesense joins and require valid collection references.
|
|
67
|
+
Search configuration and missing-reference errors propagate to callers; they do not
|
|
68
|
+
produce empty results or trigger client-side joins.
|
|
69
|
+
|
|
61
70
|
## Usage examples
|
|
62
71
|
|
|
63
72
|
```ruby
|
|
64
|
-
#
|
|
73
|
+
# Models (the source objects below are the host application's ActiveRecord models)
|
|
74
|
+
class SearchEngine::Brand < SearchEngine::Base
|
|
75
|
+
collection "brands"
|
|
76
|
+
identify_by :id
|
|
77
|
+
attribute :name, :string
|
|
78
|
+
query_by :name
|
|
79
|
+
end
|
|
80
|
+
|
|
65
81
|
class SearchEngine::Product < SearchEngine::Base
|
|
66
82
|
collection "products"
|
|
67
83
|
|
|
68
|
-
|
|
84
|
+
identify_by :id
|
|
69
85
|
attribute :name, :string
|
|
86
|
+
attribute :price_cents, :integer, sort: true
|
|
87
|
+
attribute :brand_id, :string, facet: true
|
|
88
|
+
attribute :category, :string, facet: true
|
|
89
|
+
belongs_to :brands, collection: "brands", local_key: :brand_id, foreign_key: :id
|
|
90
|
+
query_by :name
|
|
91
|
+
|
|
92
|
+
index do
|
|
93
|
+
source :active_record, model: ::Product
|
|
94
|
+
map do |record|
|
|
95
|
+
{ name: record.name, price_cents: record.price_cents,
|
|
96
|
+
brand_id: record.brand_id.to_s, category: record.category }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
70
99
|
end
|
|
71
100
|
|
|
72
101
|
# Basic query
|
|
73
102
|
SearchEngine::Product
|
|
74
103
|
.where(name: "milk")
|
|
75
104
|
# Explicit query_by always wins over model/global defaults
|
|
76
|
-
.options(query_by: 'name
|
|
105
|
+
.options(query_by: 'name')
|
|
77
106
|
.select(:id, :name)
|
|
78
107
|
.order(price_cents: :asc)
|
|
79
108
|
.limit(5)
|
|
@@ -95,7 +124,7 @@ rel = SearchEngine::Product
|
|
|
95
124
|
params = rel.to_h # compiled Typesense params
|
|
96
125
|
|
|
97
126
|
# Multi-search
|
|
98
|
-
result_set = SearchEngine.multi_search(common: { query_by:
|
|
127
|
+
result_set = SearchEngine.multi_search(common: { query_by: "name" }) do |m|
|
|
99
128
|
m.add :products, SearchEngine::Product.where("name:~rud").per(10)
|
|
100
129
|
m.add :brands, SearchEngine::Brand.all.per(5)
|
|
101
130
|
end
|
|
@@ -48,12 +48,14 @@ module SearchEngine
|
|
|
48
48
|
return [] if raw.nil?
|
|
49
49
|
|
|
50
50
|
tokens = raw.split(/[\s,]+/).map(&:strip).reject(&:empty?)
|
|
51
|
-
tokens.
|
|
51
|
+
tokens.filter_map do |pair|
|
|
52
52
|
name, type = pair.split(':', 2)
|
|
53
53
|
raise Thor::Error, "invalid attribute token: #{pair.inspect} (expected name:type)" unless name
|
|
54
54
|
|
|
55
55
|
type = (type || 'string').to_s
|
|
56
56
|
normalized = normalize_type(type)
|
|
57
|
+
next if name.to_s.underscore == 'id'
|
|
58
|
+
|
|
57
59
|
[name.to_s.underscore, normalized]
|
|
58
60
|
end
|
|
59
61
|
end
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# - https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/vector-search
|
|
8
8
|
class SearchEngine::<%= class_name %> < SearchEngine::Base
|
|
9
9
|
collection "<%= @collection_name %>"
|
|
10
|
+
identify_by :id
|
|
10
11
|
<% Array(@attributes).each do |(name, type)| -%>
|
|
11
12
|
attribute :<%= name %>, :<%= type %>
|
|
12
13
|
<% end -%>
|
|
@@ -265,8 +265,8 @@ module SearchEngine
|
|
|
265
265
|
end
|
|
266
266
|
|
|
267
267
|
if timing == :after_commit
|
|
268
|
-
|
|
269
|
-
ar_klass.
|
|
268
|
+
upsert_actions = actions & %i[create update]
|
|
269
|
+
ar_klass.after_commit :__se_syncable_upsert!, on: upsert_actions unless upsert_actions.empty?
|
|
270
270
|
ar_klass.after_destroy_commit :__se_syncable_delete! if actions.include?(:destroy)
|
|
271
271
|
else
|
|
272
272
|
ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
|
|
@@ -111,7 +111,8 @@ module SearchEngine
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def compute_required_keys_from_schema(klass, compiled_schema)
|
|
114
|
-
fields = Array(compiled_schema[:fields]).
|
|
114
|
+
fields = Array(compiled_schema[:fields]).reject { |f| f[:embed] || f['embed'] }
|
|
115
|
+
.map { |f| (f[:name] || f['name']).to_s }
|
|
115
116
|
base = fields.reject { |fname| fname.include?('.') }.to_set
|
|
116
117
|
begin
|
|
117
118
|
opts = klass.respond_to?(:attribute_options) ? (klass.attribute_options || {}) : {}
|
|
@@ -229,7 +230,7 @@ module SearchEngine
|
|
|
229
230
|
next if value.nil? && optional_fields.include?(key.to_s)
|
|
230
231
|
|
|
231
232
|
valid, coerced, err = validate_value_for_type(expected, value, coercions_enabled: coercions_enabled)
|
|
232
|
-
if coerced
|
|
233
|
+
if !coerced.nil?
|
|
233
234
|
document[key.to_s] = coerced
|
|
234
235
|
elsif !valid
|
|
235
236
|
raise SearchEngine::Errors::InvalidParams.new(
|
|
@@ -28,43 +28,9 @@ module SearchEngine
|
|
|
28
28
|
params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
|
|
29
29
|
url_opts = relation.send(:build_url_opts)
|
|
30
30
|
|
|
31
|
-
raw_result =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
preflight_raw, relation, params = preflight_join_fallback_if_needed(relation, params)
|
|
35
|
-
|
|
36
|
-
begin
|
|
37
|
-
raw_result = preflight_raw || relation.send(:client).search(
|
|
38
|
-
collection: collection,
|
|
39
|
-
params: params,
|
|
40
|
-
url_opts: url_opts
|
|
41
|
-
)
|
|
42
|
-
rescue SearchEngine::Errors::Api => error
|
|
43
|
-
# Graceful empty fallback for infix/prefix configuration errors
|
|
44
|
-
if infix_missing_error?(error)
|
|
45
|
-
empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
|
|
46
|
-
raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
|
|
47
|
-
else
|
|
48
|
-
# Client-side join fallback: handle missing Typesense reference for joined filters
|
|
49
|
-
raise unless join_reference_missing_error?(error) && Array(relation.joins_list).any?
|
|
50
|
-
|
|
51
|
-
fallback_rel = build_client_side_join_fallback_relation(relation)
|
|
52
|
-
if fallback_rel.equal?(:__empty__)
|
|
53
|
-
# Short-circuit: no matches
|
|
54
|
-
empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
|
|
55
|
-
raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
|
|
56
|
-
else
|
|
57
|
-
# Retry with rewritten base relation
|
|
58
|
-
new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params)
|
|
59
|
-
raw_result = relation.send(:client).search(collection: collection, params: new_params,
|
|
60
|
-
url_opts: url_opts
|
|
61
|
-
)
|
|
62
|
-
instrument_client_side_fallback(relation)
|
|
63
|
-
# Replace relation for selection/facets context below
|
|
64
|
-
relation = fallback_rel
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
31
|
+
raw_result = relation.send(:client).search(
|
|
32
|
+
collection: collection, params: params, url_opts: url_opts
|
|
33
|
+
)
|
|
68
34
|
|
|
69
35
|
selection_ctx = SearchEngine::Hydration::SelectionContext.build(relation)
|
|
70
36
|
facets_ctx = build_facets_context_from_state(relation)
|
|
@@ -132,7 +98,7 @@ module SearchEngine
|
|
|
132
98
|
params = SearchEngine::CompiledParams.from(preview_relation.to_typesense_params)
|
|
133
99
|
url_opts = preview_relation.send(:build_url_opts)
|
|
134
100
|
|
|
135
|
-
raw_result =
|
|
101
|
+
raw_result = preview_relation.send(:client).search(collection: collection, params: params, url_opts: url_opts)
|
|
136
102
|
|
|
137
103
|
selection_ctx = SearchEngine::Hydration::SelectionContext.build(preview_relation)
|
|
138
104
|
facets_ctx = build_facets_context_from_state(preview_relation)
|
|
@@ -153,58 +119,12 @@ module SearchEngine
|
|
|
153
119
|
array
|
|
154
120
|
end
|
|
155
121
|
|
|
156
|
-
# Internal: perform preview search, applying client-side fallback when Typesense
|
|
157
|
-
# reports a missing reference for joined filters.
|
|
158
|
-
# @param preview_relation [SearchEngine::Relation]
|
|
159
|
-
# @param collection [String]
|
|
160
|
-
# @param params [SearchEngine::CompiledParams]
|
|
161
|
-
# @param url_opts [Hash]
|
|
162
|
-
# @return [Object] raw result from client
|
|
163
|
-
def perform_preview_search_with_fallback(preview_relation, collection, params, url_opts)
|
|
164
|
-
preview_relation.send(:client).search(
|
|
165
|
-
collection: collection,
|
|
166
|
-
params: params,
|
|
167
|
-
url_opts: url_opts
|
|
168
|
-
)
|
|
169
|
-
rescue SearchEngine::Errors::Api => error
|
|
170
|
-
# Graceful empty fallback for infix/prefix configuration errors
|
|
171
|
-
if infix_missing_error?(error)
|
|
172
|
-
empty_raw = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
|
|
173
|
-
return SearchEngine::Result.new(empty_raw, klass: preview_relation.klass)
|
|
174
|
-
end
|
|
175
|
-
raise unless join_reference_missing_error?(error) && Array(preview_relation.joins_list).any?
|
|
176
|
-
|
|
177
|
-
fallback_rel = build_client_side_join_fallback_relation(preview_relation)
|
|
178
|
-
if fallback_rel.equal?(:__empty__)
|
|
179
|
-
empty_raw = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
|
|
180
|
-
return SearchEngine::Result.new(empty_raw, klass: preview_relation.klass)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params)
|
|
184
|
-
res = preview_relation.send(:client).search(
|
|
185
|
-
collection: collection,
|
|
186
|
-
params: new_params,
|
|
187
|
-
url_opts: url_opts
|
|
188
|
-
)
|
|
189
|
-
instrument_client_side_fallback(preview_relation)
|
|
190
|
-
res
|
|
191
|
-
end
|
|
192
|
-
|
|
193
122
|
def each(relation, &block)
|
|
194
123
|
arr = to_a(relation)
|
|
195
124
|
block_given? ? arr.each(&block) : arr.each
|
|
196
125
|
end
|
|
197
126
|
|
|
198
127
|
def first(relation, n = nil)
|
|
199
|
-
# Fast path: when relation has a single equality predicate on id and n=nil, use document GET
|
|
200
|
-
if n.nil?
|
|
201
|
-
id_value = detect_equality_id_predicate_value(relation)
|
|
202
|
-
if id_value
|
|
203
|
-
doc = retrieve_document_by_id(relation, id_value)
|
|
204
|
-
return doc unless doc.nil?
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
128
|
arr = to_a(relation)
|
|
209
129
|
return arr.first if n.nil?
|
|
210
130
|
|
|
@@ -340,25 +260,7 @@ module SearchEngine
|
|
|
340
260
|
minimal[:include_fields] = 'id'
|
|
341
261
|
|
|
342
262
|
url_opts = relation.send(:build_url_opts)
|
|
343
|
-
|
|
344
|
-
result = relation.send(:client).search(collection: collection, params: minimal, url_opts: url_opts)
|
|
345
|
-
rescue SearchEngine::Errors::Api => error
|
|
346
|
-
return 0 if infix_missing_error?(error)
|
|
347
|
-
# Client-side join fallback: handle missing Typesense reference for joined filters in count path
|
|
348
|
-
raise unless join_reference_missing_error?(error) && Array(relation.joins_list).any?
|
|
349
|
-
|
|
350
|
-
fallback_rel = build_client_side_join_fallback_relation(relation)
|
|
351
|
-
return 0 if fallback_rel.equal?(:__empty__)
|
|
352
|
-
|
|
353
|
-
new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params).to_h
|
|
354
|
-
new_minimal = new_params.dup
|
|
355
|
-
new_minimal[:per_page] = 1
|
|
356
|
-
new_minimal[:page] = 1
|
|
357
|
-
new_minimal[:include_fields] = 'id'
|
|
358
|
-
|
|
359
|
-
result = relation.send(:client).search(collection: collection, params: new_minimal, url_opts: url_opts)
|
|
360
|
-
instrument_client_side_fallback(relation)
|
|
361
|
-
end
|
|
263
|
+
result = relation.send(:client).search(collection: collection, params: minimal, url_opts: url_opts)
|
|
362
264
|
|
|
363
265
|
count = result.found.to_i
|
|
364
266
|
relation.send(:enforce_hit_validator_if_needed!, count, collection: collection)
|
|
@@ -381,296 +283,6 @@ module SearchEngine
|
|
|
381
283
|
end
|
|
382
284
|
module_function :curated_hits_count
|
|
383
285
|
|
|
384
|
-
# Detect Typesense 400 errors caused by missing infix/prefix configuration
|
|
385
|
-
# e.g., "Could not find `name` in the infix index. Make sure to enable infix search by specifying `infix: true` in the schema."
|
|
386
|
-
def infix_missing_error?(error)
|
|
387
|
-
return false unless error.is_a?(SearchEngine::Errors::Api)
|
|
388
|
-
|
|
389
|
-
status = error.status.to_i
|
|
390
|
-
return false unless status == 400
|
|
391
|
-
|
|
392
|
-
body = error.body
|
|
393
|
-
msg = error.message.to_s
|
|
394
|
-
# Match common phrasing from Typesense for missing infix/prefix index
|
|
395
|
-
need_infix = 'infix index'
|
|
396
|
-
enable_infix = 'enable infix'
|
|
397
|
-
could_not_find = 'Could not find'
|
|
398
|
-
missing_prefix = 'prefix index'
|
|
399
|
-
(
|
|
400
|
-
body.is_a?(String) && (body.include?(need_infix) ||
|
|
401
|
-
body.include?(enable_infix) || body.include?(missing_prefix))
|
|
402
|
-
) ||
|
|
403
|
-
msg.include?(need_infix) || msg.include?(enable_infix) ||
|
|
404
|
-
msg.include?(missing_prefix) || msg.include?(could_not_find)
|
|
405
|
-
rescue StandardError
|
|
406
|
-
false
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
# --- client-side join fallback helpers ---------------------------------
|
|
410
|
-
|
|
411
|
-
# True when the relation uses joined fields in filters and the base
|
|
412
|
-
# collection schema lacks a matching reference for at least one of
|
|
413
|
-
# those associations.
|
|
414
|
-
def join_fallback_preflight_required?(relation)
|
|
415
|
-
state = relation.instance_variable_get(:@state) || {}
|
|
416
|
-
ast_nodes = Array(state[:ast]).flatten.compact
|
|
417
|
-
assocs = extract_join_assocs_from_ast(ast_nodes)
|
|
418
|
-
return false if assocs.empty?
|
|
419
|
-
|
|
420
|
-
base_klass = relation.klass
|
|
421
|
-
compiled = SearchEngine::Schema.compile(base_klass)
|
|
422
|
-
fields = Array(compiled[:fields])
|
|
423
|
-
by_name = {}
|
|
424
|
-
fields.each do |f|
|
|
425
|
-
name = (f[:name] || f['name']).to_s
|
|
426
|
-
by_name[name] = f
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
assocs.any? do |assoc|
|
|
430
|
-
begin
|
|
431
|
-
cfg = base_klass.join_for(assoc)
|
|
432
|
-
lk = (cfg[:local_key] || '').to_s
|
|
433
|
-
fk = (cfg[:foreign_key] || '').to_s
|
|
434
|
-
coll = (cfg[:collection] || '').to_s
|
|
435
|
-
expected = "#{coll}.#{fk}"
|
|
436
|
-
entry = by_name[lk]
|
|
437
|
-
actual = entry && (entry[:reference] || entry['reference'])
|
|
438
|
-
actual_str = actual.to_s
|
|
439
|
-
# Accept async suffix on actual
|
|
440
|
-
next true if actual_str.empty? || !actual_str.start_with?(expected)
|
|
441
|
-
rescue StandardError
|
|
442
|
-
next true
|
|
443
|
-
end
|
|
444
|
-
false
|
|
445
|
-
end
|
|
446
|
-
rescue StandardError
|
|
447
|
-
false
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# Walk AST nodes and collect association names used via "$assoc.field".
|
|
451
|
-
def extract_join_assocs_from_ast(nodes)
|
|
452
|
-
list = Array(nodes).flatten.compact
|
|
453
|
-
return [] if list.empty?
|
|
454
|
-
|
|
455
|
-
seen = []
|
|
456
|
-
walker = lambda do |node|
|
|
457
|
-
return unless node.is_a?(SearchEngine::AST::Node)
|
|
458
|
-
|
|
459
|
-
if node.respond_to?(:field)
|
|
460
|
-
field = node.field.to_s
|
|
461
|
-
if field.start_with?('$')
|
|
462
|
-
m = field.match(/^\$(\w+)\./)
|
|
463
|
-
if m
|
|
464
|
-
name = m[1].to_sym
|
|
465
|
-
seen << name unless seen.include?(name)
|
|
466
|
-
end
|
|
467
|
-
end
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
Array(node.children).each { |child| walker.call(child) }
|
|
471
|
-
end
|
|
472
|
-
list.each { |n| walker.call(n) }
|
|
473
|
-
seen
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
# Attempt a client-side fallback rewrite before making a request when
|
|
477
|
-
# joined filters are present but the base schema lacks the needed reference.
|
|
478
|
-
# Returns [raw_result_or_nil, relation, params]
|
|
479
|
-
def preflight_join_fallback_if_needed(relation, params)
|
|
480
|
-
raw_result = nil
|
|
481
|
-
try_fallback = begin
|
|
482
|
-
join_fallback_preflight_required?(relation)
|
|
483
|
-
rescue StandardError
|
|
484
|
-
false
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
if try_fallback
|
|
488
|
-
begin
|
|
489
|
-
fallback_rel = build_client_side_join_fallback_relation(relation)
|
|
490
|
-
if fallback_rel.equal?(:__empty__)
|
|
491
|
-
empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
|
|
492
|
-
raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
|
|
493
|
-
else
|
|
494
|
-
relation = fallback_rel
|
|
495
|
-
params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
|
|
496
|
-
instrument_client_side_fallback(relation)
|
|
497
|
-
end
|
|
498
|
-
rescue StandardError
|
|
499
|
-
# ignore and proceed
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
[raw_result, relation, params]
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
def join_reference_missing_error?(error)
|
|
506
|
-
return false unless error.is_a?(SearchEngine::Errors::Api)
|
|
507
|
-
|
|
508
|
-
body = error.body
|
|
509
|
-
msg = error.message.to_s
|
|
510
|
-
needle = 'No reference field found'
|
|
511
|
-
(body.is_a?(String) && body.include?(needle)) || msg.include?(needle)
|
|
512
|
-
rescue StandardError
|
|
513
|
-
false
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
def build_client_side_join_fallback_relation(relation)
|
|
517
|
-
state = relation.instance_variable_get(:@state) || {}
|
|
518
|
-
ast_nodes = Array(state[:ast]).flatten.compact
|
|
519
|
-
joins = Array(state[:joins]).flatten.compact
|
|
520
|
-
return relation if joins.empty?
|
|
521
|
-
|
|
522
|
-
# Guard: sorting or selection on joined fields not supported by client-side join fallback
|
|
523
|
-
orders = Array(state[:orders]).map(&:to_s)
|
|
524
|
-
if orders.any? { |o| o.start_with?('$') }
|
|
525
|
-
raise SearchEngine::Errors::InvalidOption.new(
|
|
526
|
-
'Sorting by joined fields is not supported by client-side join fallback',
|
|
527
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/joins#client-side-fallback'
|
|
528
|
-
)
|
|
529
|
-
end
|
|
530
|
-
include_str = begin
|
|
531
|
-
relation.send(:compile_include_fields_string)
|
|
532
|
-
rescue StandardError
|
|
533
|
-
nil
|
|
534
|
-
end
|
|
535
|
-
if include_str&.split(',')&.any? { |seg| seg.strip.start_with?('$') }
|
|
536
|
-
raise SearchEngine::Errors::InvalidOption.new(
|
|
537
|
-
'Selecting joined fields is not supported by client-side join fallback',
|
|
538
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/joins#client-side-fallback'
|
|
539
|
-
)
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
# For each applied assoc, collect inner predicates (Eq/In only)
|
|
543
|
-
per_assoc_inners = extract_join_inners(ast_nodes)
|
|
544
|
-
return relation if per_assoc_inners.empty?
|
|
545
|
-
|
|
546
|
-
# Resolve keys and fetch key sets via pre-query
|
|
547
|
-
key_sets = {}
|
|
548
|
-
base_klass = relation.klass
|
|
549
|
-
joins.each do |assoc|
|
|
550
|
-
cfg = base_klass.join_for(assoc)
|
|
551
|
-
inners = per_assoc_inners[assoc] || []
|
|
552
|
-
next if inners.empty?
|
|
553
|
-
|
|
554
|
-
keys = fetch_keys_for_assoc(base_klass, cfg, inners)
|
|
555
|
-
key_sets[assoc] = keys
|
|
556
|
-
end
|
|
557
|
-
|
|
558
|
-
# If any assoc produced no keys, the AND semantics imply empty
|
|
559
|
-
return :__empty__ if key_sets.values.any? { |arr| Array(arr).empty? }
|
|
560
|
-
|
|
561
|
-
# Rewrite AST: remove joined nodes and add base IN(local_key, keys) per assoc
|
|
562
|
-
rewritten_ast = rewrite_ast_with_key_sets(ast_nodes, key_sets, base_klass)
|
|
563
|
-
|
|
564
|
-
relation.send(:spawn) do |s|
|
|
565
|
-
s[:ast] = rewritten_ast
|
|
566
|
-
# NOTE: s[:filters] retained (base fragments only); joins preserved for DX
|
|
567
|
-
end
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
def extract_join_inners(ast_nodes)
|
|
571
|
-
map = {}
|
|
572
|
-
walker = lambda do |node|
|
|
573
|
-
return unless node.is_a?(SearchEngine::AST::Node)
|
|
574
|
-
|
|
575
|
-
node.children.each { |ch| walker.call(ch) } if node.respond_to?(:children) && node.children
|
|
576
|
-
|
|
577
|
-
if node.respond_to?(:field)
|
|
578
|
-
field = node.field.to_s
|
|
579
|
-
if (m = field.match(/^\$(\w+)\.(.+)$/))
|
|
580
|
-
assoc = m[1].to_sym
|
|
581
|
-
inner_field = m[2]
|
|
582
|
-
case node
|
|
583
|
-
when SearchEngine::AST::Eq
|
|
584
|
-
(map[assoc] ||= []) << [:eq, inner_field, node.value]
|
|
585
|
-
when SearchEngine::AST::In
|
|
586
|
-
(map[assoc] ||= []) << [:in, inner_field, node.values]
|
|
587
|
-
else
|
|
588
|
-
# Unsupported node type for fallback (e.g., ranges, not_eq, etc.)
|
|
589
|
-
raise SearchEngine::Errors::InvalidOption.new(
|
|
590
|
-
'Only equality and IN predicates on joined fields are supported by client-side join fallback',
|
|
591
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/joins#client-side-fallback'
|
|
592
|
-
)
|
|
593
|
-
end
|
|
594
|
-
end
|
|
595
|
-
end
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
Array(ast_nodes).each { |n| walker.call(n) }
|
|
599
|
-
map
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
def fetch_keys_for_assoc(base_klass, assoc_cfg, inners)
|
|
603
|
-
require 'search_engine/joins/resolver'
|
|
604
|
-
keys = SearchEngine::Joins::Resolver.resolve_keys(base_klass, assoc_cfg)
|
|
605
|
-
collection = assoc_cfg[:collection]
|
|
606
|
-
target_klass = SearchEngine.collection_for(collection)
|
|
607
|
-
|
|
608
|
-
# Build target relation by AND-ing inner predicates
|
|
609
|
-
rel = target_klass.all
|
|
610
|
-
inners.each do |(op, field, value)|
|
|
611
|
-
case op
|
|
612
|
-
when :eq
|
|
613
|
-
rel = rel.where(field.to_s => value)
|
|
614
|
-
when :in
|
|
615
|
-
rel = rel.where(field.to_s => Array(value))
|
|
616
|
-
end
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
vals = rel.pluck(keys[:foreign_key])
|
|
620
|
-
Array(vals).flatten.compact.uniq
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
def rewrite_ast_with_key_sets(ast_nodes, key_sets, base_klass)
|
|
624
|
-
# Remove joined predicates and append base IN(local_key, keys) for each assoc
|
|
625
|
-
stripped = strip_join_nodes(ast_nodes)
|
|
626
|
-
added = []
|
|
627
|
-
key_sets.each do |assoc, keys|
|
|
628
|
-
cfg = base_klass.join_for(assoc)
|
|
629
|
-
require 'search_engine/joins/resolver'
|
|
630
|
-
lk = SearchEngine::Joins::Resolver.resolve_keys(base_klass, cfg)[:local_key]
|
|
631
|
-
added << SearchEngine::AST.in_(lk.to_sym, keys)
|
|
632
|
-
end
|
|
633
|
-
(Array(stripped) + added).flatten.compact
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
def strip_join_nodes(nodes)
|
|
637
|
-
out = []
|
|
638
|
-
Array(nodes).each do |node|
|
|
639
|
-
next unless node.is_a?(SearchEngine::AST::Node)
|
|
640
|
-
|
|
641
|
-
case node
|
|
642
|
-
when SearchEngine::AST::And
|
|
643
|
-
children = strip_join_nodes(node.children)
|
|
644
|
-
out.concat(Array(children))
|
|
645
|
-
when SearchEngine::AST::Or
|
|
646
|
-
# Fallback does not support OR with joined nodes; reject early
|
|
647
|
-
raise SearchEngine::Errors::InvalidOption.new(
|
|
648
|
-
'OR with joined predicates is not supported by client-side join fallback',
|
|
649
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/joins#client-side-fallback'
|
|
650
|
-
)
|
|
651
|
-
else
|
|
652
|
-
if node.respond_to?(:field)
|
|
653
|
-
f = node.field.to_s
|
|
654
|
-
next if f.start_with?('$')
|
|
655
|
-
end
|
|
656
|
-
out << node
|
|
657
|
-
end
|
|
658
|
-
end
|
|
659
|
-
out
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
def instrument_client_side_fallback(relation)
|
|
663
|
-
return unless defined?(SearchEngine::Instrumentation)
|
|
664
|
-
|
|
665
|
-
SearchEngine::Instrumentation.instrument(
|
|
666
|
-
'search_engine.joins.client_side_fallback',
|
|
667
|
-
collection: (relation.klass.respond_to?(:collection) ? relation.klass.collection : nil),
|
|
668
|
-
joins: Array(relation.joins_list).map(&:to_s)
|
|
669
|
-
)
|
|
670
|
-
rescue StandardError
|
|
671
|
-
nil
|
|
672
|
-
end
|
|
673
|
-
|
|
674
286
|
def effective_per_page(relation)
|
|
675
287
|
state = relation.instance_variable_get(:@state) || {}
|
|
676
288
|
per_page = state[:per_page]
|
|
@@ -799,33 +411,6 @@ module SearchEngine
|
|
|
799
411
|
{ fields: fields.freeze, queries: queries.freeze }.freeze
|
|
800
412
|
end
|
|
801
413
|
module_function :build_facets_context_from_state
|
|
802
|
-
|
|
803
|
-
# Detect a simple AST eq(:id, value) predicate with no other filters.
|
|
804
|
-
def detect_equality_id_predicate_value(relation)
|
|
805
|
-
state = relation.instance_variable_get(:@state) || {}
|
|
806
|
-
ast_nodes = Array(state[:ast]).flatten.compact
|
|
807
|
-
return nil unless ast_nodes.size == 1
|
|
808
|
-
|
|
809
|
-
node = ast_nodes.first
|
|
810
|
-
return nil unless node.is_a?(SearchEngine::AST::Eq)
|
|
811
|
-
return nil unless node.field.to_s == 'id'
|
|
812
|
-
|
|
813
|
-
node.value
|
|
814
|
-
rescue StandardError
|
|
815
|
-
nil
|
|
816
|
-
end
|
|
817
|
-
|
|
818
|
-
# Use the Typesense retrieve-by-id endpoint and hydrate the document.
|
|
819
|
-
def retrieve_document_by_id(relation, id_value)
|
|
820
|
-
collection = relation.send(:collection_name_for_klass)
|
|
821
|
-
client = relation.send(:client)
|
|
822
|
-
raw = client.retrieve_document(collection: collection, id: id_value)
|
|
823
|
-
return nil unless raw.is_a?(Hash)
|
|
824
|
-
|
|
825
|
-
SearchEngine::Base::Creation::Helpers.hydrate_from_document(relation.klass, raw)
|
|
826
|
-
rescue StandardError
|
|
827
|
-
nil
|
|
828
|
-
end
|
|
829
414
|
end
|
|
830
415
|
end
|
|
831
416
|
end
|
|
@@ -161,6 +161,7 @@ module SearchEngine
|
|
|
161
161
|
# @return [SearchEngine::Indexer::Summary]
|
|
162
162
|
def call_parallel(klass:, into:, enum:, batch_size:, action:, log_batches:, max_parallel:, on_batch: nil)
|
|
163
163
|
require 'concurrent-ruby'
|
|
164
|
+
require 'search_engine/interruptible_pool'
|
|
164
165
|
|
|
165
166
|
docs_enum = normalize_enum(enum)
|
|
166
167
|
total_batches_estimate = estimate_total_batches(klass)
|
|
@@ -186,7 +187,7 @@ module SearchEngine
|
|
|
186
187
|
log_batches: log_batches, started_at: started_at, total_estimate: total_batches_estimate
|
|
187
188
|
) { |err| producer_error = err }
|
|
188
189
|
|
|
189
|
-
SearchEngine::InterruptiblePool.run(
|
|
190
|
+
pool_status = SearchEngine::InterruptiblePool.run(
|
|
190
191
|
pool,
|
|
191
192
|
on_interrupt: -> { interrupt_parallel!(shared_state[:cancelled], batch_queue) }
|
|
192
193
|
) do
|
|
@@ -196,17 +197,18 @@ module SearchEngine
|
|
|
196
197
|
pool: pool, shared_state: shared_state, max_parallel: max_parallel
|
|
197
198
|
)
|
|
198
199
|
end
|
|
199
|
-
|
|
200
|
+
raise SearchEngine::Errors::InvalidParams, 'Parallel import workers timed out' if pool_status == :timed_out
|
|
201
|
+
|
|
202
|
+
producer_thread.join
|
|
203
|
+
raise shared_state[:worker_error] if shared_state[:worker_error]
|
|
200
204
|
|
|
201
205
|
raise producer_error if producer_error
|
|
202
206
|
|
|
203
207
|
build_summary(klass, shared_state)
|
|
204
208
|
ensure
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
nil
|
|
209
|
-
end
|
|
209
|
+
shared_state[:cancelled].make_true if shared_state
|
|
210
|
+
batch_queue&.close
|
|
211
|
+
producer_thread&.join
|
|
210
212
|
end
|
|
211
213
|
|
|
212
214
|
# Initialize shared state hash for parallel batch processing.
|
|
@@ -267,6 +269,8 @@ module SearchEngine
|
|
|
267
269
|
end
|
|
268
270
|
SearchEngine::Logging::Output.puts(SearchEngine::Logging::Color.dim(progress))
|
|
269
271
|
end
|
|
272
|
+
rescue ClosedQueueError
|
|
273
|
+
raise unless cancelled.true?
|
|
270
274
|
rescue StandardError => error
|
|
271
275
|
yield error if block_given?
|
|
272
276
|
err_msg = " Producer failed at batch #{batch_count}: #{error.class}: #{error.message.to_s[0, 200]}"
|
|
@@ -327,6 +331,10 @@ module SearchEngine
|
|
|
327
331
|
shared_state: shared_state
|
|
328
332
|
)
|
|
329
333
|
end
|
|
334
|
+
rescue StandardError => error
|
|
335
|
+
shared_state[:mtx].synchronize { shared_state[:worker_error] ||= error }
|
|
336
|
+
cancelled.make_true
|
|
337
|
+
batch_queue.close
|
|
330
338
|
end
|
|
331
339
|
end
|
|
332
340
|
end
|
data/lib/search_engine/mapper.rb
CHANGED
|
@@ -355,13 +355,7 @@ module SearchEngine
|
|
|
355
355
|
hash = normalize_document(@map_proc.call(row))
|
|
356
356
|
hash.delete(:id)
|
|
357
357
|
hash.delete('id')
|
|
358
|
-
|
|
359
|
-
computed_id = @klass.compute_document_id(row)
|
|
360
|
-
rescue NoMethodError
|
|
361
|
-
rid = row.respond_to?(:id) ? row.id : nil
|
|
362
|
-
computed_id = rid.is_a?(String) ? rid : rid.to_s
|
|
363
|
-
end
|
|
364
|
-
hash[:id] = computed_id
|
|
358
|
+
hash[:id] = @klass.compute_document_id(row)
|
|
365
359
|
hash[:doc_updated_at] = now_i
|
|
366
360
|
|
|
367
361
|
strip_auto_embedding_fields!(hash)
|
|
@@ -441,7 +435,7 @@ module SearchEngine
|
|
|
441
435
|
next if value.nil? && @__optional_blank_targets__.include?(fname)
|
|
442
436
|
|
|
443
437
|
valid, coerced, err = validate_value(expected, value, field: fname)
|
|
444
|
-
if coerced
|
|
438
|
+
if !coerced.nil?
|
|
445
439
|
stats[:coerced_count] += 1
|
|
446
440
|
hash[key] = coerced
|
|
447
441
|
elsif !valid && stats[:invalid_type_samples].size < @options[:max_error_samples]
|
|
@@ -26,8 +26,8 @@ module SearchEngine
|
|
|
26
26
|
@binds = binds
|
|
27
27
|
cfg = SearchEngine.config.sources.sql
|
|
28
28
|
@fetch_size = (fetch_size || cfg.fetch_size).to_i
|
|
29
|
-
@row_shape = row_shape ||
|
|
30
|
-
@statement_timeout_ms = statement_timeout_ms
|
|
29
|
+
@row_shape = row_shape || cfg.row_shape
|
|
30
|
+
@statement_timeout_ms = statement_timeout_ms || cfg.statement_timeout_ms
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Iterate over batches produced by the SQL query.
|
|
@@ -38,9 +38,9 @@ module SearchEngine
|
|
|
38
38
|
def each_batch(partition: nil, cursor: nil, &block)
|
|
39
39
|
return enum_for(:each_batch, partition: partition, cursor: cursor) unless block_given?
|
|
40
40
|
|
|
41
|
-
run_with_connection do
|
|
42
|
-
if postgres_connection?(
|
|
43
|
-
stream_postgres(
|
|
41
|
+
run_with_connection do |conn|
|
|
42
|
+
if postgres_connection?(conn)
|
|
43
|
+
stream_postgres(conn, partition: partition, cursor: cursor, &block)
|
|
44
44
|
else
|
|
45
45
|
stream_generic(nil, partition: partition, cursor: cursor, &block)
|
|
46
46
|
end
|
|
@@ -78,13 +78,17 @@ module SearchEngine
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def stream_postgres(conn, partition:, cursor:)
|
|
81
|
+
unless conn.transaction_status.zero?
|
|
82
|
+
raise SearchEngine::Errors::InvalidParams,
|
|
83
|
+
'SqlSource streaming requires a connection without an active transaction'
|
|
84
|
+
end
|
|
85
|
+
|
|
81
86
|
cursor_name = "se_cursor_#{object_id}"
|
|
82
87
|
sql, params = build_sql_and_params(partition: partition, cursor: cursor)
|
|
83
88
|
started = monotonic_ms
|
|
89
|
+
conn.exec('BEGIN READ ONLY')
|
|
84
90
|
begin
|
|
85
91
|
set_statement_timeout(conn, @statement_timeout_ms) if @statement_timeout_ms
|
|
86
|
-
# Use unnamed prepared statement + DECLARE CURSOR for streaming
|
|
87
|
-
conn.exec('BEGIN READ ONLY')
|
|
88
92
|
conn.exec_params("DECLARE #{cursor_name} NO SCROLL CURSOR FOR #{sql}", params)
|
|
89
93
|
idx = 0
|
|
90
94
|
loop do
|
|
@@ -101,18 +105,10 @@ module SearchEngine
|
|
|
101
105
|
idx += 1
|
|
102
106
|
started = monotonic_ms
|
|
103
107
|
end
|
|
108
|
+
conn.exec("CLOSE #{cursor_name}")
|
|
109
|
+
conn.exec('COMMIT')
|
|
104
110
|
ensure
|
|
105
|
-
|
|
106
|
-
conn.exec("CLOSE #{cursor_name}")
|
|
107
|
-
rescue StandardError
|
|
108
|
-
# ignore
|
|
109
|
-
end
|
|
110
|
-
begin
|
|
111
|
-
conn.exec('COMMIT')
|
|
112
|
-
rescue StandardError
|
|
113
|
-
# ignore
|
|
114
|
-
end
|
|
115
|
-
reset_statement_timeout(conn) if @statement_timeout_ms
|
|
111
|
+
conn.exec('ROLLBACK') unless conn.transaction_status.zero?
|
|
116
112
|
end
|
|
117
113
|
end
|
|
118
114
|
|
|
@@ -181,15 +177,7 @@ module SearchEngine
|
|
|
181
177
|
end
|
|
182
178
|
|
|
183
179
|
def set_statement_timeout(conn, ms)
|
|
184
|
-
conn.exec_params('
|
|
185
|
-
rescue StandardError
|
|
186
|
-
# ignore
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def reset_statement_timeout(conn)
|
|
190
|
-
conn.exec('RESET statement_timeout')
|
|
191
|
-
rescue StandardError
|
|
192
|
-
# ignore
|
|
180
|
+
conn.exec_params("SELECT set_config('statement_timeout', $1, true)", [Integer(ms).to_s])
|
|
193
181
|
end
|
|
194
182
|
end
|
|
195
183
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: search-engine-for-typesense
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 30.
|
|
4
|
+
version: 30.8.24
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nikita Shkoda
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|