search-engine-for-typesense 30.9.1 → 30.9.3

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +51 -16
  4. data/lib/search_engine/active_record_syncable.rb +85 -197
  5. data/lib/search_engine/ast/node.rb +3 -6
  6. data/lib/search_engine/async_partition_coordinator.rb +5 -3
  7. data/lib/search_engine/client/services/search.rb +2 -0
  8. data/lib/search_engine/client.rb +9 -3
  9. data/lib/search_engine/compiler.rb +33 -128
  10. data/lib/search_engine/deletion.rb +12 -9
  11. data/lib/search_engine/dsl/parser.rb +51 -71
  12. data/lib/search_engine/engine.rb +25 -16
  13. data/lib/search_engine/filters/sanitizer.rb +162 -126
  14. data/lib/search_engine/hydration/materializers.rb +53 -118
  15. data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
  16. data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
  17. data/lib/search_engine/indexing_run_store.rb +10 -0
  18. data/lib/search_engine/joins/guard.rb +14 -8
  19. data/lib/search_engine/multi.rb +92 -6
  20. data/lib/search_engine/multi_result.rb +40 -23
  21. data/lib/search_engine/notifications/compact_logger.rb +2 -2
  22. data/lib/search_engine/observability.rb +6 -6
  23. data/lib/search_engine/postgres_outbox/database_queries.rb +17 -0
  24. data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
  25. data/lib/search_engine/postgres_outbox/event.rb +12 -0
  26. data/lib/search_engine/postgres_outbox/materialization_cursor.rb +25 -11
  27. data/lib/search_engine/postgres_outbox/repository.rb +65 -115
  28. data/lib/search_engine/postgres_outbox.rb +2 -0
  29. data/lib/search_engine/registry.rb +27 -28
  30. data/lib/search_engine/relation/compiler.rb +18 -33
  31. data/lib/search_engine/relation/dsl/filters.rb +71 -175
  32. data/lib/search_engine/relation/dx.rb +3 -1
  33. data/lib/search_engine/relation/materializers.rb +6 -3
  34. data/lib/search_engine/relation/options.rb +5 -26
  35. data/lib/search_engine/relation/state.rb +3 -2
  36. data/lib/search_engine/relation.rb +5 -4
  37. data/lib/search_engine/result.rb +33 -2
  38. data/lib/search_engine/schema.rb +23 -24
  39. data/lib/search_engine/test/offline_client.rb +2 -2
  40. data/lib/search_engine/test/stub_client.rb +9 -16
  41. data/lib/search_engine/update.rb +1 -1
  42. data/lib/search_engine/version.rb +1 -1
  43. data/lib/search_engine.rb +11 -149
  44. data/lib/tasks/search_engine.rake +3 -5
  45. metadata +18 -3
  46. /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
@@ -5,11 +5,14 @@ require 'json'
5
5
 
6
6
  module SearchEngine
7
7
  module PostgresOutbox
8
- # Read under the outbox materialization lock. One page follows new event IDs;
9
- # the other sweeps backwards to revisit late commits, due retries and existing events.
8
+ # Scan under the materialization lock. Keep moving through history even when new IDs arrive faster.
10
9
  class MaterializationCursor
10
+ include DatabaseQueries
11
+
11
12
  PAGE_SIZE = 1000
12
13
 
14
+ attr_reader :checkpoint_ids
15
+
13
16
  def initialize(connection, events_table, cursor_table, target_keys)
14
17
  @connection = connection
15
18
  @events = events_table
@@ -21,7 +24,7 @@ module SearchEngine
21
24
  # @return [Array<Integer>] event IDs to check against the configured delivery targets
22
25
  def candidate_ids(limit:)
23
26
  @page_size = [PAGE_SIZE, limit].min
24
- state = @connection.select_all(<<~SQL).first
27
+ state = select_rows(<<~SQL).first
25
28
  SELECT forward_id, backfill_through_id FROM #{@table} WHERE target_scope = #{@scope}
26
29
  SQL
27
30
  if state
@@ -34,18 +37,27 @@ module SearchEngine
34
37
  # Recheck a recent ID range for concurrent inserts that commit behind the watermark.
35
38
  # Even for dense IDs, at least half of the page remains available for new events.
36
39
  @forward_page = event_ids("id > #{@forward_id - @page_size / 2} ORDER BY id ASC")
37
- @backfill_page = event_ids("id <= #{@backfill_through_id} ORDER BY id DESC")
38
- @forward_page | @backfill_page
40
+ # A returning target can be far behind. Reserve some of the second page for the current head.
41
+ @recent_page = if @forward_page.size == @page_size
42
+ event_ids("id > #{@forward_page.last} ORDER BY id DESC", [@page_size / 2, 1].max)
43
+ else
44
+ []
45
+ end
46
+ @backfill_limit = [@page_size - @recent_page.size, 1].max
47
+ @backfill_page = event_ids("id <= #{@backfill_through_id} ORDER BY id DESC", @backfill_limit)
48
+ @checkpoint_ids = @forward_page | @backfill_page
49
+ @checkpoint_ids | @recent_page
39
50
  end
40
51
 
41
52
  # Save progress in the same transaction as the new deliveries.
42
53
  def advance!
43
54
  forward = [@forward_page.last || @forward_id, @forward_id].max
44
55
  # Freeze the upper boundary until the backwards sweep reaches the beginning.
45
- backfill = @backfill_page.size < @page_size ? forward : @backfill_page.last - 1
56
+ head = [forward, @recent_page.first || forward].max
57
+ backfill = @backfill_page.size < @backfill_limit ? head : @backfill_page.last - 1
46
58
  return if forward == @forward_id && backfill == @backfill_through_id
47
59
 
48
- @connection.execute(<<~SQL)
60
+ connection.execute(<<~SQL)
49
61
  UPDATE #{@table} SET forward_id = #{forward}, backfill_through_id = #{backfill}
50
62
  WHERE target_scope = #{@scope}
51
63
  SQL
@@ -53,17 +65,19 @@ module SearchEngine
53
65
 
54
66
  private
55
67
 
68
+ attr_reader :connection
69
+
56
70
  def initialize_positions
57
- head = Integer(@connection.select_value("SELECT COALESCE(MAX(id), 0) FROM #{@events}"))
58
- @connection.execute(<<~SQL)
71
+ head = Integer(select_rows("SELECT COALESCE(MAX(id), 0) AS head FROM #{@events}").first.fetch('head'))
72
+ connection.execute(<<~SQL)
59
73
  INSERT INTO #{@table} (target_scope, forward_id, backfill_through_id)
60
74
  VALUES (#{@scope}, #{head}, #{head})
61
75
  SQL
62
76
  [head, head]
63
77
  end
64
78
 
65
- def event_ids(order)
66
- @connection.select_all("SELECT id FROM #{@events} WHERE #{order} LIMIT #{@page_size}").map do |row|
79
+ def event_ids(order, limit = @page_size)
80
+ select_rows("SELECT id FROM #{@events} WHERE #{order} LIMIT #{limit}").map do |row|
67
81
  Integer(row.fetch('id'))
68
82
  end
69
83
  end
@@ -7,6 +7,8 @@ module SearchEngine
7
7
  module PostgresOutbox
8
8
  # Raw SQL repository for host-managed PostgreSQL outbox rows.
9
9
  class Repository
10
+ include DatabaseQueries
11
+
10
12
  # Keep expensive duplicate-key ranking bounded while leaving headroom for hot keys.
11
13
  CLAIM_CANDIDATE_OVERSAMPLE = 4
12
14
  MATERIALIZATION_LOCK_NAMESPACE = 0x53454F42 # SEOB
@@ -28,6 +30,12 @@ module SearchEngine
28
30
  @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
29
31
  end
30
32
 
33
+ # Single-target claims issue a fresh owner even when a caller reuses its worker id.
34
+ # Target-scoped repositories accept the caller's per-attempt owner.
35
+ def assigns_event_lease_owner?
36
+ !delivery_mode?
37
+ end
38
+
31
39
  # Claim pending rows for one worker and return event objects.
32
40
  # @param limit [Integer]
33
41
  # @param worker_id [String]
@@ -36,16 +44,15 @@ module SearchEngine
36
44
  return claim_pending_deliveries(limit: limit, worker_id: worker_id) if delivery_mode?
37
45
 
38
46
  reset_stale_processing!
39
- rows = []
40
-
41
47
  connection.transaction do
42
48
  rows = select_rows(claim_select_sql(limit))
49
+ next [] if rows.empty?
50
+
51
+ execute(supersede_older_pending_sql(rows))
43
52
  ids = rows.map { |row| row_value(row, :id) }
44
- execute(supersede_older_pending_sql(rows)) unless rows.empty?
45
- execute(claim_update_sql(ids, worker_id)) unless ids.empty?
53
+ owner = Event.new_lease_owner(worker_id)
54
+ select_rows(claim_update_sql(ids, owner)).map { |row| Event.new(row) }.sort_by { |event| event.id.to_i }
46
55
  end
47
-
48
- rows.map { |row| Event.new(row) }
49
56
  end
50
57
 
51
58
  # Reset timed-out processing rows to pending.
@@ -71,62 +78,37 @@ module SearchEngine
71
78
  SQL
72
79
  end
73
80
 
74
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
81
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
75
82
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
76
83
  def mark_processed!(events)
77
- return update_delivery_status!(events, 'processed', extra: PROCESSED_AT_ASSIGNMENT) if delivery_mode?
78
-
79
- update_status!(events, 'processed', extra: PROCESSED_AT_ASSIGNMENT)
84
+ acknowledge!(events) { "status = 'processed', #{PROCESSED_AT_ASSIGNMENT}" }
80
85
  end
81
86
 
82
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
87
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
83
88
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
84
89
  def mark_superseded!(events)
85
- return update_delivery_status!(events, 'superseded', extra: PROCESSED_AT_ASSIGNMENT) if delivery_mode?
86
-
87
- update_status!(events, 'superseded', extra: PROCESSED_AT_ASSIGNMENT)
90
+ acknowledge!(events) { "status = 'superseded', #{PROCESSED_AT_ASSIGNMENT}" }
88
91
  end
89
92
 
90
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
93
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
91
94
  # @param error [Exception, String]
92
95
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
93
96
  def mark_retryable!(events, error:)
94
- ids = event_ids(events)
95
- return [] if ids.empty?
96
- return mark_delivery_retryable!(events, error: error) if delivery_mode?
97
-
98
- execute(<<~SQL)
99
- UPDATE #{quoted_table}
100
- SET attempts = attempts + 1,
101
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
102
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
103
- locked_at = NULL,
104
- locked_by = NULL,
105
- last_error = #{quote(truncate_error(error))},
106
- updated_at = CURRENT_TIMESTAMP
107
- WHERE id IN (#{ids_sql(ids)})
108
- SQL
109
- ids
97
+ acknowledge!(events) do
98
+ <<~SQL.chomp
99
+ attempts = attempts + 1,
100
+ status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
101
+ next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
102
+ last_error = #{quote(truncate_error(error))}
103
+ SQL
104
+ end
110
105
  end
111
106
 
112
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
107
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
113
108
  # @param error [Exception, String]
114
109
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
115
110
  def mark_failed!(events, error:)
116
- ids = event_ids(events)
117
- return [] if ids.empty?
118
- return mark_delivery_failed!(events, error: error) if delivery_mode?
119
-
120
- execute(<<~SQL)
121
- UPDATE #{quoted_table}
122
- SET status = 'failed',
123
- locked_at = NULL,
124
- locked_by = NULL,
125
- last_error = #{quote(truncate_error(error))},
126
- updated_at = CURRENT_TIMESTAMP
127
- WHERE id IN (#{ids_sql(ids)})
128
- SQL
129
- ids
111
+ acknowledge!(events) { "status = 'failed', last_error = #{quote(truncate_error(error))}" }
130
112
  end
131
113
 
132
114
  # Refresh the lock timestamp only for deliveries still owned by these claims.
@@ -198,7 +180,7 @@ module SearchEngine
198
180
  event_ids = cursor&.candidate_ids(limit: scan_limit)
199
181
  preview = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: event_ids, lock: false))
200
182
  rows = preview.empty? ? [] : materialize_batch(limit, targets, preview)
201
- if cursor && rows.size == preview.size && materialization_page_drained?(rows, event_ids, limit)
183
+ if cursor && rows.size == preview.size && page_drained?(rows, event_ids, limit, cursor, targets)
202
184
  cursor.advance!
203
185
  end
204
186
  rows
@@ -414,14 +396,23 @@ module SearchEngine
414
396
  end
415
397
 
416
398
  # When a quota may have omitted matches, revisit the page after these deliveries commit.
417
- def materialization_page_drained?(rows, event_ids, limit)
399
+ def page_drained?(rows, event_ids, limit, cursor, targets)
418
400
  return true if rows.empty? || rows.size == event_ids.size
419
401
 
420
402
  batch_size = global_limit_for(limit)
421
- return rows.size < batch_size unless collection_limited_batch?(limit)
422
-
423
- per_collection = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
424
- rows.size < [batch_size, *per_collection].min
403
+ if collection_limited_batch?(limit)
404
+ quotas = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
405
+ batch_size = [batch_size, *quotas].min
406
+ end
407
+ return true if rows.size < batch_size
408
+ return false if event_ids.size == cursor.checkpoint_ids.size
409
+
410
+ # A moving head can fill a quota on every poll. Only work on the saved pages must hold progress.
411
+ select_rows(<<~SQL).empty?
412
+ SELECT 1 FROM #{quoted_table} outbox
413
+ WHERE #{materialization_filter_sql(targets, cursor.checkpoint_ids)}
414
+ LIMIT 1
415
+ SQL
425
416
  end
426
417
 
427
418
  # Revalidate the exact preview under parent locks before locking any deliveries.
@@ -874,7 +865,7 @@ module SearchEngine
874
865
  ORDER BY ranked_candidate_events.id ASC
875
866
  LIMIT #{limit}
876
867
  )
877
- SELECT outbox.*
868
+ SELECT outbox.id, outbox.collection, outbox.document_id
878
869
  FROM #{quoted_table} outbox
879
870
  INNER JOIN latest_due
880
871
  ON latest_due.id = outbox.id
@@ -892,6 +883,7 @@ module SearchEngine
892
883
  updated_at = CURRENT_TIMESTAMP
893
884
  WHERE id IN (#{ids_sql(ids)})
894
885
  AND status = 'pending'
886
+ RETURNING *, locked_by AS event_lease_owner
895
887
  SQL
896
888
  end
897
889
 
@@ -1087,58 +1079,29 @@ module SearchEngine
1087
1079
  refresh_event_statuses!(parent_ids)
1088
1080
  end
1089
1081
 
1090
- def update_status!(events, status, extra:)
1091
- ids = event_ids(events)
1092
- return [] if ids.empty?
1082
+ def acknowledge!(events)
1083
+ events = Array(events).compact
1084
+ return [] if events.empty?
1093
1085
 
1094
- execute(<<~SQL)
1095
- UPDATE #{quoted_table}
1096
- SET status = #{quote(status)},
1097
- #{extra},
1098
- locked_at = NULL,
1099
- locked_by = NULL,
1100
- updated_at = CURRENT_TIMESTAMP
1101
- WHERE id IN (#{ids_sql(ids)})
1102
- SQL
1103
- ids
1104
- end
1086
+ assignments = "#{yield}, locked_at = NULL, locked_by = NULL"
1087
+ return mutate_claimed_deliveries!(events, assignments: assignments) if delivery_mode?
1105
1088
 
1106
- def update_delivery_status!(events, status, extra:)
1107
- mutate_claimed_deliveries!(
1108
- events,
1109
- assignments: <<~SQL.chomp
1110
- status = #{quote(status)},
1111
- #{extra},
1112
- locked_at = NULL,
1113
- locked_by = NULL
1114
- SQL
1115
- )
1116
- end
1089
+ claims = events.map do |event|
1090
+ unless event.is_a?(Event) && event.id && event.delivery_id.nil? && !event.event_lease_owner.to_s.empty?
1091
+ raise ArgumentError, 'single-target mutations require Event objects returned by claim_pending'
1092
+ end
1117
1093
 
1118
- def mark_delivery_retryable!(events, error:)
1119
- mutate_claimed_deliveries!(
1120
- events,
1121
- assignments: <<~SQL.chomp
1122
- attempts = attempts + 1,
1123
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
1124
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
1125
- locked_at = NULL,
1126
- locked_by = NULL,
1127
- last_error = #{quote(truncate_error(error))}
1128
- SQL
1129
- )
1130
- end
1094
+ "(CAST(#{quote(event.id)} AS bigint), #{quote(event.event_lease_owner)})"
1095
+ end
1131
1096
 
1132
- def mark_delivery_failed!(events, error:)
1133
- mutate_claimed_deliveries!(
1134
- events,
1135
- assignments: <<~SQL.chomp
1136
- status = 'failed',
1137
- locked_at = NULL,
1138
- locked_by = NULL,
1139
- last_error = #{quote(truncate_error(error))}
1140
- SQL
1141
- )
1097
+ rows = select_rows(<<~SQL)
1098
+ UPDATE #{quoted_table}
1099
+ SET #{assignments}, updated_at = CURRENT_TIMESTAMP
1100
+ WHERE status = 'processing'
1101
+ AND (id, locked_by) IN (VALUES #{claims.join(', ')})
1102
+ RETURNING id
1103
+ SQL
1104
+ rows.map { |row| row_value(row, :id) }
1142
1105
  end
1143
1106
 
1144
1107
  def mutate_claimed_deliveries!(events, assignments:, refresh_parent: true)
@@ -1289,13 +1252,6 @@ module SearchEngine
1289
1252
  'THEN COALESCE(aggregate.terminal_processed_at, CURRENT_TIMESTAMP) ELSE NULL END'
1290
1253
  end
1291
1254
 
1292
- def select_rows(sql)
1293
- result = connection.select_all(sql)
1294
- return result.to_a if result.respond_to?(:to_a)
1295
-
1296
- Array(result)
1297
- end
1298
-
1299
1255
  def execute(sql)
1300
1256
  connection.execute(sql)
1301
1257
  end
@@ -1304,12 +1260,6 @@ module SearchEngine
1304
1260
  ids.map { |id| quote(id) }.join(', ')
1305
1261
  end
1306
1262
 
1307
- def event_ids(events)
1308
- Array(events).compact.map do |event|
1309
- event.respond_to?(:id) ? event.id : event
1310
- end
1311
- end
1312
-
1313
1263
  def delivery_claims(events)
1314
1264
  Array(events).compact.map do |event|
1315
1265
  unless event.respond_to?(:delivery_id) && event.respond_to?(:delivery_lease_owner)
@@ -1596,7 +1546,7 @@ module SearchEngine
1596
1546
  ORDER BY id ASC
1597
1547
  LIMIT #{collection_limited_candidate_limit}
1598
1548
  )
1599
- SELECT outbox.*
1549
+ SELECT outbox.id, outbox.collection, outbox.document_id
1600
1550
  FROM #{quoted_table} outbox
1601
1551
  INNER JOIN selected_due
1602
1552
  ON selected_due.id = outbox.id
@@ -3,6 +3,7 @@
3
3
  require 'search_engine/postgres_outbox/event'
4
4
  require 'search_engine/postgres_outbox/delivery_target'
5
5
  require 'search_engine/postgres_outbox/processor_result'
6
+ require 'search_engine/postgres_outbox/database_queries'
6
7
  require 'search_engine/postgres_outbox/materialization_cursor'
7
8
  require 'search_engine/postgres_outbox/repository'
8
9
  require 'search_engine/postgres_outbox/event_processor'
@@ -14,5 +15,6 @@ require 'search_engine/postgres_outbox/migration_helpers'
14
15
  module SearchEngine
15
16
  # PostgreSQL-backed outbox integration points.
16
17
  module PostgresOutbox
18
+ autoload :DrainJob, 'search_engine/postgres_outbox/drain_job'
17
19
  end
18
20
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/inflector'
4
+
3
5
  # Internal SearchEngine registry and APIs for model mapping.
4
6
  module SearchEngine
5
7
  # Internal registry for mapping Typesense collection names to model classes.
@@ -73,40 +75,22 @@ module SearchEngine
73
75
  # Resolve the model class for a given Typesense collection name.
74
76
  #
75
77
  # @param name [#to_s]
76
- # @return [Class]
77
- # @raise [ArgumentError] when the collection is not registered
78
+ # @yield when no model is registered after autoload
79
+ # @return [Class, Object] registered model or the value of the supplied block
80
+ # @raise [ArgumentError] when the collection is not registered and no block is supplied
78
81
  def collection_for(name)
79
82
  normalized_name = name.to_s
80
83
  klass = Registry.mapping[normalized_name]
81
- return klass if klass
82
-
83
- # Lazy autoload fallback: try to resolve a namespaced model constant
84
- # based on the collection name and trigger its registration.
85
- begin
86
- begin
87
- require 'active_support/inflector'
88
- rescue StandardError
89
- nil
90
- end
91
-
92
- demod = if defined?(ActiveSupport::Inflector)
93
- ActiveSupport::Inflector.classify(normalized_name)
94
- else
95
- # Minimal classify fallback: singularize by dropping trailing 's' and camelize tokens
96
- base = normalized_name.end_with?('s') ? normalized_name[0..-2] : normalized_name
97
- base.split('_').map { |p| p[0] ? p[0].upcase + p[1..] : '' }.join
98
- end
99
-
100
- const_name = "SearchEngine::#{demod}"
101
- # Trigger autoload; ignore when constant is missing
102
- Object.const_get(const_name)
103
- rescue NameError
104
- # constant not found; proceed to error
84
+ if klass
85
+ # A class can call `collection` before the rest of its file has loaded.
86
+ # Let Ruby finish (or retry) its autoload before using its identity strategy.
87
+ load_model_constant(Object, klass.name) if klass.name
88
+ else
89
+ load_model_constant(self, ActiveSupport::Inflector.classify(normalized_name))
105
90
  end
106
-
107
- # Re-check after potential autoload/registration
108
91
  klass = Registry.mapping[normalized_name]
109
92
  return klass if klass
93
+ return yield if block_given?
110
94
 
111
95
  message = 'Unregistered collection: ' \
112
96
  "'#{normalized_name}'. " \
@@ -117,6 +101,21 @@ module SearchEngine
117
101
 
118
102
  private
119
103
 
104
+ def load_model_constant(namespace, name)
105
+ name.split('::').each do |part|
106
+ break unless namespace.is_a?(Module) && collection_constant_defined?(namespace, part)
107
+
108
+ namespace = namespace.const_get(part, false)
109
+ end
110
+ end
111
+
112
+ def collection_constant_defined?(namespace, part)
113
+ namespace.const_defined?(part, false)
114
+ rescue NameError
115
+ # Physical collection names can contain characters that cannot appear in Ruby constants.
116
+ false
117
+ end
118
+
120
119
  def mapping_idempotent?(current, new_klass)
121
120
  return false unless current
122
121
 
@@ -91,13 +91,13 @@ module SearchEngine
91
91
  compiled = SearchEngine::Compiler.compile(ast_nodes, klass: @klass)
92
92
  return nil if compiled.to_s.empty?
93
93
 
94
- return compiled
94
+ return map_join_tokens(compiled)
95
95
  end
96
96
 
97
97
  fragments = Array(@state[:filters])
98
98
  return nil if fragments.empty?
99
99
 
100
- fragments.join(' && ')
100
+ map_join_tokens(fragments.join(' && '))
101
101
  end
102
102
 
103
103
  # Compile sort_by from normalized order entries.
@@ -276,16 +276,10 @@ module SearchEngine
276
276
  end
277
277
 
278
278
  def add_pagination_line!(lines, params)
279
- page = params[:page]
280
- per = params[:per_page]
281
- return unless page || per
282
-
283
- if page && per
284
- lines << " page/per: #{page}/#{per}"
285
- elsif page
286
- lines << " page/per: #{page}/"
287
- elsif per
288
- lines << " page/per: /#{per}"
279
+ if params.key?(:offset)
280
+ lines << " offset/limit: #{params[:offset]}/#{params[:limit]}"
281
+ elsif params.key?(:page) || params.key?(:per_page)
282
+ lines << " page/per: #{params[:page]}/#{params[:per_page]}"
289
283
  end
290
284
  end
291
285
 
@@ -384,7 +378,6 @@ module SearchEngine
384
378
 
385
379
  def assign_filter_by!(params, ast_nodes)
386
380
  filter_str = compiled_filter_by(ast_nodes)
387
- filter_str = map_join_tokens(filter_str)
388
381
  params[:filter_by] = filter_str if filter_str
389
382
  filter_str
390
383
  end
@@ -417,17 +410,12 @@ module SearchEngine
417
410
  rewrites = join_token_rewrites
418
411
  return value if rewrites.empty?
419
412
 
420
- mapped = str.dup
421
- rewrites.each do |assoc, collection|
422
- coll = collection.to_s
423
- assoc_token = assoc.to_s
424
- next if coll.strip.empty? || assoc_token.empty? || coll == assoc_token
425
-
426
- pattern = /\$#{Regexp.escape(assoc_token)}(?=[.(])/
427
- mapped = mapped.gsub(pattern, "$#{coll}")
413
+ names = Regexp.union(rewrites.keys.map(&:to_s))
414
+ # Backtick-delimited values can contain literal reference syntax.
415
+ str.gsub(/`[^`]*`|\$(#{names})(?=[.(])/) do |match|
416
+ name = Regexp.last_match(1)
417
+ name ? "$#{rewrites.fetch(name.to_sym)}" : match
428
418
  end
429
-
430
- mapped
431
419
  end
432
420
 
433
421
  def join_token_rewrites
@@ -687,17 +675,14 @@ module SearchEngine
687
675
  def apply_pagination_and_hit_limits!(params)
688
676
  hits_info = {}
689
677
  pagination = compute_pagination
690
- if (hl = @state[:hit_limits]) && hl[:early_limit]
691
- if pagination.key?(:per_page) && pagination[:per_page].to_i > hl[:early_limit].to_i
692
- pagination = pagination.merge(per_page: hl[:early_limit].to_i)
693
- hits_info[:per_adjusted] = true
694
- else
695
- hits_info[:per_adjusted] = false
696
- end
697
- hits_info[:early_limit] = hl[:early_limit].to_i
678
+ if (early_limit = @state.dig(:hit_limits, :early_limit))
679
+ size_key = pagination.key?(:limit) ? :limit : :per_page
680
+ size = pagination[size_key]
681
+ hits_info[:per_adjusted] = size.to_i > early_limit
682
+ pagination[size_key] = early_limit if hits_info[:per_adjusted]
683
+ hits_info[:early_limit] = early_limit
698
684
  end
699
- params[:page] = pagination[:page] if pagination.key?(:page)
700
- params[:per_page] = pagination[:per_page] if pagination.key?(:per_page)
685
+ params.merge!(pagination)
701
686
  hits_info
702
687
  end
703
688