search-engine-for-typesense 30.1.6.18 → 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: 01b787168b6ef3694f27552da317fa390eaee171bf0734d94725b4527a883968
4
- data.tar.gz: 28b30b42639215b992ce6b152bd9c076dc5dc221f6c9a18be5f2355aa7c63865
3
+ metadata.gz: 9eea6beb086957bbee9df77008a70750be3a74f6f39a53be361a3f1ae8295219
4
+ data.tar.gz: 0eaf79b97f1c68b7bdab350f11cbfe09eecfb4944c76fd5c49528b68e63a5f09
5
5
  SHA512:
6
- metadata.gz: f5229d42405c66d0811102f31654631d8654bd52bdd55605f12f6baf446c2d61428d95a3f6a0c56a05b6dc4b404a68d3772ee13d4a1ca6a1739efd3de2f93390
7
- data.tar.gz: faf42770cec8639077c6efae5e0f1599d1d9eabba0b3fdab23149a5a334ba3a3f50cc1156940bbb8b373f95b695f3e70457370687a774e39c0d2934b9f4cc07a
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
@@ -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
 
@@ -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.18'
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.18
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-27 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
@@ -178,7 +178,9 @@ files:
178
178
  - lib/search_engine/relation/compiler.rb
179
179
  - lib/search_engine/relation/deletion.rb
180
180
  - lib/search_engine/relation/dsl.rb
181
+ - lib/search_engine/relation/dsl/eval.rb
181
182
  - lib/search_engine/relation/dsl/filters.rb
183
+ - lib/search_engine/relation/dsl/geo.rb
182
184
  - lib/search_engine/relation/dsl/selection.rb
183
185
  - lib/search_engine/relation/dsl/vectors.rb
184
186
  - lib/search_engine/relation/dx.rb