Module: Parse::AtlasSearch

Defined in:
lib/parse/atlas_search.rb,
lib/parse/atlas_search/result.rb,
lib/parse/atlas_search/session.rb,
lib/parse/atlas_search/index_manager.rb,
lib/parse/atlas_search/search_builder.rb

Overview

Note:

Requires the ‘mongo’ gem and a MongoDB Atlas cluster with Search enabled. Also works with local Atlas deployments created via ‘atlas deployments setup –type local`.

Atlas Search module for MongoDB Atlas full-text search capabilities. Provides direct access to Atlas Search features bypassing Parse Server.

Examples:

Enable Atlas Search

Parse::MongoDB.configure(uri: "mongodb+srv://...", enabled: true)
Parse::AtlasSearch.configure(enabled: true, default_index: "default")

Full-text search

result = Parse::AtlasSearch.search("Song", "love", index: "song_search")
result.results.each { |song| puts song.title }

Autocomplete

result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]

Defined Under Namespace

Modules: IndexManager, Session Classes: ACLRequired, AutocompleteResult, FacetedResult, FacetedSearchNotACLSafe, IndexNotFound, InvalidSearchParameters, NotAvailable, SearchBuilder, SearchResult

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.allow_rawBoolean

Whether ‘raw: true` is honored on search, autocomplete, and faceted_search. When `false` (the default), `raw:` is ignored and callers receive converted Parse-format documents. Even when `true`, internal-fields denylist (cf. PipelineSecurity::INTERNAL_FIELDS_DENYLIST) is ALWAYS stripped — there is no path that returns `_hashed_password`, `_session_token`, etc., regardless of `raw:`.

Returns:

  • (Boolean)


79
80
81
# File 'lib/parse/atlas_search.rb', line 79

def allow_raw
  @allow_raw
end

.default_indexString

Default search index name to use when none specified.

Returns:



67
68
69
# File 'lib/parse/atlas_search.rb', line 67

def default_index
  @default_index
end

.enabledBoolean

Feature flag to enable/disable Atlas Search.

Returns:

  • (Boolean)


62
63
64
# File 'lib/parse/atlas_search.rb', line 62

def enabled
  @enabled
end

.require_session_tokenBoolean

When true, search, autocomplete, and faceted_search raise ACLRequired unless the caller passes either session_token: or master: true. Default: false, matching the pre-ACL behavior — a one-time [Parse::AtlasSearch:SECURITY] banner is emitted instead for missing-token calls, the same pattern used by Parse::Agent for master-key construction.

New deployments are strongly encouraged to flip this to true at startup. The next major release will flip the default.

Returns:

  • (Boolean)


94
95
96
# File 'lib/parse/atlas_search.rb', line 94

def require_session_token
  @require_session_token
end

.role_cache#get, ...

Pluggable cache for Session‘s role-name lookups. See session_cache for the interface contract.

Returns:

  • (#get, #set, #invalidate)


128
129
130
# File 'lib/parse/atlas_search.rb', line 128

def role_cache
  @role_cache
end

.role_cache_ttlInteger

TTL (seconds) for Session‘s user-id → role-name cache. Default: 120 (2 minutes). Short on purpose: stale role data yields incorrect ACL decisions, so the cache is sized to amortize within a single request/turn but expire well inside the response time the operator notices a role grant.

Returns:

  • (Integer)


113
114
115
# File 'lib/parse/atlas_search.rb', line 113

def role_cache_ttl
  @role_cache_ttl
end

.session_cache#get, ...

Pluggable cache for Session‘s session-token lookups. Replace with a Redis/Memcached adapter for cross-process sharing; the object must respond to get(key), set(key, value, ttl:), and invalidate(key). Defaults to a process-local Parse::AtlasSearch::Session::MemoryCache.

Returns:

  • (#get, #set, #invalidate)


122
123
124
# File 'lib/parse/atlas_search.rb', line 122

def session_cache
  @session_cache
end

.session_cache_ttlInteger

TTL (seconds) for Session‘s session-token → user-id cache. Default: 3600 (1 hour). Longer values reduce /users/me round-trips but extend the window during which a revoked session can still authenticate Atlas Search calls; apps with sub-TTL revocation requirements should call Parse::AtlasSearch::Session.invalidate from their logout path.

Returns:

  • (Integer)


104
105
106
# File 'lib/parse/atlas_search.rb', line 104

def session_cache_ttl
  @session_cache_ttl
end

Class Method Details

.autocomplete(collection_name, query, field:, **options) ⇒ Parse::AtlasSearch::AutocompleteResult

Perform an autocomplete search for search-as-you-type functionality.

Examples:

Basic autocomplete

result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]

Parameters:

  • collection_name (String)

    the Parse collection name

  • query (String)

    the partial search query (prefix)

  • field (Symbol, String)

    the field configured for autocomplete

  • options (Hash)

    autocomplete options

Options Hash (**options):

  • :index (String)

    search index name (default: configured default_index)

  • :fuzzy (Boolean)

    enable fuzzy matching (default: false)

  • :fuzzy_max_edits (Integer)

    max edit distance (1 or 2, default: 1)

  • :token_order (String)

    “any” or “sequential” (default: “any”)

  • :limit (Integer)

    max suggestions to return (default: 10)

  • :filter (Hash)

    additional constraints

  • :raw (Boolean)

    return raw documents (default: false)

Returns:

Raises:



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/parse/atlas_search.rb', line 402

def autocomplete(collection_name, query, field:, **options)
  require_available!

  raise InvalidSearchParameters, "field is required for autocomplete" if field.nil?
  raise InvalidSearchParameters, "query must be a non-empty string" if query.nil? || query.to_s.strip.empty?

  # Wave-3b READPREF-4: see #search for rationale.
  read_preference = options.delete(:read_preference)
  resolution = resolve_scope!(options, method_name: :autocomplete)

  # Enforce CLP `find` (and pointerFields requirement) on the same
  # collection autocomplete is about to scan. Without this an
  # autocomplete UI on a protected class would silently surface
  # the protected field's leading characters to any caller.
  assert_clp_find!(collection_name, resolution)
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)

  # ATLAS-4: refuse autocomplete on a protected field. The
  # autocomplete operator returns the leading characters of the
  # indexed field value verbatim — running autocomplete on, say,
  # `email` when CLP marks `email` protected would defeat the
  # protectedFields contract.
  protected_fields = Parse::CLPScope.protected_fields_for(
    collection_name, resolution.permission_strings,
  )
  field_str = field.to_s
  if !resolution.master? && protected_fields.include?(field_str)
    raise Parse::CLPScope::Denied.new(
      collection_name, :find,
      "Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
      "protectedFields for the current scope; autocompleting on it would " \
      "leak the protected field's value.",
    )
  end

  index_name = options[:index] || @default_index
  limit = options[:limit] || 10

  # Build autocomplete search stage
  builder = SearchBuilder.new(index_name: index_name)
  builder.autocomplete(
    query: query.to_s,
    path: field_str,
    fuzzy: options[:fuzzy],
    token_order: options[:token_order],
  )

  # CRITICAL: $search MUST be stage 0 of the pipeline (see
  # comments in #search). Build manually; do NOT route through
  # Parse::MongoDB.aggregate (which would prepend an ACL $match
  # at position 0 and break Atlas's invariant).
  pipeline = [builder.build]

  # Add score
  pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }

  # Inject ACL $match AFTER $search/$addFields and BEFORE the
  # caller-supplied filter; see {.search} for the rationale.
  unless resolution.master?
    acl_match = Parse::ACLScope.match_stage_for(resolution)
    pipeline << acl_match if acl_match
  end

  # Add filter if provided
  if options[:filter]
    mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
    pipeline << { "$match" => mongo_filter }
  end

  # Sort by score and limit
  pipeline << { "$sort" => { "_score" => -1 } }
  pipeline << { "$limit" => limit }

  raw_results = run_atlas_pipeline!(
    collection_name, pipeline, options[:max_time_ms],
    read_preference: read_preference,
  )

  unless resolution.master?
    Parse::ACLScope.redact_results!(raw_results, resolution)
    Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
    if pointer_fields
      raw_results = Parse::CLPScope.filter_by_pointer_fields(
        raw_results, pointer_fields, resolution.user_id,
      )
    end
  end

  # Extract suggestions (the field values). Run after the
  # protectedFields strip / pointerFields filter so a redacted
  # row can't surface its field value through the suggestion list.
  suggestions = raw_results.map { |doc| doc[field_str] }.compact.uniq

  # Convert to full objects if needed
  class_name = options[:class_name] || collection_name
  results = if raw_mode?(options[:raw])
      sanitize_raw_results(raw_results)
    else
      parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name)
      parse_results.map { |doc| build_parse_object(doc, class_name) }.compact
    end

  AutocompleteResult.new(suggestions: suggestions, results: results)
end

.available?Boolean

Check if Atlas Search is available and enabled

Returns:

  • (Boolean)


178
179
180
181
# File 'lib/parse/atlas_search.rb', line 178

def available?
  return false unless defined?(Parse::MongoDB)
  Parse::MongoDB.available? && enabled?
end

.configure(enabled: true, default_index: "default", allow_raw: nil, require_session_token: nil, session_cache_ttl: nil, role_cache_ttl: nil) ⇒ Object

Configure Atlas Search (uses Parse::MongoDB connection)

Examples:

Parse::AtlasSearch.configure(enabled: true, default_index: "default")

Parameters:

  • enabled (Boolean) (defaults to: true)

    whether to enable Atlas Search (default: true)

  • default_index (String) (defaults to: "default")

    default search index name (default: “default”)

  • allow_raw (Boolean) (defaults to: nil)

    whether ‘raw: true` is honored on search/autocomplete/faceted_search. Defaults to `false` (raw flag ignored) in production-like environments and `true` when RACK_ENV/RAILS_ENV is `development` or `test`. Internal-field stripping runs regardless.

  • require_session_token (Boolean) (defaults to: nil)

    when true, library calls without session_token: or master: true raise ACLRequired. See #require_session_token. Default: false.

  • session_cache_ttl (Integer) (defaults to: nil)

    session-token cache TTL (seconds). Default: 3600.

  • role_cache_ttl (Integer) (defaults to: nil)

    role-name cache TTL (seconds). Default: 120.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/parse/atlas_search.rb', line 147

def configure(enabled: true,
              default_index: "default",
              allow_raw: nil,
              require_session_token: nil,
              session_cache_ttl: nil,
              role_cache_ttl: nil)
  Parse::MongoDB.require_gem!
  @enabled = enabled
  @default_index = default_index
  @allow_raw = allow_raw.nil? ? default_allow_raw : allow_raw
  @require_session_token = require_session_token unless require_session_token.nil?
  @session_cache_ttl = session_cache_ttl unless session_cache_ttl.nil?
  @role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil?
  IndexManager.clear_cache
end

.enabled?Boolean

Check if Atlas Search is enabled

Returns:

  • (Boolean)


185
186
187
# File 'lib/parse/atlas_search.rb', line 185

def enabled?
  @enabled == true
end

.faceted_search(collection_name, query, facets, **options) ⇒ Parse::AtlasSearch::FacetedResult

Perform a faceted search with category counts.

Examples:

Faceted search by genre and year

facets = {
  genre: { type: :string, path: :genre },
  decade: { type: :number, path: :year, boundaries: [1970, 1980, 1990, 2000, 2010] }
}
result = Parse::AtlasSearch.faceted_search("Song", "rock", facets)
result.facets[:genre] # => [{ value: "Rock", count: 150 }, ...]

Parameters:

  • collection_name (String)

    the Parse collection name

  • query (String, nil)

    the search query text (nil for match-all)

  • facets (Hash)

    facet definitions

  • options (Hash)

    search options (same as #search)

Returns:



523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
# File 'lib/parse/atlas_search.rb', line 523

def faceted_search(collection_name, query, facets, **options)
  require_available!

  # Faceted search uses $searchMeta, which outputs a single
  # metadata document — bucket counts can't be retroactively
  # filtered by a post-$searchMeta $match because the matched
  # documents are not in the output stream. ACL-aware faceting
  # requires either tokenizing _rperm in the search index and
  # injecting a compound.filter inside $searchMeta, or running
  # two passes with manual aggregation. Both are deferred.
  #
  # Library-layer defense: refuse ANY scoped identity kwarg
  # (session_token:, acl_user:, acl_role:) unless the caller
  # explicitly accepts master-key semantics by also passing
  # `master: true`. The original code only checked
  # `session_token:`, leaving `acl_user:` / `acl_role:` callers
  # (ATLAS-10) silently downgraded to the unauthenticated/
  # public-mode banner branch — which on $searchMeta produces
  # bucket counts that include rows the caller cannot read,
  # exfiltrating restricted document counts and category
  # values. Checking the raw options BEFORE resolve_scope!
  # pops them so the error path can name what the caller
  # actually passed.
  scoped_kwargs = %i[session_token acl_user acl_role]
  offending = scoped_kwargs.select { |k| !options[k].nil? }
  if offending.any? && options[:master] != true
    raise FacetedSearchNotACLSafe,
          "Parse::AtlasSearch.faceted_search cannot enforce per-row " \
          "ACL on $searchMeta bucket counts (got #{offending.first}:). " \
          "Pass `master: true` to run with master-key semantics and " \
          "accept that bucket counts include all rows, or use " \
          "#search for ACL-scoped results without facets."
  end
  # Wave-3b READPREF-4: see #search for rationale. Captured
  # before resolve_scope! pops the auth kwargs so the recursive
  # search() call below can re-thread it explicitly (resolve!
  # also strips it during that recursion).
  read_preference = options.delete(:read_preference)
  resolution = resolve_scope!(options, method_name: :faceted_search)
  acl = { master: resolution.master? }

  index_name = options[:index] || @default_index
  limit = options[:limit] || 100
  skip_val = options[:skip] || 0

  # Build facet definitions for $searchMeta
  facet_definitions = build_facet_definitions(facets)

  search_meta_stage = {
    "$searchMeta" => {
      "index" => index_name,
      "facet" => {
        "facets" => facet_definitions,
      },
    },
  }

  # Add operator for the search query if present
  if query.present?
    fields = normalize_fields(options[:fields])
    if fields.present?
      should_clauses = fields.map do |field|
        { "text" => { "query" => query, "path" => field } }
      end
      search_meta_stage["$searchMeta"]["facet"]["operator"] = {
        "compound" => { "should" => should_clauses, "minimumShouldMatch" => 1 },
      }
    else
      search_meta_stage["$searchMeta"]["facet"]["operator"] = {
        "text" => { "query" => query, "path" => { "wildcard" => "*" } },
      }
    end
  end

  # Execute facet query. $searchMeta MUST be the only / first
  # stage of its pipeline — Atlas rejects anything prepended.
  # Bypass Parse::MongoDB.aggregate (which would prepend a
  # public-mode ACL $match at position 0 under the no-auth-kwargs
  # fallthrough) and call the collection directly. At this point
  # the call is master-only by construction (the offending-kwargs
  # check above ensures any scoped caller bailed out), so no
  # ACL/CLP enforcement runs here either.
  facet_pipeline = [search_meta_stage]
  facet_results_raw = run_atlas_pipeline!(
    collection_name, facet_pipeline, options[:max_time_ms],
    read_preference: read_preference,
  )

  # Extract facet results
  facet_data = {}
  total_count = 0

  if facet_results_raw.first
    raw = facet_results_raw.first
    total_count = raw.dig("count", "total") || 0

    if raw["facet"]
      facets.keys.each do |facet_name|
        bucket_key = facet_name.to_s
        if raw["facet"][bucket_key]
          facet_data[facet_name] = raw["facet"][bucket_key]["buckets"].map do |bucket|
            { value: bucket["_id"], count: bucket["count"] }
          end
        end
      end
    end
  end

  # Get actual results with regular $search. Forward master:
  # explicitly because resolve_acl_options! popped it from the
  # options hash; without re-adding it the recursive call would
  # take the unauthenticated path and emit the banner a second
  # time (or raise ACLRequired under strict mode). Re-thread
  # read_preference: the same way for the same reason — the
  # outer faceted_search popped it before delegating.
  results = if limit > 0 && query.present?
      search_opts = options.merge(limit: limit, skip: skip_val)
      search_opts[:master] = true if acl[:master]
      search_opts[:read_preference] = read_preference if read_preference
      search(collection_name, query, **search_opts).results
    else
      []
    end

  FacetedResult.new(results: results, facets: facet_data, total_count: total_count)
end

.index_ready?(collection_name, index_name = nil) ⇒ Boolean

Check if a search index exists and is ready

Parameters:

  • collection_name (String)

    the Parse collection name

  • index_name (String) (defaults to: nil)

    the index name to check (default: default_index)

Returns:

  • (Boolean)

    true if index exists and is queryable



216
217
218
# File 'lib/parse/atlas_search.rb', line 216

def index_ready?(collection_name, index_name = nil)
  IndexManager.index_ready?(collection_name, index_name || @default_index)
end

.indexes(collection_name) ⇒ Array<Hash>

List search indexes for a collection (cached)

Parameters:

  • collection_name (String)

    the Parse collection name

Returns:

  • (Array<Hash>)

    array of index definitions



208
209
210
# File 'lib/parse/atlas_search.rb', line 208

def indexes(collection_name)
  IndexManager.list_indexes(collection_name)
end

.refresh_indexes(collection_name = nil) ⇒ Object

Force refresh the index cache for a collection

Parameters:

  • collection_name (String) (defaults to: nil)

    the Parse collection name (nil to clear all)



222
223
224
# File 'lib/parse/atlas_search.rb', line 222

def refresh_indexes(collection_name = nil)
  IndexManager.clear_cache(collection_name)
end

.reset!Object

Reset Atlas Search configuration to first-load defaults. Clears the session/role caches as well; this is primarily a test helper.



192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/parse/atlas_search.rb', line 192

def reset!
  @enabled = false
  @default_index = "default"
  @allow_raw = default_allow_raw
  @require_session_token = false
  @session_cache_ttl = 3600
  @role_cache_ttl = 120
  @session_cache = Session::MemoryCache.new
  @role_cache = Session::MemoryCache.new
  @master_warned = false
  IndexManager.clear_cache
end

.search(collection_name, query, **options) ⇒ Parse::AtlasSearch::SearchResult

Perform a full-text search using Atlas Search.

Examples:

Basic search

result = Parse::AtlasSearch.search("Song", "love ballad")
result.results.each { |song| puts song.title }

Search with fuzzy matching and field restriction

result = Parse::AtlasSearch.search("Song", "lvoe",
  fields: [:title, :lyrics],
  fuzzy: true,
  limit: 20
)

Parameters:

  • collection_name (String)

    the Parse collection name (e.g., “Song”)

  • query (String)

    the search query text

  • options (Hash)

    search options

Options Hash (**options):

  • :index (String)

    search index name (default: configured default_index)

  • :fields (Array<String>, String, Symbol)

    fields to search (default: all indexed fields)

  • :fuzzy (Boolean)

    enable fuzzy matching (default: false)

  • :fuzzy_max_edits (Integer)

    max edit distance for fuzzy (1 or 2, default: 2)

  • :highlight_field (Symbol, String)

    field to return highlights for

  • :limit (Integer)

    max results to return (default: 100)

  • :skip (Integer)

    number of results to skip (default: 0)

  • :filter (Hash)

    additional constraints to apply

  • :sort (Hash)

    sort specification (default: by relevance score)

  • :raw (Boolean)

    return raw MongoDB documents (default: false)

  • :class_name (String)

    Parse class name for object conversion

Returns:



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/parse/atlas_search.rb', line 259

def search(collection_name, query, **options)
  require_available!
  validate_search_params!(query)

  # Wave-3b READPREF-4: read-preference is consumed at the
  # collection-with-read-preference step inside run_atlas_pipeline!.
  # Pop it here so it doesn't surface in `options` for any
  # downstream consumer (SearchBuilder, recursive search()
  # call from faceted_search) that iterates the hash.
  read_preference = options.delete(:read_preference)
  resolution = resolve_scope!(options, method_name: :search)

  # Enforce CLP `find` (and pointerFields requirement) BEFORE
  # we build / execute the pipeline. Without this, a scoped
  # caller can issue $search against a collection whose CLP
  # would refuse them on the equivalent REST find.
  assert_clp_find!(collection_name, resolution)
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)

  # Compute the protectedFields strip set early so we can
  # refuse a highlight_field that's in it (ATLAS-4). Avoids
  # the awkward "we return objects but secretly drop their
  # highlights" state — fail loudly instead.
  protected_fields = Parse::CLPScope.protected_fields_for(
    collection_name, resolution.permission_strings,
  )
  assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)

  index_name = options[:index] || @default_index
  fields = normalize_fields(options[:fields])
  limit = options[:limit] || 100
  skip_val = options[:skip] || 0

  # Build the $search stage
  builder = SearchBuilder.new(index_name: index_name)

  if fields.present?
    fields.each do |field|
      builder.text(query: query, path: field, fuzzy: options[:fuzzy])
    end
  else
    builder.text(query: query, path: { "wildcard" => "*" }, fuzzy: options[:fuzzy])
  end

  if options[:highlight_field]
    builder.with_highlight(path: options[:highlight_field])
  end

  # CRITICAL: $search MUST be stage 0 of an Atlas Search
  # pipeline. MongoDB Atlas rejects pipelines whose first stage
  # is anything other than $search/$searchMeta. Do NOT route
  # through Parse::MongoDB.aggregate here — that helper prepends
  # the ACL $match to position 0, which Atlas would reject. We
  # build the pipeline manually with $search at index 0 and
  # place the ACL $match AFTER $search (which is correct: $search
  # has already produced its candidate set, the $match narrows it
  # to ACL-readable rows, then the caller filter narrows further).
  pipeline = [builder.build]

  # Add score projection
  pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }

  # Add highlights projection if requested
  if options[:highlight_field]
    pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } }
  end

  # Inject ACL $match BEFORE the caller-supplied filter (but AFTER
  # $search and the $addFields stages) so the user-controlled
  # filter cannot exfiltrate restricted documents that passed the
  # $search operator. The $exists: false branch in `read_predicate`
  # covers documents Parse Server treats as public (no _rperm).
  unless resolution.master?
    acl_match = Parse::ACLScope.match_stage_for(resolution)
    pipeline << acl_match if acl_match
  end

  # Add filter stage if provided
  if options[:filter]
    mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
    pipeline << { "$match" => mongo_filter }
  end

  # Add sort (default by score)
  sort_spec = options[:sort] || { "_score" => -1 }
  pipeline << { "$sort" => sort_spec }

  # Add pagination
  pipeline << { "$skip" => skip_val } if skip_val > 0
  pipeline << { "$limit" => limit }

  # Execute directly against the MongoDB collection — bypasses
  # Parse::MongoDB.aggregate so its ACL-prepend doesn't violate
  # the $search-at-stage-0 invariant. We're reproducing the
  # SDK-side enforcement chain (ACL match, protectedFields strip,
  # pointerFields filter, embedded sub-doc redaction) inline below.
  raw_results = run_atlas_pipeline!(
    collection_name, pipeline, options[:max_time_ms],
    read_preference: read_preference,
  )

  # Post-fetch enforcement: walk the result rows the same way
  # Parse::MongoDB.aggregate would. Master mode is the ACL bypass
  # — skip every redaction layer (matches the helper's behavior).
  unless resolution.master?
    Parse::ACLScope.redact_results!(raw_results, resolution)
    Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
    if pointer_fields
      raw_results = Parse::CLPScope.filter_by_pointer_fields(
        raw_results, pointer_fields, resolution.user_id,
      )
    end
    # ATLAS-4: drop any `_highlights` entry whose `path` names a
    # protected field. `searchHighlights` returns the matched
    # token plus its surrounding text, which would otherwise leak
    # the protected field's value through the snippet.
    strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any?
  end

  # Convert results
  class_name = options[:class_name] || collection_name
  process_search_results(raw_results, class_name, options[:raw])
end