search-engine-for-typesense 30.1.6.17 → 30.1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bd17994c46590322f8d32265bf5f1a5ae91be3b911a120c48730ba6339fb9e3
4
- data.tar.gz: e7d9785f65c500ddffaef1898b8620c13142ade4f737d6b93f7f1b88a31ee582
3
+ metadata.gz: 9eea6beb086957bbee9df77008a70750be3a74f6f39a53be361a3f1ae8295219
4
+ data.tar.gz: 0eaf79b97f1c68b7bdab350f11cbfe09eecfb4944c76fd5c49528b68e63a5f09
5
5
  SHA512:
6
- metadata.gz: 744c09d6abd7bce828543b0cd11bb354d94cdff2c17a89d690efd25948993901341c0177890e07256e0b1a9e376925f6ed790c1b5ad64f629e68a99dca9b6ad9
7
- data.tar.gz: aee5a996ae1f5075533786e58347e0771737ecc8278cd25b380df7b8c21b6ea917e052a9ad34216eab7f6a2b8a80c90e6701241843eb9698572f1adc9fd201c1
6
+ metadata.gz: ab9dd851d14c4a7019902afad59af281623576952ade57dd2a7df7d458caf68a3f432ac742873b52bc94fd8a88b14ef8715807d7ab3d7d7fd44cc05765424489
7
+ data.tar.gz: 546d394202e194705c24f1c2584b57239bca5e1084d299a3c801fc2781c480d666e47e2906992e6885c788a12ec9162c57e18b1e7eb88e65e93d2a661a2d27f3
data/README.md CHANGED
@@ -116,6 +116,36 @@ SearchEngine::Product.upsert_bulk(records: Product.limit(2))
116
116
 
117
117
  # Bulk upsert mapped payloads
118
118
  SearchEngine::Product.upsert_bulk(data: [mapped])
119
+
120
+ # Geo search
121
+ class SearchEngine::Venue < SearchEngine::Base
122
+ collection :venues
123
+ identify_by :id
124
+
125
+ attribute :name, :string
126
+ attribute :location, :geopoint
127
+ end
128
+
129
+ # Filter by radius
130
+ SearchEngine::Venue
131
+ .where_geo(:location, within_radius: { lat: 54.69, lng: 25.28, radius: "10 km" })
132
+ .order_geo(:location, from: { lat: 54.69, lng: 25.28 })
133
+ .to_a
134
+
135
+ # Filter by polygon (viewport)
136
+ SearchEngine::Venue
137
+ .where_geo(:location, within_polygon: [[54.72, 25.35], [54.72, 25.22], [54.67, 25.22], [54.67, 25.35]])
138
+ .to_a
139
+
140
+ # Viewport boost with _eval() + distance tiebreaker
141
+ SearchEngine::Venue
142
+ .order_eval("location:(54.72,25.35, 54.72,25.22, 54.67,25.22, 54.67,25.35)", direction: :desc)
143
+ .order_geo(:location, from: { lat: 54.69, lng: 25.28 })
144
+ .to_a
145
+
146
+ # Access geo distance on results (present when order_geo is used)
147
+ result = SearchEngine::Venue.all.order_geo(:location, from: { lat: 54.69, lng: 25.28 }).execute
148
+ result.hits.first.geo_distance_meters # => { "location" => 1234 }
119
149
  ```
120
150
 
121
151
  ## Documentation
@@ -235,10 +235,21 @@ module SearchEngine
235
235
  end
236
236
 
237
237
  actions = cfg[:actions]
238
+ timing = begin
239
+ SearchEngine.config.syncable_callback_timing
240
+ rescue StandardError
241
+ :after_commit
242
+ end
238
243
 
239
- ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
240
- ar_klass.after_update :__se_syncable_upsert! if actions.include?(:update)
241
- ar_klass.after_destroy :__se_syncable_delete! if actions.include?(:destroy)
244
+ if timing == :after_commit
245
+ ar_klass.after_create_commit :__se_syncable_upsert! if actions.include?(:create)
246
+ ar_klass.after_update_commit :__se_syncable_upsert! if actions.include?(:update)
247
+ ar_klass.after_destroy_commit :__se_syncable_delete! if actions.include?(:destroy)
248
+ else
249
+ ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
250
+ ar_klass.after_update :__se_syncable_upsert! if actions.include?(:update)
251
+ ar_klass.after_destroy :__se_syncable_delete! if actions.include?(:destroy)
252
+ end
242
253
 
243
254
  ar_klass.instance_variable_set(:@__se_syncable_callbacks_installed__, true)
244
255
  nil
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/concern'
4
+ require 'search_engine/logging/output'
4
5
 
5
6
  module SearchEngine
6
7
  class Base
@@ -44,13 +45,15 @@ module SearchEngine
44
45
  # @return [Integer] number of deleted documents
45
46
  def cleanup(into: nil, partition: nil, clear_cache: false)
46
47
  logical = respond_to?(:collection) ? collection.to_s : name.to_s
47
- puts
48
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Cleanup Collection "#{logical}")))
48
+ SearchEngine::Logging::Output.puts
49
+ SearchEngine::Logging::Output.puts(
50
+ SearchEngine::Logging::Color.header(%(>>>>>> Cleanup Collection "#{logical}"))
51
+ )
49
52
 
50
53
  filters = SearchEngine::StaleRules.compile_filters(self, partition: partition)
51
54
  filters.compact!
52
55
  filters.reject! { |f| f.to_s.strip.empty? }
53
- step = SearchEngine::Logging::StepLine.new('Cleanup')
56
+ step = SearchEngine::Logging::StepLine.new('Cleanup', io: SearchEngine::Logging::Output.io)
54
57
  if filters.empty?
55
58
  step.skip('no stale configuration')
56
59
  return 0
@@ -76,14 +79,14 @@ module SearchEngine
76
79
  step&.close
77
80
  if clear_cache
78
81
  begin
79
- puts("Cleanup — #{SearchEngine::Logging::Color.bold('cache clear')}")
82
+ SearchEngine::Logging::Output.puts("Cleanup — #{SearchEngine::Logging::Color.bold('cache clear')}")
80
83
  SearchEngine::Cache.clear
81
84
  rescue StandardError => error
82
85
  err_msg = "Cleanup — cache clear error=#{error.class}: #{error.message.to_s[0, 200]}"
83
86
  warn(SearchEngine::Logging::Color.apply(err_msg, :red))
84
87
  end
85
88
  end
86
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Cleanup Done)))
89
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::Color.header(%(>>>>>> Cleanup Done)))
87
90
  end
88
91
 
89
92
  private
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'search_engine/logging/output'
4
+
3
5
  module SearchEngine
4
6
  class Base
5
7
  module IndexMaintenance
@@ -16,8 +18,10 @@ module SearchEngine
16
18
  # @return [Hash, nil] result hash with :status, :docs_total, :success_total, :failed_total, :sample_error
17
19
  def index_collection(partition: nil, client: nil, pre: nil, force_rebuild: false)
18
20
  logical = respond_to?(:collection) ? collection.to_s : name.to_s
19
- puts
20
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Indexing Collection "#{logical}")))
21
+ SearchEngine::Logging::Output.puts
22
+ SearchEngine::Logging::Output.puts(
23
+ SearchEngine::Logging::Color.header(%(>>>>>> Indexing Collection "#{logical}"))
24
+ )
21
25
  client_obj = client || SearchEngine.client
22
26
 
23
27
  result = if partition.nil?
@@ -56,7 +60,7 @@ module SearchEngine
56
60
 
57
61
  diff = SearchEngine::Schema.diff(self, client: client)[:diff] || {}
58
62
  missing = __se_schema_missing?(diff)
59
- step = SearchEngine::Logging::StepLine.new('Presence')
63
+ step = SearchEngine::Logging::StepLine.new('Presence', io: SearchEngine::Logging::Output.io)
60
64
  missing ? step.finish_warn('missing') : step.finish('present')
61
65
 
62
66
  applied, indexed_inside_apply = __se_full_apply_if_missing(client, missing)
@@ -76,7 +80,7 @@ module SearchEngine
76
80
  def __se_full_apply_if_missing(client, missing)
77
81
  applied = false
78
82
  indexed_inside_apply = false
79
- step = SearchEngine::Logging::StepLine.new('Schema')
83
+ step = SearchEngine::Logging::StepLine.new('Schema', io: SearchEngine::Logging::Output.io)
80
84
  if missing
81
85
  step.update('creating')
82
86
  begin
@@ -100,7 +104,7 @@ module SearchEngine
100
104
  end
101
105
 
102
106
  def __se_full_check_drift(diff, missing, force_rebuild)
103
- step = SearchEngine::Logging::StepLine.new('Schema Status')
107
+ step = SearchEngine::Logging::StepLine.new('Schema Status', io: SearchEngine::Logging::Output.io)
104
108
  unless missing
105
109
  step.update('checking')
106
110
  drift = __se_schema_drift?(diff)
@@ -118,7 +122,7 @@ module SearchEngine
118
122
  end
119
123
 
120
124
  def __se_full_apply_if_drift(client, drift, applied, indexed_inside_apply, force_rebuild)
121
- step = SearchEngine::Logging::StepLine.new('Schema Apply')
125
+ step = SearchEngine::Logging::StepLine.new('Schema Apply', io: SearchEngine::Logging::Output.io)
122
126
  if drift
123
127
  step.update('applying')
124
128
  begin
@@ -143,7 +147,7 @@ module SearchEngine
143
147
 
144
148
  def __se_full_indexation(applied, indexed_inside_apply)
145
149
  result = nil
146
- step = SearchEngine::Logging::StepLine.new('Indexing')
150
+ step = SearchEngine::Logging::StepLine.new('Indexing', io: SearchEngine::Logging::Output.io)
147
151
  if applied && indexed_inside_apply
148
152
  result = indexed_inside_apply if indexed_inside_apply.is_a?(Hash)
149
153
  if __se_result_status(result) == :ok
@@ -177,7 +181,7 @@ module SearchEngine
177
181
  end
178
182
 
179
183
  def __se_full_retention(applied, logical, client)
180
- step = SearchEngine::Logging::StepLine.new('Retention')
184
+ step = SearchEngine::Logging::StepLine.new('Retention', io: SearchEngine::Logging::Output.io)
181
185
  if applied
182
186
  step.skip('handled by schema apply')
183
187
  else
@@ -195,7 +199,7 @@ module SearchEngine
195
199
  diff = diff_res[:diff] || {}
196
200
 
197
201
  missing = __se_schema_missing?(diff)
198
- step = SearchEngine::Logging::StepLine.new('Presence')
202
+ step = SearchEngine::Logging::StepLine.new('Presence', io: SearchEngine::Logging::Output.io)
199
203
  if missing
200
204
  step.finish_warn('missing — collection not present, exit early')
201
205
  return { status: :failed, docs_total: 0, success_total: 0, failed_total: 0,
@@ -203,7 +207,7 @@ module SearchEngine
203
207
  end
204
208
  step.finish('present')
205
209
 
206
- step = SearchEngine::Logging::StepLine.new('Schema Status')
210
+ step = SearchEngine::Logging::StepLine.new('Schema Status', io: SearchEngine::Logging::Output.io)
207
211
  step.update('checking')
208
212
  drift = __se_schema_drift?(diff)
209
213
  if drift
@@ -215,12 +219,13 @@ module SearchEngine
215
219
 
216
220
  __se_preflight_dependencies!(mode: pre, client: client) if pre
217
221
 
218
- step = SearchEngine::Logging::StepLine.new('Partial Indexing')
222
+ step = SearchEngine::Logging::StepLine.new('Partial Indexing', io: SearchEngine::Logging::Output.io)
219
223
  step.update('indexing')
220
224
  step.yield_line!
221
225
 
222
226
  renderer = SearchEngine::Logging::LiveRenderer.new(
223
- labels: partitions.map(&:inspect), partitions: partitions
227
+ labels: partitions.map(&:inspect), partitions: partitions,
228
+ io: SearchEngine::Logging::Output.io
224
229
  )
225
230
  renderer.start
226
231
  summaries = []
@@ -255,35 +260,30 @@ module SearchEngine
255
260
  # rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize
256
261
  def __se_cascade_after_indexation!(context: :full)
257
262
  if SearchEngine::Instrumentation.context&.[](:bulk_suppress_cascade)
258
- puts
259
- puts(SearchEngine::Logging::Color.dim('>>>>>> Cascade Referencers — suppressed (bulk)'))
263
+ SearchEngine::Logging::Output.puts
264
+ SearchEngine::Logging::Output.puts(
265
+ SearchEngine::Logging::Color.dim('>>>>>> Cascade Referencers — suppressed (bulk)')
266
+ )
260
267
  return
261
268
  end
262
- puts
263
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Cascade Referencers)))
269
+ SearchEngine::Logging::Output.puts
270
+ SearchEngine::Logging::Output.puts(
271
+ SearchEngine::Logging::Color.header(%(>>>>>> Cascade Referencers))
272
+ )
264
273
  results = SearchEngine::Cascade.cascade_reindex!(source: self, ids: nil, context: context)
265
274
  outcomes = Array(results[:outcomes])
266
275
  if outcomes.empty?
267
- puts(SearchEngine::Logging::Color.dim(' none'))
276
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::Color.dim(' none'))
268
277
  else
269
278
  outcomes.each do |o|
270
279
  coll = o[:collection] || o['collection']
271
280
  mode = (o[:mode] || o['mode']).to_s
272
- case mode
273
- when 'partial'
274
- puts(%( Referencer "#{coll}" → #{SearchEngine::Logging::Color.apply('partial reindex', :green)}))
275
- when 'full'
276
- puts(%( Referencer "#{coll}" → #{SearchEngine::Logging::Color.apply('full reindex', :green)}))
277
- when 'skipped_unregistered'
278
- puts(SearchEngine::Logging::Color.dim(%( Referencer "#{coll}" → skipped (unregistered))))
279
- when 'skipped_cycle'
280
- puts(SearchEngine::Logging::Color.dim(%( Referencer "#{coll}" → skipped (cycle))))
281
- else
282
- puts(%( Referencer "#{coll}" → #{mode}))
283
- end
281
+ __se_log_cascade_outcome(coll, mode)
284
282
  end
285
283
  end
286
- puts(SearchEngine::Logging::Color.header('>>>>>> Cascade Done'))
284
+ SearchEngine::Logging::Output.puts(
285
+ SearchEngine::Logging::Color.header('>>>>>> Cascade Done')
286
+ )
287
287
  rescue StandardError => error
288
288
  base = "Cascade — error=#{error.class}: #{error.message.to_s[0, 200]}"
289
289
  if error.respond_to?(:status) || error.respond_to?(:body)
@@ -314,6 +314,22 @@ module SearchEngine
314
314
  end
315
315
  # rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize
316
316
 
317
+ def __se_log_cascade_outcome(coll, mode)
318
+ msg = case mode
319
+ when 'partial'
320
+ %( Referencer "#{coll}" → #{SearchEngine::Logging::Color.apply('partial reindex', :green)})
321
+ when 'full'
322
+ %( Referencer "#{coll}" → #{SearchEngine::Logging::Color.apply('full reindex', :green)})
323
+ when 'skipped_unregistered'
324
+ SearchEngine::Logging::Color.dim(%( Referencer "#{coll}" → skipped (unregistered)))
325
+ when 'skipped_cycle'
326
+ SearchEngine::Logging::Color.dim(%( Referencer "#{coll}" → skipped (cycle)))
327
+ else
328
+ %( Referencer "#{coll}" → #{mode})
329
+ end
330
+ SearchEngine::Logging::Output.puts(msg)
331
+ end
332
+
317
333
  # Raise {SearchEngine::Errors::IndexationAborted} when the result
318
334
  # from {__se_index_partitions!} indicates a non-ok status. Called
319
335
  # inside a {Schema.apply!} block to prevent the alias swap.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'search_engine/logging/output'
4
+
3
5
  module SearchEngine
4
6
  class Base
5
7
  module IndexMaintenance
@@ -27,7 +29,7 @@ module SearchEngine
27
29
 
28
30
  def update_collection!
29
31
  client = SearchEngine.client
30
- step = SearchEngine::Logging::StepLine.new('Update Collection')
32
+ step = SearchEngine::Logging::StepLine.new('Update Collection', io: SearchEngine::Logging::Output.io)
31
33
  step.update('analyzing diff')
32
34
  updated = SearchEngine::Schema.update!(self, client: client)
33
35
 
@@ -51,14 +53,16 @@ module SearchEngine
51
53
  physicals = __se_list_all_physicals(logical, client)
52
54
  bare_schema = client.retrieve_collection_schema(logical, timeout_ms: 10_000)
53
55
 
54
- step = SearchEngine::Logging::StepLine.new('Drop Collection')
56
+ step = SearchEngine::Logging::StepLine.new('Drop Collection', io: SearchEngine::Logging::Output.io)
55
57
  if !has_alias && physicals.empty? && bare_schema.nil?
56
58
  step.skip('not present')
57
59
  return
58
60
  end
59
61
 
60
- puts
61
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Dropping Collection "#{logical}")))
62
+ SearchEngine::Logging::Output.puts
63
+ SearchEngine::Logging::Output.puts(
64
+ SearchEngine::Logging::Color.header(%(>>>>>> Dropping Collection "#{logical}"))
65
+ )
62
66
 
63
67
  physicals.each do |name|
64
68
  step.update("dropping physical #{name}")
@@ -76,7 +80,9 @@ module SearchEngine
76
80
  end
77
81
 
78
82
  step.finish("done (physicals=#{physicals.size})")
79
- puts(SearchEngine::Logging::Color.header(%(>>>>>> Dropped Collection "#{logical}")))
83
+ SearchEngine::Logging::Output.puts(
84
+ SearchEngine::Logging::Color.header(%(>>>>>> Dropped Collection "#{logical}"))
85
+ )
80
86
  nil
81
87
  ensure
82
88
  step&.close
@@ -110,7 +116,7 @@ module SearchEngine
110
116
  physicals = __se_list_all_physicals(logical, client)
111
117
  bare_schema = client.retrieve_collection_schema(logical)
112
118
 
113
- step = SearchEngine::Logging::StepLine.new('Recreate Collection')
119
+ step = SearchEngine::Logging::StepLine.new('Recreate Collection', io: SearchEngine::Logging::Output.io)
114
120
  if has_alias || physicals.any? || bare_schema
115
121
  step.update("dropping existing (logical=#{logical})")
116
122
  physicals.each { |name| client.delete_collection(name) }
@@ -8,6 +8,7 @@ require 'search_engine/logging/color'
8
8
  require 'search_engine/logging/batch_line'
9
9
  require 'search_engine/logging/step_line'
10
10
  require 'search_engine/logging/live_renderer'
11
+ require 'search_engine/logging/output'
11
12
 
12
13
  module SearchEngine
13
14
  class Base
@@ -43,11 +44,11 @@ module SearchEngine
43
44
  return if deps.empty?
44
45
 
45
46
  indent = ' ' * depth
46
- puts if depth.zero?
47
+ SearchEngine::Logging::Output.puts if depth.zero?
47
48
  header = SearchEngine::Logging::Color.header(
48
49
  %(#{indent}>>>>>> Preflight Dependencies (mode: #{mode}, collection: "#{current}"))
49
50
  )
50
- puts(header)
51
+ SearchEngine::Logging::Output.puts(header)
51
52
 
52
53
  deps.each do |cfg|
53
54
  dep_coll = (cfg[:collection] || cfg['collection']).to_s
@@ -56,7 +57,9 @@ module SearchEngine
56
57
  dep_klass = __se_resolve_dep_class(dep_coll)
57
58
 
58
59
  if dep_klass.nil?
59
- puts(SearchEngine::Logging::Color.dim(%(#{indent} "#{dep_coll}" → skipped (unregistered))))
60
+ SearchEngine::Logging::Output.puts(
61
+ SearchEngine::Logging::Color.dim(%(#{indent} "#{dep_coll}" → skipped (unregistered)))
62
+ )
60
63
  visited.add(dep_coll)
61
64
  next
62
65
  end
@@ -78,7 +81,9 @@ module SearchEngine
78
81
  visited.add(dep_coll)
79
82
  end
80
83
 
81
- puts(SearchEngine::Logging::Color.header(%(#{indent}>>>>>> Preflight Done (collection: "#{current}"))))
84
+ SearchEngine::Logging::Output.puts(
85
+ SearchEngine::Logging::Color.header(%(#{indent}>>>>>> Preflight Done (collection: "#{current}")))
86
+ )
82
87
  end
83
88
 
84
89
  # @return [String] current collection logical name; empty string when unavailable
@@ -172,28 +177,32 @@ module SearchEngine
172
177
  when 'ensure'
173
178
  if missing
174
179
  status_word = SearchEngine::Logging::Color.apply('ensure (missing)', :yellow)
175
- puts(%(#{indent}"#{dep_coll}" → #{status_word} → index_collection))
176
- # Avoid nested preflight to prevent redundant recursion cycles
180
+ SearchEngine::Logging::Output.puts(%(#{indent}"#{dep_coll}" → #{status_word} → index_collection))
177
181
  SearchEngine::Instrumentation.with_context(bulk_suppress_cascade: true) do
178
182
  dep_klass.index_collection(client: client)
179
183
  end
180
184
  else
181
- puts(SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → present (skip))))
185
+ SearchEngine::Logging::Output.puts(
186
+ SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → present (skip)))
187
+ )
182
188
  end
183
189
  when 'index'
184
190
  if missing || drift
185
191
  reason = missing ? 'missing' : 'drift'
186
192
  status_word = SearchEngine::Logging::Color.apply("index (#{reason})", :yellow)
187
- puts(%(#{indent}"#{dep_coll}" → #{status_word} → index_collection))
188
- # Avoid nested preflight to prevent redundant recursion cycles
193
+ SearchEngine::Logging::Output.puts(%(#{indent}"#{dep_coll}" → #{status_word} → index_collection))
189
194
  SearchEngine::Instrumentation.with_context(bulk_suppress_cascade: true) do
190
195
  dep_klass.index_collection(client: client)
191
196
  end
192
197
  else
193
- puts(SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → in_sync (skip))))
198
+ SearchEngine::Logging::Output.puts(
199
+ SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → in_sync (skip)))
200
+ )
194
201
  end
195
202
  else
196
- puts(SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → skipped (unknown mode: #{mode}))))
203
+ SearchEngine::Logging::Output.puts(
204
+ SearchEngine::Logging::Color.dim(%(#{indent}"#{dep_coll}" → skipped (unknown mode: #{mode})))
205
+ )
197
206
  end
198
207
  end
199
208
 
@@ -201,7 +210,9 @@ module SearchEngine
201
210
  return unless batches.is_a?(Array)
202
211
 
203
212
  batches.each_with_index do |batch_stats, idx|
204
- puts(SearchEngine::Logging::BatchLine.format(batch_stats, idx + 1, indifferent: true))
213
+ SearchEngine::Logging::Output.puts(
214
+ SearchEngine::Logging::BatchLine.format(batch_stats, idx + 1, indifferent: true)
215
+ )
205
216
  end
206
217
  end
207
218
 
@@ -285,7 +296,8 @@ module SearchEngine
285
296
  docs_estimate = __se_heuristic_docs_estimate(1)
286
297
  renderer = SearchEngine::Logging::LiveRenderer.new(
287
298
  labels: ['single'], partitions: [nil],
288
- per_partition_docs_estimates: [docs_estimate]
299
+ per_partition_docs_estimates: [docs_estimate],
300
+ io: SearchEngine::Logging::Output.io
289
301
  )
290
302
  renderer.start
291
303
 
@@ -348,7 +360,9 @@ module SearchEngine
348
360
  def __se_index_partitions_seq!(parts, into, compiled)
349
361
  docs_estimates = __se_per_partition_docs_estimates(parts, compiled)
350
362
  renderer = SearchEngine::Logging::LiveRenderer.new(
351
- labels: parts.map(&:inspect), partitions: parts, per_partition_docs_estimates: docs_estimates
363
+ labels: parts.map(&:inspect), partitions: parts,
364
+ per_partition_docs_estimates: docs_estimates,
365
+ io: SearchEngine::Logging::Output.io
352
366
  )
353
367
  renderer.start
354
368
 
@@ -387,7 +401,9 @@ module SearchEngine
387
401
 
388
402
  docs_estimates = __se_per_partition_docs_estimates(parts, compiled)
389
403
  renderer = SearchEngine::Logging::LiveRenderer.new(
390
- labels: parts.map(&:inspect), partitions: parts, per_partition_docs_estimates: docs_estimates
404
+ labels: parts.map(&:inspect), partitions: parts,
405
+ per_partition_docs_estimates: docs_estimates,
406
+ io: SearchEngine::Logging::Output.io
391
407
  )
392
408
  renderer.start
393
409
 
@@ -18,9 +18,10 @@ module SearchEngine
18
18
  # When no targets are provided, all declared/registered collections are indexed
19
19
  # (models are eagerly loaded from the configured `search_engine_models` path).
20
20
  # @param targets [Array<Symbol, String, Class>] collections or model classes
21
+ # @param silent [Boolean] suppress progress output to stdout (errors still go to stderr)
21
22
  # @return [Hash] summary (includes :failed_collections_total for unresolved targets)
22
- def index_collections(*targets, client: nil)
23
- run!(mode: :index, targets: targets, client: client)
23
+ def index_collections(*targets, client: nil, silent: false)
24
+ run!(mode: :index, targets: targets, client: client, silent: silent)
24
25
  end
25
26
 
26
27
  # Index all registered/declared collections.
@@ -30,20 +31,22 @@ module SearchEngine
30
31
  # and runs indexing as if they were passed to {.index_collections}.
31
32
  #
32
33
  # @param client [SearchEngine::Client, nil]
34
+ # @param silent [Boolean] suppress progress output to stdout (errors still go to stderr)
33
35
  # @return [Hash] summary (includes :failed_collections_total for unresolved targets)
34
- def index_all(client: nil)
36
+ def index_all(client: nil, silent: false)
35
37
  ensure_models_loaded_from_configured_path!
36
38
  names = SearchEngine::CollectionResolver.models_map.keys
37
- run!(mode: :index, targets: names, client: client)
39
+ run!(mode: :index, targets: names, client: client, silent: silent)
38
40
  end
39
41
 
40
42
  # Drop+index (destructive), mirroring {SearchEngine::Base.reindex_collection!}.
41
43
  # When no targets are provided, all declared/registered collections are reindexed
42
44
  # (models are eagerly loaded from the configured `search_engine_models` path).
43
45
  # @param targets [Array<Symbol, String, Class>] collections or model classes
46
+ # @param silent [Boolean] suppress progress output to stdout (errors still go to stderr)
44
47
  # @return [Hash] summary (includes :failed_collections_total for unresolved targets)
45
- def reindex_collections!(*targets, client: nil)
46
- run!(mode: :reindex, targets: targets, client: client)
48
+ def reindex_collections!(*targets, client: nil, silent: false)
49
+ run!(mode: :reindex, targets: targets, client: client, silent: silent)
47
50
  end
48
51
 
49
52
  # Reindex all registered/declared collections.
@@ -53,11 +56,12 @@ module SearchEngine
53
56
  # and runs reindexing as if they were passed to {.reindex_collections!}.
54
57
  #
55
58
  # @param client [SearchEngine::Client, nil]
59
+ # @param silent [Boolean] suppress progress output to stdout (errors still go to stderr)
56
60
  # @return [Hash] summary (includes :failed_collections_total for unresolved targets)
57
- def reindex_all!(client: nil)
61
+ def reindex_all!(client: nil, silent: false)
58
62
  ensure_models_loaded_from_configured_path!
59
63
  names = SearchEngine::CollectionResolver.models_map.keys
60
- run!(mode: :reindex, targets: names, client: client)
64
+ run!(mode: :reindex, targets: names, client: client, silent: silent)
61
65
  end
62
66
 
63
67
  # Drop orphaned physical collections across all logical collections.
@@ -74,8 +78,9 @@ module SearchEngine
74
78
  # @param mode [Symbol] :index | :reindex
75
79
  # @param targets [Array]
76
80
  # @param client [SearchEngine::Client, nil]
81
+ # @param silent [Boolean]
77
82
  # @return [Hash]
78
- def run!(mode:, targets:, client: nil)
83
+ def run!(mode:, targets:, client: nil, silent: false)
79
84
  raise ArgumentError, 'mode must be :index or :reindex' unless %i[index reindex].include?(mode.to_sym)
80
85
 
81
86
  ts_client = client || SearchEngine.client
@@ -119,7 +124,9 @@ module SearchEngine
119
124
  collection_results = []
120
125
  failed_collections_total = 0
121
126
 
122
- SearchEngine::Instrumentation.with_context(bulk: true, bulk_suppress_cascade: true, bulk_mode: mode.to_sym) do
127
+ ctx = { bulk: true, bulk_suppress_cascade: true, bulk_mode: mode.to_sym }
128
+ ctx[:bulk_silent] = true if silent
129
+ SearchEngine::Instrumentation.with_context(ctx) do
123
130
  SearchEngine::Instrumentation.instrument('search_engine.bulk.run', payload.merge(stats)) do |ctx|
124
131
  run_stage!(mode, stage1_list, :input, collection_results)
125
132
  run_stage!(mode, cascade_order, :cascade, collection_results)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'search_engine/logging/output'
4
+
3
5
  module SearchEngine
4
6
  # Cascade reindexing for collections that reference other collections via
5
7
  # Typesense field-level references.
@@ -192,12 +194,16 @@ into: nil
192
194
  parts = parts.reject { |p| p.nil? || p.to_s.strip.empty? }
193
195
 
194
196
  if parts.empty?
195
- puts(SearchEngine::Logging::Color.dim(%( Referencer "#{coll_display}" — partitions=0 → skip)))
197
+ SearchEngine::Logging::Output.puts(
198
+ SearchEngine::Logging::Color.dim(%( Referencer "#{coll_display}" — partitions=0 → skip))
199
+ )
196
200
  return false
197
201
  end
198
202
 
199
203
  parts_str = SearchEngine::Logging::Color.bold("partitions=#{parts.size}")
200
- puts(%( Referencer "#{coll_display}" — #{parts_str} parallel=#{compiled.max_parallel}))
204
+ SearchEngine::Logging::Output.puts(
205
+ %( Referencer "#{coll_display}" — #{parts_str} parallel=#{compiled.max_parallel})
206
+ )
201
207
  mp = compiled.max_parallel.to_i
202
208
  if mp > 1 && parts.size > 1
203
209
  require 'concurrent-ruby'
@@ -214,7 +220,9 @@ into: nil
214
220
  end
215
221
 
216
222
  else
217
- puts(%( Referencer "#{coll_display}" — #{SearchEngine::Logging::Color.bold('single')}))
223
+ SearchEngine::Logging::Output.puts(
224
+ %( Referencer "#{coll_display}" — #{SearchEngine::Logging::Color.bold('single')})
225
+ )
218
226
  SearchEngine::Indexer.rebuild_partition!(ref_klass, partition: nil, into: nil)
219
227
  executed = true
220
228
  end
@@ -336,7 +344,7 @@ into: nil
336
344
  coll_display = physical && physical != logical ? "#{logical} (physical: #{physical})" : logical
337
345
  action = force_rebuild ? 'force_rebuild index_collection' : 'index_collection'
338
346
  status_word = SearchEngine::Logging::Color.apply("schema rebuild required, running #{action}", :yellow)
339
- puts(%( Referencer "#{coll_display}" — #{status_word}))
347
+ SearchEngine::Logging::Output.puts(%( Referencer "#{coll_display}" — #{status_word}))
340
348
 
341
349
  SearchEngine::Instrumentation.with_context(bulk_suppress_cascade: true) do
342
350
  ref_klass.index_collection(client: client, pre: :ensure, force_rebuild: force_rebuild)
@@ -344,7 +352,7 @@ into: nil
344
352
  true
345
353
  rescue StandardError => error
346
354
  err_line = %( Referencer "#{logical}" — schema rebuild failed: #{error.message})
347
- puts(SearchEngine::Logging::Color.apply(err_line, :red))
355
+ warn(SearchEngine::Logging::Color.apply(err_line, :red))
348
356
  false
349
357
  end
350
358
 
@@ -353,7 +361,9 @@ into: nil
353
361
  pool.post do
354
362
  SearchEngine::Instrumentation.with_context(ctx) do
355
363
  summary = SearchEngine::Indexer.rebuild_partition!(ref_klass, partition: p, into: nil)
356
- mtx.synchronize { puts(SearchEngine::Logging::PartitionProgress.line(p, summary)) }
364
+ mtx.synchronize do
365
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::PartitionProgress.line(p, summary))
366
+ end
357
367
  end
358
368
  end
359
369
  end
@@ -363,7 +373,7 @@ into: nil
363
373
  executed = false
364
374
  parts.each do |p|
365
375
  summary = SearchEngine::Indexer.rebuild_partition!(ref_klass, partition: p, into: nil)
366
- puts(SearchEngine::Logging::PartitionProgress.line(p, summary))
376
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::PartitionProgress.line(p, summary))
367
377
  executed = true
368
378
  end
369
379
  executed
@@ -56,6 +56,10 @@ module SearchEngine
56
56
  # @return [String, nil, false] path to host app SearchEngine models directory. May be
57
57
  # relative to `Rails.root` (e.g., "app/search_engine") or absolute. When `nil` or
58
58
  # `false`, gem-managed loading of host SearchEngine models is disabled.
59
+ # @!attribute [rw] syncable_callback_timing
60
+ # @return [Symbol] controls ActiveRecordSyncable callback timing.
61
+ # +:after_commit+ (default) uses +after_*_commit+ callbacks (safe, post-transaction).
62
+ # +:after_save+ uses legacy +after_*+ callbacks (in-transaction).
59
63
  attr_accessor :logger,
60
64
  :default_query_by,
61
65
  :default_infix,
@@ -67,7 +71,8 @@ module SearchEngine
67
71
  :client,
68
72
  :default_console_model,
69
73
  :search_engine_models,
70
- :relation_print_materializes
74
+ :relation_print_materializes,
75
+ :syncable_callback_timing
71
76
 
72
77
  # Lightweight nested configuration for schema lifecycle.
73
78
  class SchemaConfig
@@ -402,6 +407,9 @@ module SearchEngine
402
407
  @search_engine_models = 'app/search_engine'
403
408
  # When true, Relation#inspect/pretty_print materialize a preview (AR-like).
404
409
  @relation_print_materializes = true
410
+ # Controls whether ActiveRecordSyncable uses after_*_commit (safe, default)
411
+ # or after_* (legacy in-transaction) callbacks. Values: :after_commit, :after_save.
412
+ @syncable_callback_timing = :after_commit
405
413
  end
406
414
 
407
415
  # Whether the engine should avoid network I/O and use an offline client.
@@ -701,7 +709,8 @@ module SearchEngine
701
709
  presets: presets_hash_for_to_h,
702
710
  curation: curation_hash_for_to_h,
703
711
  embedding: embedding_hash_for_to_h,
704
- relation_print_materializes: relation_print_materializes ? true : false
712
+ relation_print_materializes: relation_print_materializes ? true : false,
713
+ syncable_callback_timing: syncable_callback_timing
705
714
  }
706
715
  end
707
716
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'search_engine/logging/color'
4
4
  require 'search_engine/logging/batch_line'
5
+ require 'search_engine/logging/output'
5
6
 
6
7
  module SearchEngine
7
8
  class Indexer
@@ -151,7 +152,11 @@ module SearchEngine
151
152
  shared_state[:on_batch] = on_batch
152
153
  producer_error = nil
153
154
 
154
- puts(SearchEngine::Logging::Color.dim(' Starting parallel batch processing...')) if log_batches
155
+ if log_batches
156
+ SearchEngine::Logging::Output.puts(
157
+ SearchEngine::Logging::Color.dim(' Starting parallel batch processing...')
158
+ )
159
+ end
155
160
  started_at = monotonic_ms
156
161
 
157
162
  producer_thread = start_producer_thread(
@@ -234,7 +239,7 @@ module SearchEngine
234
239
  else
235
240
  " Processed #{batch_count} batches... (#{elapsed}ms)"
236
241
  end
237
- puts(SearchEngine::Logging::Color.dim(progress))
242
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::Color.dim(progress))
238
243
  end
239
244
  rescue StandardError => error
240
245
  yield error if block_given?
@@ -680,7 +685,7 @@ module SearchEngine
680
685
  end
681
686
 
682
687
  def log_batch(stats, batch_number)
683
- puts(SearchEngine::Logging::BatchLine.format(stats, batch_number))
688
+ SearchEngine::Logging::Output.puts(SearchEngine::Logging::BatchLine.format(stats, batch_number))
684
689
  end
685
690
  end
686
691
  end
@@ -83,7 +83,7 @@ module SearchEngine
83
83
  enum: docs_enum,
84
84
  batch_size: nil,
85
85
  action: :upsert,
86
- log_batches: partition.nil? && on_batch.nil?,
86
+ log_batches: !SearchEngine::Instrumentation.context[:bulk_silent] && partition.nil? && on_batch.nil?,
87
87
  max_parallel: max_parallel,
88
88
  on_batch: on_batch
89
89
  )
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module Logging
5
+ # Thread-safe output routing for indexation progress logging.
6
+ #
7
+ # When the instrumentation context includes `bulk_silent: true`,
8
+ # all output is routed to {File::NULL}. Otherwise, output goes to `$stdout`.
9
+ # Error output via `warn()` is unaffected — it always reaches `$stderr`.
10
+ #
11
+ # @since M10
12
+ module Output
13
+ NULL_IO = File.open(File::NULL, 'w')
14
+
15
+ module_function
16
+
17
+ # @return [Boolean] true when the current thread context indicates silent mode
18
+ def silent?
19
+ SearchEngine::Instrumentation.context[:bulk_silent] == true
20
+ end
21
+
22
+ # @return [IO] the appropriate output stream for progress logging
23
+ def io
24
+ silent? ? NULL_IO : $stdout
25
+ end
26
+
27
+ # Write a line to the progress output (suppressed in silent mode).
28
+ # @param args [Array] arguments forwarded to `IO#puts`
29
+ # @return [nil]
30
+ def puts(*args)
31
+ io.puts(*args)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ module DSL
6
+ # Typesense `_eval()` conditional sort expressions.
7
+ # Mixed into Relation's DSL; preserves copy-on-write semantics.
8
+ module Eval
9
+ # Sort by a Typesense `_eval()` conditional expression.
10
+ #
11
+ # Accepts a plain filter-syntax string (simple form) or an Array of
12
+ # `{ expr:, weight: }` hashes (weighted multi-expression form).
13
+ #
14
+ # @param expression [String, Array<Hash>] filter expression(s)
15
+ # @param direction [Symbol] `:desc` (default, matches first) or `:asc`
16
+ # @return [SearchEngine::Relation]
17
+ def order_eval(expression, direction: :desc)
18
+ dir = direction.to_s.downcase
19
+ unless %w[asc desc].include?(dir)
20
+ raise ArgumentError, "order_eval: direction must be :asc or :desc (got #{direction.inspect})"
21
+ end
22
+
23
+ sort_token = case expression
24
+ when String
25
+ raise ArgumentError, 'order_eval: expression must not be blank' if expression.strip.empty?
26
+
27
+ "_eval(#{expression}):#{dir}"
28
+ when Array
29
+ validate_weighted_expressions!(expression)
30
+ weighted = expression.map { |e| "(#{e[:expr]}):#{e[:weight]}" }.join(', ')
31
+ "_eval([ #{weighted} ]):#{dir}"
32
+ else
33
+ raise ArgumentError,
34
+ 'order_eval: expression must be a String or Array of { expr:, weight: }'
35
+ end
36
+
37
+ spawn do |s|
38
+ existing = Array(s[:orders])
39
+ s[:orders] = dedupe_orders_last_wins(existing + [sort_token])
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def validate_weighted_expressions!(expressions)
46
+ unless expressions.is_a?(Array) && !expressions.empty? && expressions.all? { |e| e.is_a?(Hash) }
47
+ raise ArgumentError, 'order_eval: weighted form expects a non-empty Array of { expr:, weight: }'
48
+ end
49
+
50
+ expressions.each_with_index do |entry, i|
51
+ unless entry[:expr].is_a?(String) && !entry[:expr].strip.empty?
52
+ raise ArgumentError, "order_eval: entry #{i} must have a non-blank :expr"
53
+ end
54
+ unless entry[:weight].is_a?(Integer) && entry[:weight].positive?
55
+ raise ArgumentError, "order_eval: entry #{i} :weight must be a positive Integer"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ module DSL
6
+ # Geo search chainers: filtering by radius/polygon and geo distance sorting.
7
+ # Mixed into Relation's DSL; preserves copy-on-write semantics.
8
+ # See DSL::Eval for `_eval()`-based conditional sort expressions.
9
+ module Geo
10
+ # Filter by geographic proximity (radius) or containment (polygon).
11
+ #
12
+ # @param field [Symbol, String] a `:geopoint` or `[:geopoint]` attribute
13
+ # @param within_radius [Hash, nil] `{ lat:, lng:, radius: "10 km" }`
14
+ # @param within_polygon [Array<Array(Numeric,Numeric)>, nil] three or more `[lat, lng]` pairs
15
+ # @return [SearchEngine::Relation]
16
+ def where_geo(field, within_radius: nil, within_polygon: nil)
17
+ validate_geo_field!(field)
18
+ validate_geo_predicate_exclusivity!(within_radius, within_polygon)
19
+
20
+ fragment = if within_radius
21
+ build_radius_filter(field, within_radius)
22
+ else
23
+ build_polygon_filter(field, within_polygon)
24
+ end
25
+
26
+ spawn do |s|
27
+ s[:ast] = Array(s[:ast]) + [SearchEngine::AST.raw(fragment)]
28
+ s[:filters] = Array(s[:filters])
29
+ end
30
+ end
31
+
32
+ # Sort by geographic distance from a reference point.
33
+ #
34
+ # @param field [Symbol, String] a `:geopoint` or `[:geopoint]` attribute
35
+ # @param from [Hash] `{ lat:, lng: }` — the reference point
36
+ # @param direction [Symbol] `:asc` (nearest first, default) or `:desc`
37
+ # @param exclude_radius [String, nil] e.g. `"2 km"` — exclude results within this radius from distance scoring
38
+ # @param precision [String, nil] e.g. `"1 km"` — bucket precision for distance sort
39
+ # @return [SearchEngine::Relation]
40
+ def order_geo(field, from:, direction: :asc, exclude_radius: nil, precision: nil)
41
+ validate_geo_field!(field, context: 'order_geo')
42
+
43
+ lat = from[:lat]
44
+ lng = from[:lng]
45
+ validate_geo_coordinate!(lat, lng, context: 'order_geo')
46
+
47
+ dir = direction.to_s.downcase
48
+ unless %w[asc desc].include?(dir)
49
+ raise ArgumentError, "order_geo: direction must be :asc or :desc (got #{direction.inspect})"
50
+ end
51
+
52
+ sort_token = build_geo_sort_token(field, lat, lng, dir, exclude_radius, precision)
53
+
54
+ spawn do |s|
55
+ existing = Array(s[:orders])
56
+ s[:orders] = dedupe_orders_last_wins(existing + [sort_token])
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def validate_geo_field!(field, context: 'where_geo')
63
+ attrs = safe_attributes_map
64
+ return unless attrs && !attrs.empty?
65
+
66
+ type = attrs[field.to_sym]
67
+ return if [:geopoint, [:geopoint]].include?(type)
68
+
69
+ raise ArgumentError,
70
+ "#{context}: field :#{field} must be declared as :geopoint or [:geopoint]"
71
+ end
72
+
73
+ def validate_geo_predicate_exclusivity!(within_radius, within_polygon)
74
+ if within_radius.nil? && within_polygon.nil?
75
+ raise ArgumentError, 'where_geo: provide either within_radius: or within_polygon:'
76
+ end
77
+ return unless within_radius && within_polygon
78
+
79
+ raise ArgumentError, 'where_geo: within_radius: and within_polygon: are mutually exclusive'
80
+ end
81
+
82
+ def validate_geo_coordinate!(lat, lng, context: 'where_geo')
83
+ unless lat.is_a?(Numeric) && lat >= -90 && lat <= 90
84
+ raise ArgumentError, "#{context}: lat must be a number in [-90, 90] (got #{lat.inspect})"
85
+ end
86
+ return if lng.is_a?(Numeric) && lng >= -180 && lng <= 180
87
+
88
+ raise ArgumentError, "#{context}: lng must be a number in [-180, 180] (got #{lng.inspect})"
89
+ end
90
+
91
+ def validate_radius!(radius, context: 'where_geo')
92
+ return if radius.is_a?(String) && radius.match?(/\A\d+(\.\d+)?\s*(km|mi)\z/)
93
+
94
+ raise ArgumentError,
95
+ "#{context}: radius must be a string like '10 km' or '5 mi' (got #{radius.inspect})"
96
+ end
97
+
98
+ def build_radius_filter(field, opts)
99
+ lat = opts[:lat]
100
+ lng = opts[:lng]
101
+ radius = opts[:radius]
102
+ validate_geo_coordinate!(lat, lng)
103
+ validate_radius!(radius)
104
+ "#{field}:(#{lat}, #{lng}, #{radius})"
105
+ end
106
+
107
+ def build_polygon_filter(field, points)
108
+ unless points.is_a?(Array) && points.size >= 3
109
+ raise ArgumentError, "where_geo: polygon must have >= 3 points (got #{points&.size || 0})"
110
+ end
111
+
112
+ points.each_with_index do |point, i|
113
+ unless point.is_a?(Array) && point.size == 2
114
+ raise ArgumentError, "where_geo: polygon point #{i} must be [lat, lng]"
115
+ end
116
+
117
+ validate_geo_coordinate!(point[0], point[1])
118
+ end
119
+
120
+ coords = points.map { |p| "#{p[0]}, #{p[1]}" }.join(', ')
121
+ "#{field}:(#{coords})"
122
+ end
123
+
124
+ def build_geo_sort_token(field, lat, lng, dir, exclude_radius, precision)
125
+ parts = +"#{field}(#{lat}, #{lng}"
126
+ if exclude_radius
127
+ validate_radius!(exclude_radius, context: 'order_geo')
128
+ parts << ", exclude_radius: #{exclude_radius}"
129
+ end
130
+ if precision
131
+ validate_radius!(precision, context: 'order_geo')
132
+ parts << ", precision: #{precision}"
133
+ end
134
+ "#{parts}):#{dir}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'search_engine/relation/dsl/eval'
3
4
  require 'search_engine/relation/dsl/filters'
5
+ require 'search_engine/relation/dsl/geo'
4
6
  require 'search_engine/relation/dsl/selection'
5
7
  require 'search_engine/relation/dsl/vectors'
6
8
 
@@ -9,7 +11,9 @@ module SearchEngine
9
11
  # User-facing chainers and input normalizers.
10
12
  # Chainers MUST be copy-on-write and return new Relation instances.
11
13
  module DSL
14
+ include SearchEngine::Relation::DSL::Eval
12
15
  include SearchEngine::Relation::DSL::Filters
16
+ include SearchEngine::Relation::DSL::Geo
13
17
  include SearchEngine::Relation::DSL::Selection
14
18
  include SearchEngine::Relation::DSL::Vectors
15
19
 
@@ -94,6 +94,7 @@ module SearchEngine
94
94
 
95
95
  obj = hydrate(entry[:document])
96
96
  attach_highlighting!(obj, entry)
97
+ attach_geo_distance!(obj, entry)
97
98
  hydrated << obj
98
99
  end
99
100
  @hits = hydrated.freeze
@@ -284,6 +285,16 @@ module SearchEngine
284
285
  end
285
286
  end
286
287
 
288
+ # Per-hit geo distance mixin: added onto hydrated objects when Typesense
289
+ # returns geo_distance_meters metadata (present when sort_by includes a
290
+ # geopoint distance sort).
291
+ module GeoDistance
292
+ # @return [Hash{String=>Numeric}, nil] mapping of geo field name to distance in meters
293
+ def geo_distance_meters
294
+ instance_variable_get(:@__se_geo_distance__)&.dup
295
+ end
296
+ end
297
+
287
298
  def parse_facets
288
299
  @__facets_parsed_memo || {}.freeze
289
300
  end
@@ -389,7 +400,9 @@ module SearchEngine
389
400
  next unless doc
390
401
 
391
402
  obj = hydrate(doc)
392
- attach_highlighting!(obj, symbolize_hit(sub))
403
+ sym_sub = symbolize_hit(sub)
404
+ attach_highlighting!(obj, sym_sub)
405
+ attach_geo_distance!(obj, sym_sub)
393
406
  hydrated << obj
394
407
  end
395
408
 
@@ -533,6 +546,17 @@ module SearchEngine
533
546
  obj
534
547
  end
535
548
 
549
+ def attach_geo_distance!(obj, hit_entry)
550
+ raw_geo = hit_entry[:geo_distance_meters]
551
+ return obj unless raw_geo.is_a?(Hash) && !raw_geo.empty?
552
+
553
+ obj.extend(GeoDistance) unless obj.singleton_class.included_modules.include?(GeoDistance)
554
+ obj.instance_variable_set(:@__se_geo_distance__, raw_geo)
555
+ obj
556
+ rescue StandardError
557
+ obj
558
+ end
559
+
536
560
  def safe_highlight_ctx
537
561
  ctx = @highlight_ctx || {}
538
562
  return {} unless ctx.is_a?(Hash)
@@ -31,7 +31,8 @@ module SearchEngine
31
31
  datetime: 'int64',
32
32
  time_string: 'string',
33
33
  datetime_string: 'string',
34
- vector: 'float[]'
34
+ vector: 'float[]',
35
+ geopoint: 'geopoint'
35
36
  }.freeze
36
37
 
37
38
  FIELD_COMPARE_KEYS = %i[
@@ -818,11 +819,13 @@ module SearchEngine
818
819
  s = type_string.to_s
819
820
  return 'string[]' if s.casecmp('string[]').zero?
820
821
  return 'float[]' if s.casecmp('float[]').zero?
822
+ return 'geopoint[]' if s.casecmp('geopoint[]').zero?
821
823
  return 'int64' if s.casecmp('int64').zero?
822
824
  return 'int32' if s.casecmp('int32').zero?
823
825
  return 'float' if s.casecmp('float').zero?
824
826
  return 'bool' if %w[bool boolean].include?(s.downcase)
825
827
  return 'string' if s.casecmp('string').zero?
828
+ return 'geopoint' if s.casecmp('geopoint').zero?
826
829
 
827
830
  # Fallback: return as-is
828
831
  s
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.6.17'
6
+ VERSION = '30.1.7.0'
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.6.17
4
+ version: 30.1.7.0
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-04-06 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -159,6 +159,7 @@ files:
159
159
  - lib/search_engine/logging/cursor_guard.rb
160
160
  - lib/search_engine/logging/format_helpers.rb
161
161
  - lib/search_engine/logging/live_renderer.rb
162
+ - lib/search_engine/logging/output.rb
162
163
  - lib/search_engine/logging/partition_progress.rb
163
164
  - lib/search_engine/logging/spinner.rb
164
165
  - lib/search_engine/logging/step_line.rb
@@ -177,7 +178,9 @@ files:
177
178
  - lib/search_engine/relation/compiler.rb
178
179
  - lib/search_engine/relation/deletion.rb
179
180
  - lib/search_engine/relation/dsl.rb
181
+ - lib/search_engine/relation/dsl/eval.rb
180
182
  - lib/search_engine/relation/dsl/filters.rb
183
+ - lib/search_engine/relation/dsl/geo.rb
181
184
  - lib/search_engine/relation/dsl/selection.rb
182
185
  - lib/search_engine/relation/dsl/vectors.rb
183
186
  - lib/search_engine/relation/dx.rb