parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,604 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "date"
6
+
7
+ module Parse
8
+ module AtlasSearch
9
+ # Builder for constructing $search aggregation pipeline stages.
10
+ # Supports fluent interface for complex queries.
11
+ #
12
+ # @example Simple text search
13
+ # builder = SearchBuilder.new(index_name: "default")
14
+ # builder.text(query: "love", path: :title)
15
+ # stage = builder.build
16
+ # # => { "$search" => { "index" => "default", "text" => { "query" => "love", "path" => "title" } } }
17
+ #
18
+ # @example Complex compound query
19
+ # builder = SearchBuilder.new
20
+ # builder.text(query: "love", path: [:title, :lyrics])
21
+ # builder.phrase(query: "broken heart", path: :lyrics, slop: 2)
22
+ # builder.with_highlight(path: :lyrics)
23
+ # stage = builder.build
24
+ class SearchBuilder
25
+ # Maximum length of a regex or wildcard query string. Atlas Search uses
26
+ # Lucene's bounded regex evaluator; long patterns and full-string
27
+ # wildcards force a state-machine explosion or whole-index scan and can
28
+ # be used to DoS the search node.
29
+ MAX_PATTERN_LENGTH = 256
30
+
31
+ attr_reader :index_name, :operators, :highlight_config, :count_config
32
+
33
+ def initialize(index_name: nil)
34
+ @index_name = index_name || Parse::AtlasSearch.default_index || "default"
35
+ @operators = []
36
+ @highlight_config = nil
37
+ @count_config = nil
38
+ @fuzzy_config = nil
39
+ end
40
+
41
+ # Add a text search operator
42
+ # @param query [String] the search query
43
+ # @param path [String, Symbol, Array, Hash] field(s) to search
44
+ # @param fuzzy [Boolean, Hash] fuzzy matching options
45
+ # @param score [Hash] custom score modifiers
46
+ # @param synonyms [String] synonym mapping name
47
+ # @return [self] for chaining
48
+ def text(query:, path:, fuzzy: nil, score: nil, synonyms: nil)
49
+ validate_query_length!(query, "text")
50
+ operator = {
51
+ "text" => {
52
+ "query" => query,
53
+ "path" => normalize_path(path),
54
+ },
55
+ }
56
+
57
+ if fuzzy
58
+ operator["text"]["fuzzy"] = fuzzy.is_a?(Hash) ? fuzzy : { "maxEdits" => 2 }
59
+ end
60
+
61
+ operator["text"]["score"] = score if score
62
+ operator["text"]["synonyms"] = synonyms if synonyms
63
+
64
+ @operators << operator
65
+ self
66
+ end
67
+
68
+ # Add a phrase search operator
69
+ # @param query [String] the phrase to search for
70
+ # @param path [String, Symbol, Array] field(s) to search
71
+ # @param slop [Integer] number of words between phrase terms (default: 0)
72
+ # @return [self] for chaining
73
+ def phrase(query:, path:, slop: nil)
74
+ validate_query_length!(query, "phrase")
75
+ operator = {
76
+ "phrase" => {
77
+ "query" => query,
78
+ "path" => normalize_path(path),
79
+ },
80
+ }
81
+
82
+ operator["phrase"]["slop"] = slop if slop
83
+
84
+ @operators << operator
85
+ self
86
+ end
87
+
88
+ # Add an autocomplete operator (requires autocomplete index type)
89
+ # @param query [String] the partial text to autocomplete
90
+ # @param path [String, Symbol] the field with autocomplete index
91
+ # @param fuzzy [Boolean, Hash] fuzzy matching options
92
+ # @param token_order [String] "any" or "sequential"
93
+ # @return [self] for chaining
94
+ def autocomplete(query:, path:, fuzzy: nil, token_order: nil)
95
+ validate_query_length!(query, "autocomplete")
96
+ operator = {
97
+ "autocomplete" => {
98
+ "query" => query,
99
+ "path" => path.to_s,
100
+ },
101
+ }
102
+
103
+ if fuzzy
104
+ operator["autocomplete"]["fuzzy"] = fuzzy.is_a?(Hash) ? fuzzy : {
105
+ "maxEdits" => 1,
106
+ "prefixLength" => 1,
107
+ }
108
+ end
109
+
110
+ operator["autocomplete"]["tokenOrder"] = token_order if token_order
111
+
112
+ @operators << operator
113
+ self
114
+ end
115
+
116
+ # Add a wildcard search operator
117
+ # @param query [String] the wildcard pattern (* and ? supported)
118
+ # @param path [String, Symbol, Array] field(s) to search
119
+ # @param allow_analyzed_field [Boolean] allow searching analyzed fields
120
+ # @return [self] for chaining
121
+ # @raise [ArgumentError] if `query` is empty, too long, or begins with
122
+ # a leading wildcard (`*` or `?`) which forces a full-index scan.
123
+ def wildcard(query:, path:, allow_analyzed_field: nil)
124
+ validate_pattern!(query, kind: "wildcard")
125
+ operator = {
126
+ "wildcard" => {
127
+ "query" => query,
128
+ "path" => normalize_path(path),
129
+ },
130
+ }
131
+
132
+ operator["wildcard"]["allowAnalyzedField"] = allow_analyzed_field unless allow_analyzed_field.nil?
133
+
134
+ @operators << operator
135
+ self
136
+ end
137
+
138
+ # Add a regex search operator
139
+ # @param query [String] the regex pattern
140
+ # @param path [String, Symbol, Array] field(s) to search
141
+ # @param allow_analyzed_field [Boolean] allow searching analyzed fields
142
+ # @return [self] for chaining
143
+ # @raise [ArgumentError] if `query` is empty, too long, or starts with
144
+ # an unbounded match (`.*`, `.+`, `*`, `?`) that would scan the full
145
+ # index.
146
+ def regex(query:, path:, allow_analyzed_field: nil)
147
+ validate_pattern!(query, kind: "regex")
148
+ operator = {
149
+ "regex" => {
150
+ "query" => query,
151
+ "path" => normalize_path(path),
152
+ },
153
+ }
154
+
155
+ operator["regex"]["allowAnalyzedField"] = allow_analyzed_field unless allow_analyzed_field.nil?
156
+
157
+ @operators << operator
158
+ self
159
+ end
160
+
161
+ # Add a range search operator for numeric/date fields
162
+ # @param path [String, Symbol] the field to search
163
+ # @param gt [Numeric, Time, Date] greater than value
164
+ # @param gte [Numeric, Time, Date] greater than or equal value
165
+ # @param lt [Numeric, Time, Date] less than value
166
+ # @param lte [Numeric, Time, Date] less than or equal value
167
+ # @return [self] for chaining
168
+ def range(path:, gt: nil, gte: nil, lt: nil, lte: nil)
169
+ operator = {
170
+ "range" => {
171
+ "path" => path.to_s,
172
+ },
173
+ }
174
+
175
+ operator["range"]["gt"] = format_range_value(gt) if gt
176
+ operator["range"]["gte"] = format_range_value(gte) if gte
177
+ operator["range"]["lt"] = format_range_value(lt) if lt
178
+ operator["range"]["lte"] = format_range_value(lte) if lte
179
+
180
+ @operators << operator
181
+ self
182
+ end
183
+
184
+ # Add an exists operator to match documents where field exists
185
+ # @param path [String, Symbol] the field to check
186
+ # @return [self] for chaining
187
+ def exists(path:)
188
+ @operators << { "exists" => { "path" => path.to_s } }
189
+ self
190
+ end
191
+
192
+ # Atlas Search `geoShape` operator. Filters documents where the
193
+ # indexed geometry has the specified relation to a query geometry.
194
+ # Requires the indexed field to be mapped with `{type: "geo",
195
+ # indexShapes: true}`.
196
+ #
197
+ # Note: Atlas Search uses Cartesian (planar) distance, NOT the
198
+ # 2dsphere geodesic distance used by core MongoDB geo operators.
199
+ # For shapes spanning large areas the two engines can return
200
+ # different result sets.
201
+ #
202
+ # @param path [String, Symbol] the indexed `geo` field.
203
+ # @param relation [Symbol, String] one of :contains, :disjoint,
204
+ # :intersects, :within. `:within` is not valid with LineString /
205
+ # Point query geometries.
206
+ # @param geometry [Hash, Parse::Polygon, Parse::GeoPoint,
207
+ # Parse::GeoJSON::Geometry] the query geometry. Parse-native
208
+ # types are auto-converted to their GeoJSON form.
209
+ # @param score [Hash, nil] optional `score` modifier.
210
+ # @return [self] for chaining
211
+ def geo_shape(path:, relation:, geometry:, score: nil)
212
+ op = {
213
+ "path" => path.to_s,
214
+ "relation" => relation.to_s,
215
+ "geometry" => coerce_geojson_geometry(geometry),
216
+ }
217
+ op["score"] = score if score
218
+ @operators << { "geoShape" => op }
219
+ self
220
+ end
221
+
222
+ # Atlas Search `geoWithin` operator. Returns documents whose
223
+ # indexed point is inside the supplied region. Exactly one of
224
+ # `box:`, `circle:`, `geometry:` must be provided.
225
+ #
226
+ # - `box`: `[bottom_left, top_right]` — each entry may be a
227
+ # {Parse::GeoPoint} or a GeoJSON Point Hash.
228
+ # - `circle`: `{center: <GeoPoint|Hash>, radius: <meters>}`.
229
+ # Radius is measured in meters and must be non-negative.
230
+ # - `geometry`: a GeoJSON Polygon or MultiPolygon (Hash, a
231
+ # {Parse::Polygon}, or {Parse::GeoJSON::MultiPolygon}).
232
+ #
233
+ # @param path [String, Symbol] the indexed `geo` field.
234
+ # @param box [Array, nil] `[bottom_left, top_right]` point pair.
235
+ # @param circle [Hash, nil] `{center:, radius:}`.
236
+ # @param geometry [Hash, Parse::Polygon, Parse::GeoJSON::Geometry, nil]
237
+ # @param score [Hash, nil] optional `score` modifier.
238
+ # @return [self] for chaining
239
+ def geo_within(path:, box: nil, circle: nil, geometry: nil, score: nil)
240
+ provided = [box, circle, geometry].count { |v| !v.nil? }
241
+ if provided != 1
242
+ raise ArgumentError, "[Parse::AtlasSearch] geo_within requires exactly one of " \
243
+ "box:, circle:, or geometry: (got #{provided})."
244
+ end
245
+
246
+ op = { "path" => path.to_s }
247
+ op["score"] = score if score
248
+
249
+ if box
250
+ unless box.is_a?(Array) && box.length == 2
251
+ raise ArgumentError, "[Parse::AtlasSearch] geo_within `box:` must be [bottom_left, top_right]."
252
+ end
253
+ op["box"] = {
254
+ "bottomLeft" => coerce_geojson_point(box[0]),
255
+ "topRight" => coerce_geojson_point(box[1]),
256
+ }
257
+ elsif circle
258
+ unless circle.is_a?(Hash)
259
+ raise ArgumentError, "[Parse::AtlasSearch] geo_within `circle:` must be a Hash."
260
+ end
261
+ center = circle[:center] || circle["center"]
262
+ radius = circle[:radius] || circle["radius"]
263
+ unless radius.is_a?(Numeric) && radius >= 0
264
+ raise ArgumentError, "[Parse::AtlasSearch] geo_within `circle: { radius: }` must be a non-negative number (meters)."
265
+ end
266
+ op["circle"] = { "center" => coerce_geojson_point(center), "radius" => radius.to_f }
267
+ else
268
+ op["geometry"] = coerce_geojson_geometry(geometry)
269
+ end
270
+
271
+ @operators << { "geoWithin" => op }
272
+ self
273
+ end
274
+
275
+ # Atlas Search `near` operator on a geo path. SCORING operator —
276
+ # blends "distance from origin" into the document score; it does
277
+ # not strictly filter by distance. Combine with a `compound.must`
278
+ # text/exists clause to bound the result set.
279
+ #
280
+ # `pivot` is the distance (in meters) at which the score is halved:
281
+ # `score = pivot / (pivot + distance)`. Smaller pivot = steeper
282
+ # falloff, more weight on the closest hits.
283
+ #
284
+ # @param path [String, Symbol] the indexed `geo` field.
285
+ # @param origin [Parse::GeoPoint, Hash, Array] anchor point.
286
+ # @param pivot [Numeric] half-score distance in meters.
287
+ # @param score [Hash, nil] optional `score` modifier (advanced).
288
+ # @return [self] for chaining
289
+ def near(path:, origin:, pivot:, score: nil)
290
+ unless pivot.is_a?(Numeric) && pivot > 0
291
+ raise ArgumentError, "[Parse::AtlasSearch] near `pivot:` must be a positive number (meters)."
292
+ end
293
+ op = {
294
+ "path" => path.to_s,
295
+ "origin" => coerce_geojson_point(origin),
296
+ "pivot" => pivot.to_f,
297
+ }
298
+ op["score"] = score if score
299
+ @operators << { "near" => op }
300
+ self
301
+ end
302
+
303
+ # Add global fuzzy configuration for subsequent text operators
304
+ # @param max_edits [Integer] maximum edit distance (1 or 2)
305
+ # @param prefix_length [Integer] number of characters that must match exactly
306
+ # @param max_expansions [Integer] maximum number of variations to generate
307
+ # @return [self] for chaining
308
+ def with_fuzzy(max_edits: 2, prefix_length: 0, max_expansions: 50)
309
+ @fuzzy_config = {
310
+ "maxEdits" => max_edits,
311
+ "prefixLength" => prefix_length,
312
+ "maxExpansions" => max_expansions,
313
+ }
314
+ self
315
+ end
316
+
317
+ # Enable highlighting for search results
318
+ # @param path [String, Symbol, Array] field(s) to highlight
319
+ # @param max_chars_to_examine [Integer] max characters to analyze for highlights
320
+ # @param max_num_passages [Integer] max number of highlight passages
321
+ # @return [self] for chaining
322
+ def with_highlight(path: nil, max_chars_to_examine: nil, max_num_passages: nil)
323
+ @highlight_config = {}
324
+ @highlight_config["path"] = normalize_path(path) if path
325
+ @highlight_config["maxCharsToExamine"] = max_chars_to_examine if max_chars_to_examine
326
+ @highlight_config["maxNumPassages"] = max_num_passages if max_num_passages
327
+ self
328
+ end
329
+
330
+ # Enable count metadata in results
331
+ # @param type [String] count type - "total" or "lowerBound"
332
+ # @return [self] for chaining
333
+ def with_count(type: "total")
334
+ @count_config = { "type" => type }
335
+ self
336
+ end
337
+
338
+ # Build the $search aggregation stage
339
+ # @return [Hash] the $search stage
340
+ # @raise [InvalidSearchParameters] if no operators have been added
341
+ def build
342
+ if @operators.empty?
343
+ raise InvalidSearchParameters, "At least one search operator must be specified"
344
+ end
345
+
346
+ search_stage = { "$search" => { "index" => @index_name } }
347
+
348
+ # Single operator or compound
349
+ if @operators.length == 1
350
+ search_stage["$search"].merge!(@operators.first)
351
+ else
352
+ # Multiple operators become a compound query with "must" clauses
353
+ search_stage["$search"]["compound"] = { "must" => @operators }
354
+ end
355
+
356
+ # Add highlight config
357
+ search_stage["$search"]["highlight"] = @highlight_config if @highlight_config
358
+
359
+ # Add count config
360
+ search_stage["$search"]["count"] = @count_config if @count_config
361
+
362
+ search_stage
363
+ end
364
+
365
+ # Build a compound query explicitly
366
+ # @param must [Array, Hash] operators that must match
367
+ # @param must_not [Array, Hash] operators that must not match
368
+ # @param should [Array, Hash] operators where at least one should match
369
+ # @param filter [Array, Hash] operators for filtering (no scoring impact)
370
+ # @param minimum_should_match [Integer] minimum number of should clauses to match
371
+ # @return [Hash] the $search stage with compound query
372
+ def build_compound(must: nil, must_not: nil, should: nil, filter: nil, minimum_should_match: nil)
373
+ compound = {}
374
+
375
+ compound["must"] = Array.wrap(must).map { |op| extract_operator(op) } if must
376
+ compound["mustNot"] = Array.wrap(must_not).map { |op| extract_operator(op) } if must_not
377
+ compound["should"] = Array.wrap(should).map { |op| extract_operator(op) } if should
378
+ compound["filter"] = Array.wrap(filter).map { |op| extract_operator(op) } if filter
379
+ compound["minimumShouldMatch"] = minimum_should_match if minimum_should_match
380
+
381
+ search_stage = {
382
+ "$search" => {
383
+ "index" => @index_name,
384
+ "compound" => compound,
385
+ },
386
+ }
387
+
388
+ search_stage["$search"]["highlight"] = @highlight_config if @highlight_config
389
+ search_stage["$search"]["count"] = @count_config if @count_config
390
+
391
+ search_stage
392
+ end
393
+
394
+ private
395
+
396
+ # Coerce a user-supplied point value to a GeoJSON Point Hash.
397
+ # Accepts Parse::GeoPoint, an already-shaped GeoJSON Point Hash,
398
+ # or a `[longitude, latitude]` Array. The Hash is returned with
399
+ # string keys so it serializes cleanly through `$search`.
400
+ def coerce_geojson_point(value)
401
+ case value
402
+ when Parse::GeoPoint
403
+ { "type" => "Point", "coordinates" => [value.longitude, value.latitude] }
404
+ when Hash
405
+ h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
406
+ type = h[:type] || h["type"]
407
+ coords = h[:coordinates] || h["coordinates"]
408
+ unless type.to_s == "Point" && coords.is_a?(Array) && coords.length == 2 &&
409
+ coords.all? { |n| n.is_a?(Numeric) }
410
+ raise ArgumentError, "[Parse::AtlasSearch] expected a GeoJSON Point hash."
411
+ end
412
+ { "type" => "Point", "coordinates" => [coords[0].to_f, coords[1].to_f] }
413
+ when Array
414
+ unless value.length == 2 && value.all? { |n| n.is_a?(Numeric) }
415
+ raise ArgumentError, "[Parse::AtlasSearch] point Array must be [longitude, latitude]."
416
+ end
417
+ { "type" => "Point", "coordinates" => [value[0].to_f, value[1].to_f] }
418
+ else
419
+ raise ArgumentError, "[Parse::AtlasSearch] cannot coerce #{value.class} to a GeoJSON Point."
420
+ end
421
+ end
422
+
423
+ # Coerce a user-supplied geometry value to a GeoJSON geometry Hash.
424
+ # Accepts any Parse::GeoJSON::Geometry subclass, Parse::Polygon,
425
+ # Parse::GeoPoint, or a raw GeoJSON Hash (validated minimally).
426
+ def coerce_geojson_geometry(value)
427
+ case value
428
+ when Parse::GeoJSON::Geometry then value.to_geojson
429
+ when Parse::Polygon then value.to_geojson
430
+ when Parse::GeoPoint then value.to_geojson
431
+ when Hash
432
+ h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
433
+ type = h[:type] || h["type"]
434
+ unless type.is_a?(String) && (h[:coordinates] || h["coordinates"])
435
+ raise ArgumentError, "[Parse::AtlasSearch] GeoJSON geometry hash needs both `type` and `coordinates`."
436
+ end
437
+ { "type" => type, "coordinates" => (h[:coordinates] || h["coordinates"]) }
438
+ else
439
+ raise ArgumentError, "[Parse::AtlasSearch] cannot coerce #{value.class} to a GeoJSON geometry."
440
+ end
441
+ end
442
+
443
+ # Reject empty, non-String, or oversized query strings. Applies
444
+ # to every operator that takes a `query:` value (`text`,
445
+ # `autocomplete`, `wildcard`, `regex`, `phrase`). Long patterns
446
+ # are a denial-of-service vector against Atlas Search regardless
447
+ # of operator type.
448
+ def validate_query_length!(query, op_name)
449
+ unless query.is_a?(String) && !query.empty?
450
+ raise ArgumentError, "#{op_name} query must be a non-empty String"
451
+ end
452
+ if query.length > MAX_PATTERN_LENGTH
453
+ raise ArgumentError,
454
+ "#{op_name} query exceeds #{MAX_PATTERN_LENGTH} chars (#{query.length}). " \
455
+ "Long patterns are denial-of-service vectors against Atlas Search."
456
+ end
457
+ nil
458
+ end
459
+
460
+ # Reject empty, oversized, or leading-wildcard patterns. Leading
461
+ # wildcards on `wildcard` / `regex` operators force Atlas Search to
462
+ # evaluate against every term in the index, which is both very slow
463
+ # and a denial-of-service vector when the input is user-controlled.
464
+ def validate_pattern!(query, kind:)
465
+ validate_query_length!(query, kind)
466
+ if kind == "wildcard"
467
+ if query.start_with?("*") || query.start_with?("?")
468
+ raise ArgumentError,
469
+ "wildcard query may not begin with '*' or '?'; leading wildcards " \
470
+ "force a full-index scan. Anchor the pattern with a literal prefix."
471
+ end
472
+ else # regex
473
+ if query.start_with?(".*") || query.start_with?(".+") ||
474
+ query.start_with?("*") || query.start_with?("?")
475
+ raise ArgumentError,
476
+ "regex query may not begin with '.*', '.+', '*', or '?'; " \
477
+ "unbounded leading matches force a full-index scan. Anchor the " \
478
+ "pattern with a literal prefix."
479
+ end
480
+ end
481
+ nil
482
+ end
483
+
484
+ # Reject a `path` value that itself encodes a wildcard scanning
485
+ # every indexed field (`{ "wildcard" => "*" }` or any leading
486
+ # `*`/`?` wildcard). Used to harden pattern operators against
487
+ # the `path` channel — `path: { wildcard: "*" }` reaches every
488
+ # field in the index even when the `query` is anchored. The
489
+ # top-level `Parse::AtlasSearch.search` call uses this for its
490
+ # default-field fallback, but a caller-supplied hash payload
491
+ # to `build_compound` must not be able to opt back in.
492
+ def validate_path_for_pattern!(path, op_name)
493
+ return unless path.is_a?(Hash)
494
+ wildcard = path["wildcard"] || path[:wildcard]
495
+ return if wildcard.nil?
496
+ unless wildcard.is_a?(String) && !wildcard.empty?
497
+ raise ArgumentError, "#{op_name} path.wildcard must be a non-empty String"
498
+ end
499
+ if wildcard.length > MAX_PATTERN_LENGTH
500
+ raise ArgumentError,
501
+ "#{op_name} path.wildcard exceeds #{MAX_PATTERN_LENGTH} chars."
502
+ end
503
+ if wildcard.start_with?("*") || wildcard.start_with?("?")
504
+ raise ArgumentError,
505
+ "#{op_name} path.wildcard may not begin with '*' or '?'; a leading " \
506
+ "wildcard on the path scans every indexed field."
507
+ end
508
+ nil
509
+ end
510
+
511
+ # Recursively validate the query/path payloads inside an
512
+ # operator hash supplied to {build_compound} via
513
+ # `must:`/`should:`/`filter:`/`must_not:`. The caller-supplied
514
+ # Hash form bypasses {#wildcard}/{#regex}/{#text}/{#autocomplete}
515
+ # entirely, so this is the only gate the structural payload
516
+ # passes through before reaching Atlas Search.
517
+ #
518
+ # Walks the `compound`/`must`/`mustNot`/`should`/`filter`
519
+ # branches one level deep — the SDK-public API does not need
520
+ # deeper-than-one nesting for the compound shapes we support.
521
+ def validate_operator_payload!(op)
522
+ return unless op.is_a?(Hash)
523
+ op.each do |key, value|
524
+ case key.to_s
525
+ when "wildcard"
526
+ next unless value.is_a?(Hash)
527
+ q = value["query"] || value[:query]
528
+ validate_pattern!(q, kind: "wildcard") if q
529
+ validate_path_for_pattern!(value["path"] || value[:path], "wildcard")
530
+ when "regex"
531
+ next unless value.is_a?(Hash)
532
+ q = value["query"] || value[:query]
533
+ validate_pattern!(q, kind: "regex") if q
534
+ validate_path_for_pattern!(value["path"] || value[:path], "regex")
535
+ when "text"
536
+ next unless value.is_a?(Hash)
537
+ q = value["query"] || value[:query]
538
+ validate_query_length!(q, "text") if q
539
+ validate_path_for_pattern!(value["path"] || value[:path], "text")
540
+ when "autocomplete"
541
+ next unless value.is_a?(Hash)
542
+ q = value["query"] || value[:query]
543
+ validate_query_length!(q, "autocomplete") if q
544
+ validate_path_for_pattern!(value["path"] || value[:path], "autocomplete")
545
+ when "phrase"
546
+ next unless value.is_a?(Hash)
547
+ q = value["query"] || value[:query]
548
+ validate_query_length!(q, "phrase") if q
549
+ when "compound"
550
+ next unless value.is_a?(Hash)
551
+ %w[must mustNot should filter].each do |branch|
552
+ Array(value[branch] || value[branch.to_sym]).each { |child| validate_operator_payload!(child) }
553
+ end
554
+ end
555
+ end
556
+ end
557
+
558
+ def normalize_path(path)
559
+ case path
560
+ when Array
561
+ path.map(&:to_s)
562
+ when Hash
563
+ # Wildcard path: { "wildcard" => "*" }
564
+ path.transform_keys(&:to_s)
565
+ else
566
+ path.to_s
567
+ end
568
+ end
569
+
570
+ def format_range_value(value)
571
+ case value
572
+ when ::Time, ::DateTime
573
+ value.utc.iso8601(3)
574
+ when ::Date
575
+ value.to_time.utc.iso8601(3)
576
+ else
577
+ value
578
+ end
579
+ end
580
+
581
+ def extract_operator(op)
582
+ # If it's a SearchBuilder, build it and extract the operator.
583
+ # The nested operators were validated when they were added
584
+ # through #wildcard/#regex/#text/#autocomplete on that builder,
585
+ # so no re-check is required.
586
+ if op.is_a?(SearchBuilder)
587
+ built = op.build
588
+ # Extract the operator from the built stage
589
+ built["$search"].except("index")
590
+ elsif op.is_a?(Hash)
591
+ # Hash operator payload supplied directly by the caller --
592
+ # this is the only path where validate_pattern! has NOT
593
+ # already run. Refuse leading-wildcard regex/wildcard
594
+ # patterns and oversized query strings before forwarding
595
+ # to Atlas Search.
596
+ validate_operator_payload!(op)
597
+ op
598
+ else
599
+ op
600
+ end
601
+ end
602
+ end
603
+ end
604
+ end