parse-stack-next 5.3.0 → 5.4.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -128,6 +128,39 @@ module Parse
128
128
  base.extend(ClassMethods)
129
129
  end
130
130
 
131
+ # Recompute this record's managed embedding(s) in-place, NOW,
132
+ # without a save. Runs the same digest-tracked recompute the
133
+ # `before_save` callback runs: a provider call happens only when the
134
+ # source text/URL changed since the last embed (digest miss). Useful
135
+ # to populate the vector before inspecting it, or to force a refresh
136
+ # in a console.
137
+ #
138
+ # @param field [Symbol, nil] limit to one embed target; nil
139
+ # recomputes every declared directive.
140
+ # @return [self]
141
+ # @raise [ArgumentError] when `field:` names no embed target, or the
142
+ # class declares no `embed` directives.
143
+ def compute_embedding!(field: nil)
144
+ directives = self.class.embed_directives
145
+ if directives.empty?
146
+ raise ArgumentError, "#{self.class}#compute_embedding!: no `embed` directives declared."
147
+ end
148
+ selected =
149
+ if field
150
+ d = directives[field.to_sym]
151
+ unless d
152
+ raise ArgumentError,
153
+ "#{self.class}#compute_embedding!: :#{field} is not an embed target " \
154
+ "(have #{directives.keys.inspect})."
155
+ end
156
+ [d]
157
+ else
158
+ directives.values
159
+ end
160
+ selected.each { |directive| Parse::Core::EmbedManaged.recompute_embedding!(self, directive) }
161
+ self
162
+ end
163
+
131
164
  module ClassMethods
132
165
  # Per-class registry of {EmbedDirective}s keyed by target vector
133
166
  # property symbol. Read by tests and tooling; written only by
@@ -300,6 +333,86 @@ module Parse
300
333
  into
301
334
  end
302
335
 
336
+ # Backfill embeddings for records whose managed vector field is
337
+ # still null — the bulk counterpart to the per-save embed path.
338
+ # Walks the class with objectId-cursor pagination (robust to the
339
+ # result set shrinking as records are embedded; terminates even
340
+ # when a record has no source text and stays null), saving each
341
+ # pending record so its `before_save` embed callback runs.
342
+ #
343
+ # Intended as an admin / maintenance operation: it reads and
344
+ # writes through the default client, so run it with a master-key
345
+ # client (or pass `save_opts:` carrying a `session_token:` that can
346
+ # write every row).
347
+ #
348
+ # @param field [Symbol, nil] limit the backfill to one embed
349
+ # target; nil processes every declared directive.
350
+ # @param batch_size [Integer] rows fetched per round (default 100).
351
+ # @param limit [Integer, nil] stop after embedding at most this
352
+ # many records across all directives; nil = no cap.
353
+ # @param where [Hash, nil] extra query constraints AND-ed with the
354
+ # null-target filter (e.g. `{ published: true }`).
355
+ # @param save_opts [Hash] options forwarded to each `record.save`
356
+ # (e.g. `session_token:`).
357
+ # @return [Integer] number of records saved (embedded).
358
+ # @raise [ArgumentError] when `field:` names no embed target, or
359
+ # the class declares no `embed` directives.
360
+ def embed_pending!(field: nil, batch_size: 100, limit: nil, where: nil, save_opts: {})
361
+ bs = Integer(batch_size)
362
+ raise ArgumentError, "#{self}.embed_pending!: batch_size must be positive." if bs <= 0
363
+ directives = resolve_embed_directives_for_backfill(field)
364
+
365
+ processed = 0
366
+ directives.each do |directive|
367
+ remaining = limit ? (limit - processed) : nil
368
+ break if remaining && remaining <= 0
369
+ processed += backfill_embed_directive!(directive, bs, where, remaining, save_opts)
370
+ end
371
+ processed
372
+ end
373
+
374
+ # @!visibility private
375
+ def resolve_embed_directives_for_backfill(field)
376
+ if field
377
+ d = embed_directives[field.to_sym]
378
+ unless d
379
+ raise ArgumentError,
380
+ "#{self}.embed_pending!: :#{field} is not an embed target " \
381
+ "(have #{embed_directives.keys.inspect})."
382
+ end
383
+ [d]
384
+ else
385
+ ds = embed_directives.values
386
+ raise ArgumentError, "#{self}.embed_pending!: no `embed` directives declared." if ds.empty?
387
+ ds
388
+ end
389
+ end
390
+
391
+ # @!visibility private
392
+ # objectId-cursor walk over rows where `directive.into` is null.
393
+ def backfill_embed_directive!(directive, batch_size, where, remaining, save_opts)
394
+ count = 0
395
+ cursor = nil
396
+ loop do
397
+ q = query(directive.into.null => true)
398
+ q = q.where(where) if where.is_a?(Hash) && !where.empty?
399
+ q = q.where(:objectId.gt => cursor) if cursor
400
+ q.order(:objectId.asc)
401
+ q.limit(batch_size)
402
+ batch = q.results
403
+ break if batch.nil? || batch.empty?
404
+
405
+ batch.each do |record|
406
+ cursor = record.id
407
+ record.save(**save_opts)
408
+ count += 1
409
+ return count if remaining && count >= remaining
410
+ end
411
+ break if batch.length < batch_size
412
+ end
413
+ count
414
+ end
415
+
303
416
  # @!visibility private
304
417
  # Prepend a module that intercepts the public `<into>=` setter
305
418
  # and raises {ProtectedFieldError} unless the current thread has
@@ -554,7 +554,7 @@ module Parse
554
554
  # @return [Parse::LiveQuery::Subscription] the subscription object
555
555
  # @see Parse::LiveQuery::Subscription
556
556
  # @see Parse::Query#subscribe
557
- def subscribe(where: {}, fields: nil, session_token: nil, client: nil,
557
+ def subscribe(where: {}, fields: nil, keys: nil, watch: nil, session_token: nil, client: nil,
558
558
  use_master_key: false, &block)
559
559
  # Fall through to the ambient set by `Parse.with_session` / `Parse.login`
560
560
  # so a caller wrapping a region with `with_session(user) { Klass.subscribe ... }`
@@ -565,6 +565,8 @@ module Parse
565
565
  end
566
566
  query(where).subscribe(
567
567
  fields: fields,
568
+ keys: keys,
569
+ watch: watch,
568
570
  session_token: session_token,
569
571
  client: client,
570
572
  use_master_key: use_master_key,
@@ -63,6 +63,54 @@ module Parse
63
63
  # binding it to a provider at the property level.
64
64
  class EmbedderNotConfigured < ArgumentError; end
65
65
 
66
+ # Accepted {#vector_visibility} modes.
67
+ VECTOR_VISIBILITY_MODES = %i[owner_only public].freeze
68
+
69
+ # Class-level default for whether this class's `:vector` properties
70
+ # are included in `as_json` serialization.
71
+ #
72
+ # * `:owner_only` (default) — vectors are OMITTED from `as_json`
73
+ # unless the caller passes `include_vectors: true`. Embeddings are
74
+ # large and leak ML signal; the safe default keeps them off the
75
+ # wire and out of API responses. Row-level read access is still
76
+ # governed by ACL as usual — this controls serialization exposure,
77
+ # not row authorization.
78
+ # * `:public` — vectors are INCLUDED in `as_json` by default (a
79
+ # caller can still suppress per-call with `include_vectors: false`).
80
+ #
81
+ # class Article < Parse::Object
82
+ # vector_visibility :public # expose embeddings in as_json
83
+ # property :embedding, :vector, dimensions: 1536, provider: :openai
84
+ # end
85
+ #
86
+ # Read the effective mode by calling with no argument; it inherits
87
+ # from the superclass when unset on the subclass.
88
+ #
89
+ # @param mode [Symbol, nil] one of {VECTOR_VISIBILITY_MODES}, or nil
90
+ # to read the current effective mode.
91
+ # @return [Symbol] the effective mode (when reading) or the mode set.
92
+ # @raise [ArgumentError] on an unknown mode.
93
+ def vector_visibility(mode = nil)
94
+ if mode.nil?
95
+ return @vector_visibility if defined?(@vector_visibility) && @vector_visibility
96
+ return superclass.vector_visibility if superclass.respond_to?(:vector_visibility)
97
+ return :owner_only
98
+ end
99
+ m = mode.to_sym
100
+ unless VECTOR_VISIBILITY_MODES.include?(m)
101
+ raise ArgumentError,
102
+ "#{self}.vector_visibility: mode must be one of " \
103
+ "#{VECTOR_VISIBILITY_MODES.inspect} (got #{mode.inspect})."
104
+ end
105
+ @vector_visibility = m
106
+ end
107
+
108
+ # @return [Boolean] whether `:vector` fields are serialized into
109
+ # `as_json` by default for this class (true only for `:public`).
110
+ def vectors_public_by_default?
111
+ vector_visibility == :public
112
+ end
113
+
66
114
  # Find documents whose declared `:vector` property is closest to
67
115
  # `vector:` under the Atlas vectorSearch index's similarity
68
116
  # function.
@@ -169,6 +217,97 @@ module Parse
169
217
  build_vector_hits(raw_hits)
170
218
  end
171
219
 
220
+ # Hybrid (lexical + vector) search with reciprocal-rank fusion.
221
+ #
222
+ # Runs a lexical Atlas Search branch and a `$vectorSearch` branch
223
+ # independently, then fuses their ranked results client-side via RRF
224
+ # (or, on Atlas 8.0+, server-side via native `$rankFusion` when
225
+ # detected). Both branches enforce ACL/CLP/protectedFields before
226
+ # fusion — see {Parse::VectorSearch::Hybrid}.
227
+ #
228
+ # @example
229
+ # Song.hybrid_search(
230
+ # text: "love songs about rain",
231
+ # lexical: { index: "song_search", query: "rain love" },
232
+ # vector: { num_candidates: 200 },
233
+ # k: 20,
234
+ # fusion: { k_constant: 60, weights: { lexical: 0.4, vector: 0.6 } },
235
+ # )
236
+ #
237
+ # @param text [String, nil] natural-language query. Embedded (via
238
+ # the resolved `:vector` property's `provider:`) for the vector
239
+ # branch, and used as the lexical query unless `lexical[:query]`
240
+ # overrides it.
241
+ # @param query_vector [Array<Float>, Parse::Vector, nil] pre-computed
242
+ # query embedding (alternative to `text:` for the vector branch).
243
+ # @param lexical [Hash] lexical branch config (`:query`, `:index`,
244
+ # `:fields`, `:filter`, `:fuzzy`). `:query` defaults to `text:`.
245
+ # @param vector [Hash] vector branch config (`:field`, `:index`,
246
+ # `:num_candidates`, `:filter`, `:vector_filter`). `:field`
247
+ # defaults to the class's sole `:vector` property; `:index` is
248
+ # auto-discovered when omitted.
249
+ # @param k [Integer] number of fused hits to return.
250
+ # @param fusion [Hash, nil] `:method` (`:rrf` / `:rrf_client`),
251
+ # `:k_constant`, `:weights` (`{ lexical:, vector: }`).
252
+ # @param raw [Boolean] return fused raw rows instead of built
253
+ # Parse::Object instances.
254
+ # @param scope_opts [Hash] ACL/CLP scope kwargs forwarded to both
255
+ # branches (`session_token:` / `master:` / `acl_user:` /
256
+ # `acl_role:`).
257
+ # @return [Array<Parse::Object>] fused, RRF-ordered; each carries
258
+ # `#hybrid_score` and `#hybrid_ranks` (and `#vector_score` /
259
+ # `#search_score` when the branch contributed). `raw: true`
260
+ # returns the fused Hashes.
261
+ def hybrid_search(text: nil, query_vector: nil, lexical: {}, vector: {},
262
+ k: 20, fusion: nil, raw: false, **scope_opts)
263
+ require_relative "../../vector_search/hybrid"
264
+ lex = (lexical || {}).transform_keys(&:to_sym)
265
+ vec = (vector || {}).transform_keys(&:to_sym)
266
+
267
+ field_sym = resolve_vector_field!(vec[:field])
268
+ declared_dims = vector_properties.dig(field_sym, :dimensions)
269
+
270
+ qv = query_vector || vec[:query_vector]
271
+ qv =
272
+ if qv.nil?
273
+ unless text.is_a?(String) && !text.strip.empty?
274
+ raise ArgumentError,
275
+ "#{self}.hybrid_search: pass `text:` (to embed) or a `query_vector:`."
276
+ end
277
+ embed_query_text!(text, field_sym)
278
+ else
279
+ coerce_query_vector(qv)
280
+ end
281
+ Parse::VectorSearch.validate_query_vector!(qv, dimensions: declared_dims)
282
+
283
+ lexical_query = lex[:query] || text
284
+ unless lexical_query.is_a?(String) && !lexical_query.strip.empty?
285
+ raise ArgumentError,
286
+ "#{self}.hybrid_search: needs a lexical query — pass `text:` or `lexical: { query: }`."
287
+ end
288
+
289
+ vector_index = vec[:index] || resolve_vector_index!(field_sym, nil)
290
+
291
+ fused = Parse::VectorSearch::Hybrid.search(
292
+ parse_class,
293
+ lexical: {
294
+ query: lexical_query, index: lex[:index], fields: lex[:fields],
295
+ filter: lex[:filter], fuzzy: lex[:fuzzy],
296
+ },
297
+ vector: {
298
+ query_vector: qv, field: field_sym, index: vector_index,
299
+ num_candidates: vec[:num_candidates], filter: vec[:filter],
300
+ vector_filter: vec[:vector_filter],
301
+ },
302
+ k: k,
303
+ fusion: fusion,
304
+ **scope_opts,
305
+ )
306
+
307
+ return fused if raw
308
+ build_hybrid_hits(fused)
309
+ end
310
+
172
311
  private
173
312
 
174
313
  def resolve_vector_field!(field)
@@ -280,6 +419,28 @@ module Parse
280
419
  obj
281
420
  end.compact
282
421
  end
422
+
423
+ # Build Parse::Object instances from fused hybrid rows, attaching
424
+ # the fused score / per-branch ranks plus whatever per-branch scores
425
+ # survived the merge (`_vscore`, `_score`).
426
+ def build_hybrid_hits(rows)
427
+ return [] if rows.nil? || rows.empty?
428
+ converted = Parse::MongoDB.convert_documents_to_parse(rows, parse_class)
429
+ converted.each_with_index.map do |doc, idx|
430
+ obj = Parse::Object.build(doc, parse_class)
431
+ next nil unless obj
432
+ src = rows[idx]
433
+ hscore = src["_hybrid_score"] || src[:_hybrid_score]
434
+ hranks = src["_hybrid_ranks"] || src[:_hybrid_ranks]
435
+ vscore = src["_vscore"] || src[:_vscore]
436
+ sscore = src["_score"] || src[:_score]
437
+ obj.instance_variable_set(:@_hybrid_score, hscore) unless hscore.nil?
438
+ obj.instance_variable_set(:@_hybrid_ranks, hranks) unless hranks.nil?
439
+ obj.instance_variable_set(:@_vector_score, vscore) unless vscore.nil?
440
+ obj.instance_variable_set(:@_search_score, sscore) unless sscore.nil?
441
+ obj
442
+ end.compact
443
+ end
283
444
  end
284
445
  end
285
446
  end
@@ -182,6 +182,14 @@ module Parse
182
182
  # @return [Hash, nil] Atlas Search highlights blob.
183
183
  def search_highlights; @_search_highlights; end
184
184
 
185
+ # @return [Float, nil] fused reciprocal-rank-fusion score from
186
+ # `Class.hybrid_search`.
187
+ def hybrid_score; @_hybrid_score; end
188
+
189
+ # @return [Hash, nil] per-branch 1-based ranks from
190
+ # `Class.hybrid_search` (`{ lexical:, vector: }`).
191
+ def hybrid_ranks; @_hybrid_ranks; end
192
+
185
193
  # @return [Model::TYPE_OBJECT]
186
194
  def __type; Parse::Model::TYPE_OBJECT; end
187
195
 
@@ -384,7 +392,12 @@ module Parse
384
392
  end
385
393
 
386
394
  # The set of default ACLs to be applied on newly created instances of this class.
387
- # By default, public read and write are enabled unless {default_acl_private} is true.
395
+ # The result follows the class's {acl_policy_setting}: the shipped default
396
+ # policy is `:owner_else_private`, whose fallback half is {Parse::ACL.private}
397
+ # (an empty ACL — readable only by the master key until an owner is resolved
398
+ # at save time). Classes that opt into a `:public*` policy, or set
399
+ # {default_acl_private} / {set_default_acl}, get the corresponding permissions
400
+ # instead.
388
401
  # @see Parse::ACL.everyone
389
402
  # @see Parse::ACL.private
390
403
  # @return [Parse::ACL] the current default ACLs for this class.
@@ -398,8 +411,10 @@ module Parse
398
411
  end
399
412
 
400
413
  # A method to set default ACLs to be applied for newly created
401
- # instances of this class. All subclasses have public read and write enabled
402
- # by default.
414
+ # instances of this class. Unless overridden, subclasses inherit the
415
+ # shipped `:owner_else_private` policy (records are private/master-only
416
+ # until an owner is resolved at save time); use this method (or
417
+ # {acl_policy}) to grant broader access.
403
418
  # @example
404
419
  # class AdminData < Parse::Object
405
420
  #
@@ -1223,8 +1238,16 @@ module Parse
1223
1238
  # signal to clients, and they round-trip through the dedicated
1224
1239
  # embed/find_similar pipelines rather than the standard REST
1225
1240
  # save/find. Pass `include_vectors: true` to opt back in (e.g.,
1226
- # for tests or internal mongo-direct bulk writes).
1227
- unless opts[:include_vectors] == true
1241
+ # for tests or internal mongo-direct bulk writes). A class may flip
1242
+ # the per-class default with `vector_visibility :public`; an explicit
1243
+ # `include_vectors:` in the call always wins over the class default.
1244
+ include_vectors =
1245
+ if opts.key?(:include_vectors)
1246
+ opts[:include_vectors] == true
1247
+ else
1248
+ self.class.respond_to?(:vectors_public_by_default?) && self.class.vectors_public_by_default?
1249
+ end
1250
+ unless include_vectors
1228
1251
  vector_fields = self.class.respond_to?(:fields) ? self.class.fields(:vector).keys.map(&:to_s) : []
1229
1252
  if vector_fields.any?
1230
1253
  except = Array(opts[:except]).map(&:to_s) | vector_fields
data/lib/parse/mongodb.rb CHANGED
@@ -1499,7 +1499,7 @@ module Parse
1499
1499
  # @raise [Parse::ACLScope::ACLRequired] when neither
1500
1500
  # `session_token:` nor `master: true` is supplied and
1501
1501
  # {Parse::ACLScope.require_session_token} is enabled.
1502
- def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil)
1502
+ def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil, hint: nil)
1503
1503
  # AS::N envelope. Payload is intentionally metadata-only —
1504
1504
  # `stage_count`, `stage_types`, `collection`, `scope`,
1505
1505
  # `result_count`, `max_time_ms`, `read_preference`. Pipeline
@@ -1620,6 +1620,11 @@ module Parse
1620
1620
 
1621
1621
  agg_opts = {}
1622
1622
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1623
+ # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint`
1624
+ # on the mongo-direct path so a bad plan diagnosed with `explain` can
1625
+ # be corrected here too. Accepts an index name (String) or a key
1626
+ # pattern (Hash).
1627
+ agg_opts[:hint] = hint unless hint.nil?
1623
1628
  coll = collection(collection_name)
1624
1629
  if (mode = normalize_read_preference(read_preference))
1625
1630
  coll = coll.with(read: { mode: mode })
@@ -1838,6 +1843,7 @@ module Parse
1838
1843
  cursor = cursor.skip(options[:skip]) if options[:skip]
1839
1844
  cursor = cursor.sort(options[:sort]) if options[:sort]
1840
1845
  cursor = cursor.projection(options[:projection]) if options[:projection]
1846
+ cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
1841
1847
  cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
1842
1848
  results = cursor.to_a
1843
1849
 
@@ -182,13 +182,15 @@ module Parse
182
182
  # that `Parse::AtlasSearch` calls do not break. `$vectorSearch` is
183
183
  # included for `Parse::VectorSearch` — like `$search`, it is a
184
184
  # read-only Atlas index stage and must be the FIRST stage of the
185
- # pipeline (Atlas refuses it otherwise).
185
+ # pipeline (Atlas refuses it otherwise). `$rankFusion` (Atlas 8.0+)
186
+ # is the native server-side reciprocal-rank-fusion stage used by
187
+ # `Parse::VectorSearch::Hybrid` — also a read-only stage-0 operator.
186
188
  ALLOWED_STAGES = %w[
187
189
  $match $group $sort $project $limit $skip $unwind $lookup
188
190
  $count $addFields $set $unset $bucket $bucketAuto $facet
189
191
  $sample $sortByCount $replaceRoot $replaceWith $redact
190
192
  $graphLookup $unionWith
191
- $search $searchMeta $listSearchIndexes $vectorSearch
193
+ $search $searchMeta $listSearchIndexes $vectorSearch $rankFusion
192
194
  ].freeze
193
195
 
194
196
  # Atlas operators that are valid only as the FIRST stage of a
@@ -202,7 +204,7 @@ module Parse
202
204
  # for full-text and vector search is the dedicated
203
205
  # `atlas_search` / `semantic_search` tools, not raw aggregate.
204
206
  STAGE0_ONLY_ATLAS_STAGES = %w[
205
- $search $searchMeta $vectorSearch $listSearchIndexes
207
+ $search $searchMeta $vectorSearch $listSearchIndexes $rankFusion
206
208
  ].freeze
207
209
 
208
210
  # Cap on the length of a caller-supplied `$regex` (or the `regex:`
@@ -499,6 +499,35 @@ module Parse
499
499
  end
500
500
  end
501
501
 
502
+ # Equivalent to the $containedBy Parse query operation. Matches documents
503
+ # where the array field's values are all within the supplied set (the
504
+ # inverse of {ContainsAllConstraint}: the field must be a *subset* of the
505
+ # provided array). The field column should be of type {Array} in your
506
+ # Parse class.
507
+ #
508
+ # q.where :field.contained_by => [1, 2, 3]
509
+ # q.where :tags.contained_by => ["ruby", "rails", "parse"]
510
+ #
511
+ # @see ContainsAllConstraint
512
+ # @see ContainedInConstraint
513
+ class ContainedByConstraint < Constraint
514
+ # @!method contained_by
515
+ # A registered method on a symbol to create the constraint.
516
+ # Maps to Parse operator "$containedBy".
517
+ # @example
518
+ # q.where :tags.contained_by => ["ruby", "rails"]
519
+ # @return [ContainedByConstraint]
520
+ constraint_keyword :$containedBy
521
+ register :contained_by
522
+
523
+ # @return [Hash] the compiled constraint.
524
+ def build
525
+ val = formatted_value
526
+ val = [val].compact unless val.is_a?(Array)
527
+ { @operation.operand => { key => val } }
528
+ end
529
+ end
530
+
502
531
  # Array size constraint using MongoDB aggregation.
503
532
  # Parse Server does not natively support $size query constraint, so we use
504
533
  # MongoDB aggregation pipeline with $expr and $size to check array length.