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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd7e450cd267dab4d557451ce255f20707b07d2a97cf42b143dfdc601561668f
4
- data.tar.gz: 29e7f4f858407039f8e477a8d0f0fc1b9985e3922214bae03def126065e31106
3
+ metadata.gz: db1ad7dbacfe019861ce7dbe7f3511326d67f0afc8bbf22ed2929cdb300797ad
4
+ data.tar.gz: 5bfafebc3623ee781a1c519c799621bfb8ca62b19f21e6370e79d4d165bc3999
5
5
  SHA512:
6
- metadata.gz: 8ab3eda607afe100090554542a0c74a89d1418da5bf8c0034c35fbd4b26ae1c2b6dfbeb8789d4a16c313b34179f64b0c031a61bb463133c14a51087e5f1fe83a
7
- data.tar.gz: a3a21521da9b613004e0883142d6c9ec63e3711d573be90b28db7eeb8dfcf391872876d182f2bd2c21e08d56963a2e746415bd6c4cc0eddf4166bf67b2cffc70
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 gem version mirrors the Typesense server major/minor it targets. Patch releases are reserved for gem-only fixes and enhancements.
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.1.x` targets Typesense `30.1`.
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
- attribute :id, :integer
37
+ identify_by :id
37
38
  attribute :name, :string
38
39
 
39
- query_by %i[name brand description]
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
- # Model
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
- attribute :id, :integer
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,brand')
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: SearchEngine.config.default_query_by }) do |m|
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.map do |pair|
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
- ar_klass.after_create_commit :__se_syncable_upsert! if actions.include?(:create)
269
- ar_klass.after_update_commit :__se_syncable_upsert! if actions.include?(:update)
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]).map { |f| (f[:name] || f['name']).to_s }
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 = nil
32
-
33
- # Preflight client-side fallback (extracted for readability)
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 = perform_preview_search_with_fallback(preview_relation, collection, params, url_opts)
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
- begin
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
- producer_thread.join if producer_thread.alive?
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
- begin
206
- producer_thread&.join(5) if producer_thread&.alive?
207
- rescue StandardError
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
@@ -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
- begin
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 || :auto
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?(ActiveRecord::Base.connection.raw_connection)
43
- stream_postgres(ActiveRecord::Base.connection.raw_connection, partition: partition, cursor: cursor, &block)
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
- begin
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('SET LOCAL statement_timeout = $1', [Integer(ms)])
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
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.8.23'
6
+ VERSION = '30.8.24'
7
7
  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.1.8.23
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 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby