search-engine-for-typesense 1.0.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.
Files changed (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module SearchEngine
6
+ class Relation
7
+ module Dx
8
+ # Pure helpers for producing redacted previews without I/O.
9
+ module DryRun
10
+ # Redact a compiled params hash, preserving only whitelisted keys and
11
+ # masking sensitive fields. Returns a new Hash.
12
+ # @param params [Hash]
13
+ # @return [Hash]
14
+ def self.redact_params(params)
15
+ redacted = SearchEngine::Observability.redact(params)
16
+ return redacted unless params.is_a?(Hash)
17
+
18
+ hits = params[:_hits] || params['_hits']
19
+ return redacted unless hits
20
+
21
+ out = redacted.is_a?(Hash) ? redacted.dup : {}
22
+ out[:_hits] = hits
23
+ out
24
+ end
25
+
26
+ # Return pretty or compact JSON with stable key ordering when pretty.
27
+ # @param value [Object]
28
+ # @param pretty [Boolean]
29
+ # @return [String]
30
+ def self.to_json(value, pretty: true)
31
+ if pretty && value.is_a?(Hash)
32
+ ordered = value.sort_by { |(k, _v)| k.to_s }.to_h
33
+ JSON.pretty_generate(ordered)
34
+ else
35
+ JSON.generate(value)
36
+ end
37
+ end
38
+
39
+ # Build a single-line curl string with redacted body.
40
+ # @param url [String]
41
+ # @param params [Hash]
42
+ # @return [String]
43
+ def self.curl(url, params)
44
+ body_json = JSON.generate(redact_params(params))
45
+ %(curl -X POST #{url} -H 'Content-Type: application/json' -H 'X-TYPESENSE-API-KEY: ***' -d '#{body_json}')
46
+ end
47
+
48
+ # Return a structured dry-run payload with redacted JSON body and url options.
49
+ # @param url [String]
50
+ # @param params [Hash]
51
+ # @param url_opts [Hash]
52
+ # @return [Hash]
53
+ def self.payload(url:, params:, url_opts: {})
54
+ { url: url, body: JSON.generate(redact_params(params)), url_opts: url_opts.freeze }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ module Dx
6
+ # Pure helper for human-friendly rendering of Typesense filter strings.
7
+ # Accepts a String and returns a transformed String without mutating input.
8
+ module FriendlyWhere
9
+ # Render a human-friendly `filter_by` string.
10
+ # @param filter_by [String]
11
+ # @return [String]
12
+ def self.render(filter_by)
13
+ s = filter_by.to_s
14
+ return s if s.empty?
15
+
16
+ s.gsub(' && ', ' AND ')
17
+ .gsub(' || ', ' OR ')
18
+ .gsub(':=[', ' IN [')
19
+ .gsub(':!=[', ' NOT IN [')
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'search_engine/relation/dx/friendly_where'
5
+ require 'search_engine/relation/dx/dry_run'
6
+
7
+ module SearchEngine
8
+ # Immutable, chainable query relation bound to a model class.
9
+ # See `lib/search_engine/relation.rb` for full API; DX helpers are mixed in here.
10
+ class Relation
11
+ # DX helpers are mixed into `Relation` to offer redaction‑aware, zero‑I/O explain and preview utilities.
12
+ # These helpers are pure and never mutate relation state.
13
+ module Dx
14
+ # Return the request body JSON after compile, fully redacted.
15
+ # @param pretty [Boolean] pretty-print with stable key ordering when true
16
+ # @return [String]
17
+ # @since M8
18
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx
19
+ def to_params_json(pretty: true)
20
+ params = SearchEngine::CompiledParams.from(to_typesense_params)
21
+ redacted = SearchEngine::Relation::Dx::DryRun.redact_params(params.to_h)
22
+ SearchEngine::Relation::Dx::DryRun.to_json(redacted, pretty: pretty)
23
+ end
24
+
25
+ # Return a single-line curl command with redacted API key and JSON body.
26
+ # @return [String]
27
+ # @since M8
28
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx
29
+ def to_curl
30
+ url = compiled_url
31
+ params = SearchEngine::CompiledParams.from(to_typesense_params).to_h
32
+ SearchEngine::Relation::Dx::DryRun.curl(url, params)
33
+ end
34
+
35
+ # Compile and validate without performing network I/O.
36
+ # Returns a structured hash with URL and post-redaction body.
37
+ # @return [Hash] { url:, body:, url_opts: }
38
+ # @raise [SearchEngine::Errors::*] same validation errors as runtime path
39
+ # @since M8
40
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx
41
+ def dry_run!
42
+ params = SearchEngine::CompiledParams.from(to_typesense_params).to_h
43
+ SearchEngine::Relation::Dx::DryRun.payload(url: compiled_url, params: params, url_opts: compiled_url_opts)
44
+ end
45
+
46
+ # Enhanced explain output with overview, parts, conflicts, and predicted events.
47
+ # Builds a redaction-aware summary without network I/O.
48
+ # @param to [Symbol, nil]
49
+ # @return [String]
50
+ # @since M8
51
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx#helpers--examples
52
+ def explain(to: nil)
53
+ params = SearchEngine::CompiledParams.from(to_typesense_params)
54
+ lines = []
55
+
56
+ lines << header_line
57
+ append_preset_explain_line(lines, params)
58
+ append_curation_explain_lines(lines)
59
+ append_where_line(lines, params)
60
+ append_order_line(lines, params)
61
+ append_group_line(lines)
62
+ append_facets_line(lines, params)
63
+ append_selection_explain_lines(lines, params)
64
+ add_effective_selection_tokens!(lines)
65
+ add_pagination_line!(lines, params)
66
+ append_hit_limits_line(lines, params)
67
+ append_ranking_line(lines, params)
68
+ lines << overview_line(params)
69
+ append_conflicts_line(lines, params)
70
+ append_events_line(lines, params)
71
+
72
+ out = lines.join("\n")
73
+ puts(out) if to == :stdout
74
+ out
75
+ end
76
+
77
+ private
78
+
79
+ def redact_body(params)
80
+ hash = params.dup
81
+ preview = SearchEngine::Observability.redact(params)
82
+ hash[:filter_by] = preview[:filter_by] if preview.is_a?(Hash) && preview.key?(:filter_by)
83
+ hash
84
+ end
85
+
86
+ def predicted_events_for_plan(params)
87
+ events = []
88
+ events << 'search_engine.compile' if Array(@state[:ast]).any?
89
+ events << 'search_engine.joins.compile' if Array(params[:_join] && params[:_join][:assocs]).any?
90
+ events << 'search_engine.grouping.compile' if params.key?(:group_by)
91
+ if preset_name
92
+ events << 'search_engine.preset.apply'
93
+ events << 'search_engine.preset.conflict' if Array(params[:_preset_conflicts]).any?
94
+ end
95
+ events << 'search_engine.search'
96
+ events
97
+ end
98
+
99
+ def compiled_url
100
+ collection = collection_name_for_klass
101
+ cfg = SearchEngine.config
102
+ proto = cfg.protocol.to_s.strip.presence
103
+ base = [proto, "#{cfg.host}:#{cfg.port}"].compact.join('://')
104
+ "#{base}/collections/#{collection}/documents/search"
105
+ end
106
+
107
+ def compiled_url_opts
108
+ url_opts = ClientOptions.url_options_from_config(SearchEngine.config)
109
+ overrides = build_url_opts
110
+ url_opts.merge!(overrides) unless overrides.empty?
111
+ url_opts
112
+ end
113
+
114
+ def header_line
115
+ "#{klass_name_for_inspect} Relation"
116
+ end
117
+
118
+ def append_where_line(lines, params)
119
+ fb = params[:filter_by]
120
+ return unless fb && !fb.to_s.strip.empty?
121
+
122
+ # Render friendly operators first, then redact the final string to preserve
123
+ # IN/NOT IN tokens while masking literals.
124
+ friendly = SearchEngine::Relation::Dx::FriendlyWhere.render(fb.to_s)
125
+ masked = SearchEngine::Observability.redact(friendly)
126
+ where_str = masked.is_a?(String) ? masked : friendly
127
+ lines << " where: #{where_str}" unless where_str.to_s.strip.empty?
128
+ end
129
+
130
+ def append_order_line(lines, params)
131
+ sb = params[:sort_by]
132
+ lines << " order: #{sb}" if sb && !sb.to_s.strip.empty?
133
+ end
134
+
135
+ def append_group_line(lines)
136
+ g = @state[:grouping]
137
+ return unless g
138
+
139
+ gparts = ["group_by=#{g[:field]}"]
140
+ gparts << "limit=#{g[:limit]}" if g[:limit]
141
+ gparts << 'missing_values=true' if g[:missing_values]
142
+ lines << " group: #{gparts.join(' ')}"
143
+ end
144
+
145
+ def overview_line(params)
146
+ parts = []
147
+ parts << "collection=#{collection_name_for_klass}"
148
+ if (g = grouping)
149
+ seg = [g[:field]].compact
150
+ seg << "limit=#{g[:limit]}" if g[:limit]
151
+ seg << 'missing_values' if g[:missing_values]
152
+ parts << "grouping=#{seg.join(':')}"
153
+ end
154
+ if params.key?(:page) || params.key?(:per_page)
155
+ p = params[:page]
156
+ per = params[:per_page]
157
+ parts << "page/per=#{p || ''}/#{per || ''}"
158
+ end
159
+ parts << (preset_name ? "preset=#{preset_name}(mode=#{preset_mode})" : 'preset=—')
160
+ parts << "joins=#{Array(joins_list).size}"
161
+ cid = SearchEngine::Instrumentation.current_correlation_id
162
+ parts << "cid=#{cid && !cid.to_s.empty? ? cid : '—'}"
163
+ "Overview: #{parts.join(' ')}"
164
+ end
165
+
166
+ def append_conflicts_line(lines, params)
167
+ conflicts = Array(params[:_preset_conflicts]).map(&:to_s).sort
168
+ lines << "Conflicts & Warnings: dropped keys in :lock mode => #{conflicts.join(', ')}" unless conflicts.empty?
169
+ end
170
+
171
+ def append_events_line(lines, params)
172
+ events = predicted_events_for_plan(params)
173
+ lines << "Events that would fire: #{events.join(' → ')}" unless events.empty?
174
+ end
175
+
176
+ def append_facets_line(lines, params)
177
+ fb = params[:facet_by]
178
+ fq = params[:facet_query]
179
+ mv = params[:max_facet_values]
180
+ segs = []
181
+ segs << "facet_by=#{fb}" if fb && !fb.to_s.strip.empty?
182
+ segs << "max=#{mv}" if mv
183
+ segs << "queries=#{fq}" if fq && !fq.to_s.strip.empty?
184
+ lines << " facets: #{segs.join(' ')}" unless segs.empty?
185
+ end
186
+
187
+ def append_ranking_line(lines, params)
188
+ rk = @state[:ranking]
189
+ has_ranking_param = rk ||
190
+ params.key?(:query_by_weights) || params.key?(:num_typos) ||
191
+ params.key?(:drop_tokens_threshold) || params.key?(:prioritize_exact_match) ||
192
+ params.key?(:infix)
193
+ return unless has_ranking_param
194
+
195
+ parts = []
196
+ begin
197
+ plan = SearchEngine::RankingPlan.new(relation: self, query_by: params[:query_by], ranking: rk || {})
198
+ fields = plan.effective_query_by_fields
199
+ parts << "query_by=[#{fields.join(', ')}]" unless fields.empty?
200
+ rescue StandardError
201
+ # ignore
202
+ end
203
+
204
+ parts << "weights=#{params[:query_by_weights]}" if params[:query_by_weights]
205
+ parts << "num_typos=#{params[:num_typos]}" if params.key?(:num_typos)
206
+ parts << "drop_tokens_threshold=#{params[:drop_tokens_threshold]}" if params.key?(:drop_tokens_threshold)
207
+ parts << "prioritize_exact_match=#{params[:prioritize_exact_match]}" if params.key?(:prioritize_exact_match)
208
+ parts << "prefix=#{params[:infix]}" if params[:infix]
209
+
210
+ lines << " ranking: #{parts.join(' ')}" unless parts.empty?
211
+ end
212
+
213
+ def append_hit_limits_line(lines, params)
214
+ hits = params[:_hits]
215
+ return unless hits.is_a?(Hash) && (hits[:early_limit] || hits[:max])
216
+
217
+ segs = []
218
+ segs << "early_limit=#{hits[:early_limit]}" if hits[:early_limit]
219
+ if hits[:per_adjusted] == true
220
+ segs << 'per_adjusted=true'
221
+ elsif hits.key?(:per_adjusted)
222
+ segs << 'per_adjusted=false'
223
+ end
224
+ segs << "validator_max=#{hits[:max]}" if hits[:max]
225
+ lines << " hits: #{segs.join(' ')}" unless segs.empty?
226
+ end
227
+ end
228
+
229
+ include Dx
230
+ end
231
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ # Materializers delegated to Hydration layer (single network call per Relation instance).
6
+ module Materializers
7
+ # Execute the relation and return the memoized Result.
8
+ # @return [SearchEngine::Result]
9
+ def execute
10
+ SearchEngine::Hydration::Materializers.execute(self)
11
+ end
12
+
13
+ # Return a shallow copy of hydrated hits.
14
+ # @return [Array<Object>]
15
+ def to_a
16
+ SearchEngine::Hydration::Materializers.to_a(self)
17
+ end
18
+
19
+ # Iterate over hydrated hits.
20
+ # @yieldparam obj [Object]
21
+ # @return [Enumerator] when no block is given
22
+ def each(&block)
23
+ SearchEngine::Hydration::Materializers.each(self, &block)
24
+ end
25
+
26
+ # Return the first element or the first N elements from the loaded page.
27
+ # @param n [Integer, nil]
28
+ # @return [Object, Array<Object>]
29
+ def first(n = nil)
30
+ SearchEngine::Hydration::Materializers.first(self, n)
31
+ end
32
+
33
+ # Return the last element or the last N elements from the currently fetched page.
34
+ # @param n [Integer, nil]
35
+ # @return [Object, Array<Object>]
36
+ def last(n = nil)
37
+ SearchEngine::Hydration::Materializers.last(self, n)
38
+ end
39
+
40
+ # Take N elements from the head. When N==1, returns a single object.
41
+ # @param n [Integer]
42
+ # @return [Object, Array<Object>]
43
+ def take(n = 1)
44
+ SearchEngine::Hydration::Materializers.take(self, n)
45
+ end
46
+
47
+ # Return raw Typesense response for this relation.
48
+ # Executes the query (memoized) and returns Result#raw.
49
+ # @return [Hash]
50
+ def raw
51
+ SearchEngine::Hydration::Materializers.execute(self).raw
52
+ end
53
+
54
+ # Find the first matching record using where-like inputs.
55
+ # Accepts the same arguments as `.where` (Hash, String, Array, Symbol),
56
+ # applies them if provided, then limits to a single result and returns it.
57
+ #
58
+ # @param args [Array<Object>] where-compatible arguments
59
+ # @return [Object, nil]
60
+ # @example
61
+ # SearchEngine::Product.find_by(article_code: 12312, store_id: 1031)
62
+ # SearchEngine::Product.where(active: true).find_by('price:>100')
63
+ def find_by(*args)
64
+ relation = args.nil? || args.empty? ? self : where(*args)
65
+ relation.per(1).page(1).first
66
+ end
67
+
68
+ # Convenience for plucking :id values.
69
+ # @return [Array<Object>]
70
+ def ids
71
+ SearchEngine::Hydration::Materializers.ids(self)
72
+ end
73
+
74
+ # Pick one or multiple fields from the first matching record.
75
+ # Returns a single value for one field, or an Array for multiple fields.
76
+ # Returns nil when no records match.
77
+ # @param fields [Array<#to_sym,#to_s>]
78
+ # @return [Object, Array<Object>, nil]
79
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/materializers#pick
80
+ def pick(*fields)
81
+ SearchEngine::Hydration::Materializers.pick(self, *fields)
82
+ end
83
+
84
+ # Fetch and hydrate all matching records across all pages.
85
+ # Performs a count first, then retrieves pages in batches via multi-search.
86
+ # Warning: This can be memory and time intensive.
87
+ # @return [Array<Object>]
88
+ def all!
89
+ SearchEngine::Hydration::Materializers.all!(self)
90
+ end
91
+
92
+ # Pluck one or multiple fields.
93
+ # @param fields [Array<#to_sym,#to_s>]
94
+ # @return [Array<Object>, Array<Array<Object>>]
95
+ def pluck(*fields)
96
+ SearchEngine::Hydration::Materializers.pluck(self, *fields)
97
+ end
98
+
99
+ # Whether any matching documents exist.
100
+ # @return [Boolean]
101
+ def exists?
102
+ SearchEngine::Hydration::Materializers.exists?(self)
103
+ end
104
+
105
+ # Return total number of matching documents.
106
+ # @return [Integer]
107
+ def count
108
+ SearchEngine::Hydration::Materializers.count(self)
109
+ end
110
+
111
+ # Return total number of pages for this relation based on total hits and per-page size.
112
+ # @return [Integer]
113
+ def pages_count
114
+ SearchEngine::Hydration::Materializers.pages_count(self)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Relation
5
+ # Options and pagination normalization helpers.
6
+ # Centralizes precedence rules and guards for page/per vs limit/offset.
7
+ module Options
8
+ # Compute page/per_page with precedence:
9
+ # - When page/per are provided, prefer them (fill missing per with nil, page defaults to 1 when only per is set)
10
+ # - Otherwise, when limit is set, derive page from offset and per_page from limit
11
+ # - Otherwise, return empty Hash
12
+ # @return [Hash{Symbol=>Integer}]
13
+ def compute_pagination
14
+ page = @state[:page]
15
+ per = @state[:per_page]
16
+
17
+ if page || per
18
+ out = {}
19
+ if per && page
20
+ out[:page] = page
21
+ out[:per_page] = per
22
+ elsif per && !page
23
+ out[:page] = 1
24
+ out[:per_page] = per
25
+ elsif page && !per
26
+ out[:page] = page
27
+ end
28
+ return out
29
+ elsif @state[:limit]
30
+ limit = @state[:limit]
31
+ off = @state[:offset] || 0
32
+ computed_page = (off.to_i / limit.to_i) + 1
33
+ return { page: computed_page, per_page: limit }
34
+ end
35
+
36
+ {}
37
+ end
38
+
39
+ # Normalize hit limits input into a compact Hash.
40
+ # Accepts keys :early_limit and :max and coerces to positive Integers when possible.
41
+ # @param value [Hash]
42
+ # @return [Hash]
43
+ def normalize_hit_limits_input(value)
44
+ unless value.is_a?(Hash)
45
+ raise SearchEngine::Errors::InvalidOption.new(
46
+ 'InvalidOption: hit_limits expects a Hash of options',
47
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/hit-limits'
48
+ )
49
+ end
50
+
51
+ out = {}
52
+ if value.key?(:early_limit) || value.key?('early_limit')
53
+ raw = value[:early_limit] || value['early_limit']
54
+ begin
55
+ iv = Integer(raw)
56
+ out[:early_limit] = iv if iv.positive?
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ if value.key?(:max) || value.key?('max')
62
+ raw = value[:max] || value['max']
63
+ begin
64
+ iv = Integer(raw)
65
+ out[:max] = iv if iv.positive?
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ out
71
+ end
72
+
73
+ # Coerce integers with a minimum bound; nil passes through.
74
+ # @param value [Object]
75
+ # @param name [Symbol]
76
+ # @param min [Integer]
77
+ # @return [Integer, nil]
78
+ # @raise [ArgumentError] when not coercible or below min
79
+ def coerce_integer_min(value, name, min)
80
+ return nil if value.nil?
81
+
82
+ integer =
83
+ case value
84
+ when Integer then value
85
+ else Integer(value)
86
+ end
87
+
88
+ raise ArgumentError, "#{name} must be >= #{min}" if integer < min
89
+
90
+ integer
91
+ rescue ArgumentError, TypeError
92
+ raise ArgumentError, "#{name} must be an Integer or nil"
93
+ end
94
+
95
+ # Strict boolean coercion with helpful errors.
96
+ # @param value [Object]
97
+ # @param name [Symbol]
98
+ # @return [true,false]
99
+ # @raise [ArgumentError]
100
+ def coerce_boolean_strict(value, name)
101
+ case value
102
+ when true, false
103
+ value
104
+ when String
105
+ s = value.to_s.strip.downcase
106
+ return true if %w[1 true yes on t].include?(s)
107
+ return false if %w[0 false no off f].include?(s)
108
+
109
+ raise ArgumentError, "#{name} must be a boolean"
110
+ when Integer
111
+ return true if value == 1
112
+ return false if value.zero?
113
+
114
+ raise ArgumentError, "#{name} must be a boolean"
115
+ else
116
+ raise ArgumentError, "#{name} must be a boolean"
117
+ end
118
+ end
119
+
120
+ # Access indifferent key from Hash
121
+ def option_value(hash, key)
122
+ if hash.key?(key)
123
+ hash[key]
124
+ else
125
+ hash[key.to_s]
126
+ end
127
+ end
128
+
129
+ # Stable truncation for inspect helpers
130
+ def truncate_for_inspect(str, max = 80)
131
+ return str unless str.is_a?(String)
132
+ return str if str.length <= max
133
+
134
+ "#{str[0, max]}..."
135
+ end
136
+ end
137
+ end
138
+ end