Module: Parse::MongoDB

Defined in:
lib/parse/mongodb.rb

Overview

Note:

Requires the ‘mongo’ gem to be installed. Add to your Gemfile: gem ‘mongo’, ‘~> 2.18’

Direct MongoDB access module for bypassing Parse Server. Provides read-only direct access to MongoDB for performance-critical queries.

Field Name Conventions

When writing aggregation pipelines for direct MongoDB queries, use MongoDB’s native field naming conventions:

  • *Regular fields*: Use camelCase (e.g., releaseDate, playCount, firstName)

  • *Pointer fields*: Use p prefix (e.g., _p_author, _p_album)

  • *Built-in dates*: Use _created_at and _updated_at

  • *Field references*: Use $fieldName syntax (e.g., $releaseDate, $_p_author)

Results are automatically converted to Ruby-friendly format:

  • Field names converted to snake_case (totalPlaystotal_plays)

  • Custom aggregation results wrapped in AggregationResult for method access

  • Parse documents returned as proper Parse::Object instances

Date Comparisons

MongoDB stores dates in UTC. When comparing dates in aggregation pipelines:

  • Use Ruby Time objects for comparisons (automatically converted to BSON dates)

  • Ruby Date objects (without time) are stored as midnight UTC

  • For accurate date-only comparisons, use Time.utc(year, month, day)

Examples:

Enable direct MongoDB queries

Parse::MongoDB.configure(
  uri: "mongodb://localhost:27017/parse",
  enabled: true
)

Using direct queries

# Returns Parse objects, queried directly from MongoDB
songs = Song.query(:plays.gt => 1000).results_direct
first_song = Song.query(:plays.gt => 1000).first_direct

Aggregation pipeline with MongoDB field names

pipeline = [
  { "$match" => { "releaseDate" => { "$lt" => Time.now } } },
  { "$group" => { "_id" => "$_p_artist", "totalPlays" => { "$sum" => "$playCount" } } }
]
results = Song.query.aggregate(pipeline, mongo_direct: true).results

# Results use snake_case and support method access
results.first.total_plays  # => 5000
results.first["totalPlays"] # => 5000 (original key also works)

Date comparison in aggregation

# Compare with a specific UTC time
cutoff = Time.utc(2024, 1, 1, 0, 0, 0)
pipeline = [{ "$match" => { "releaseDate" => { "$gte" => cutoff } } }]

Using the date conversion helper

# Safely convert any date/time to MongoDB-compatible UTC Time
cutoff = Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 1))  # => Time UTC
cutoff = Parse::MongoDB.to_mongodb_date("2024-01-01")          # => Time UTC
cutoff = Parse::MongoDB.to_mongodb_date(Time.now)              # => Time UTC

Defined Under Namespace

Classes: ConnectionError, DeniedOperator, ExecutionTimeout, ForbiddenCollection, GemNotAvailable, MutationsDisabled, NotEnabled, WriterNotConfigured, WriterRoleTooPermissive

Constant Summary collapse

DEFAULT_FIND_LIMIT =

Threshold above which ‘Parse::MongoDB.find` emits a deprecation warning when called without an explicit `:limit` option. A future major release will enforce this as a hard default limit. Callers should pass an explicit `:limit` (including `:limit => 0` for unbounded) to silence the warning.

1000
ENV_URI_KEYS =

Environment variable names consulted (in priority order) when configure is called without an explicit ‘uri:` argument. `ANALYTICS_DATABASE_URI` is listed first so deployments can point direct-read traffic at a dedicated analytics replica without disturbing the primary `DATABASE_URI` that Parse Server uses for writes. `DATABASE_URI` is the fallback for deployments where the direct path reads from the same node as Parse Server.

%w[ANALYTICS_DATABASE_URI DATABASE_URI].freeze
MUTATION_ENV_KEY =

Environment variable consulted as part of the triple gate for index mutations. The check is performed on every call (not just at configure time) so a SIGHUP / process-supervisor that flips the variable can revoke without restart.

"PARSE_MONGO_INDEX_MUTATIONS"
PARSE_INTERNAL_CLASSES =

Parse-internal collections that must not receive index mutations without explicit ‘allow_system_classes: true`. A unique index on `_Session.session_token`, for example, would break auth on the first duplicate token write.

%w[
  _User _Role _Session _Installation _Audience _Idempotency
  _PushStatus _JobStatus _Hooks _GlobalConfig _SCHEMA
].freeze
WRITER_ALLOWED_ACTIONS =

Mongo privilege actions the writer role MAY hold. Anything outside this set causes configure_writer to refuse with WriterRoleTooPermissive. Reads are allowed; mutations are scoped to index management only.

The Atlas Search actions (‘createSearchIndexes`, `dropSearchIndex`, `updateSearchIndex`, `listSearchIndexes`) are included so a writer role provisioned for search-index management passes the privilege probe. Operators who do not grant those actions in their Mongo role simply cannot invoke the search-index primitives — the SDK allowlist does not auto-grant; it only refuses to reject roles that legitimately hold these specific actions.

%w[
  createIndex dropIndex
  createSearchIndexes dropSearchIndex updateSearchIndex listSearchIndexes
  listIndexes listCollections collStats
  find listDatabases connPoolStats serverStatus
].freeze
WRITE_ACTIONS =

MongoDB privilege “actions” that indicate write capability. Used by read_only? to classify the authenticated user’s role.

%w[
  insert update remove
  createCollection dropCollection
  createIndex dropIndex
  applyOps dropDatabase
  renameCollectionSameDB enableSharding
].freeze
DENIED_OPERATORS =
Deprecated.

Retained for backwards compatibility. The canonical list now lives in PipelineSecurity::DENIED_OPERATORS.

Parse::PipelineSecurity::DENIED_OPERATORS

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.clientMongo::Client (readonly)

Get or create the MongoDB client

Returns:

  • (Mongo::Client)

Raises:



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

def client
  @client
end

.databaseString

MongoDB database name (extracted from URI or set manually).

Returns:



203
204
205
# File 'lib/parse/mongodb.rb', line 203

def database
  @database
end

.enabledBoolean

Feature flag to enable/disable direct MongoDB queries.

Returns:

  • (Boolean)


193
194
195
# File 'lib/parse/mongodb.rb', line 193

def enabled
  @enabled
end

.index_mutations_enabledBoolean

Ruby-side gate (one of the three required for mutations). Default ‘false`. Must be flipped to `true` explicitly in code (typically in a rake task initializer, never in a web-process initializer).

Returns:

  • (Boolean)


433
434
435
# File 'lib/parse/mongodb.rb', line 433

def index_mutations_enabled
  @index_mutations_enabled
end

.uriString

MongoDB connection URI.

Returns:



198
199
200
# File 'lib/parse/mongodb.rb', line 198

def uri
  @uri
end

Class Method Details

.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) ⇒ Array<Hash>

Execute an aggregation pipeline directly on MongoDB

Parameters:

  • collection_name (String)

    the collection name

  • pipeline (Array<Hash>)

    the aggregation pipeline stages

  • max_time_ms (Integer, nil) (defaults to: nil)

    optional server-side time limit in milliseconds. When provided, MongoDB will cancel the query if it exceeds this budget and the driver error is translated to ExecutionTimeout. Pass nil (the default) for no cap.

  • rewrite_lookups (Boolean, nil) (defaults to: nil)

    when true (default ‘nil` – reads `Parse.rewrite_lookups`), auto-rewrite LLM-style $lookup stages against logical class names into the Parse-on-Mongo column form when the foreign class declares `parse_reference`.

  • allow_internal_fields (Boolean) (defaults to: false)

    when true, skip the internal-fields denylist check (e.g. for SDK-generated ACL filters produced by Query#readable_by_role and friends that legitimately reference _rperm/_wperm). The DENIED_OPERATORS walk, forensic-operator-in-+$expr+ check, and internal-field $-reference string check all still run. Passed true only from the SDK direct-execution sites that build their pipeline entirely from Query#compile_where: Parse::Query#results_direct, #first_direct (via results_direct), #count_direct, #distinct_direct, #atlas_search builder-block, and the two #group_by_* direct paths. The Agent MCP tool path and Aggregation#execute_direct! keep the default false so attacker-controlled or user-supplied aggregate stages cannot reach internal columns.

  • session_token (String, nil) (defaults to: nil)

    when provided, the SDK resolves the token to the requesting user + role membership (via AtlasSearch::Session) and prepends an ‘_rperm` `$match` stage to the pipeline so the result set simulates Parse Server’s row-level ACL enforcement. This path is the only ACL boundary on a mongo-direct call — the underlying Mongo connection is admin-credentialed at ‘Parse::MongoDB.configure` time, so the SDK is the enforcement layer. Mutually exclusive with `master:`.

  • master (Boolean, nil) (defaults to: nil)

    pass ‘true` to explicitly bypass the SDK’s row-ACL injection (analytics jobs, admin tooling that legitimately needs to read across users). Mutually exclusive with ‘session_token:`.

Returns:

  • (Array<Hash>)

    the raw results from MongoDB

Raises:



1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
# File 'lib/parse/mongodb.rb', line 1501

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)
  # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
  # call MUTATES the temporary kwargs hash (popping the auth
  # entries) before the resolution; we package them into a hash
  # here only so the shared helper can stay path-agnostic. The
  # hash is local and discarded after the call.
  auth_kwargs = {
    session_token: session_token,
    master: master,
    acl_user: acl_user,
    acl_role: acl_role,
  }.compact
  resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)

  # Validate BEFORE rewrite so the security denylist is applied to the
  # caller's original pipeline (which an attacker controls), not to
  # the gem-rewritten form (which it doesn't). Matches the ordering
  # used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate.
  assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields)

  # Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>`
  # reference that names a protectedField for the queried class
  # in the current scope. The post-fetch redact strips by NAME,
  # so a pipeline can launder a protected value through a
  # `$project: { renamed: "$ssn" }` (and similar) clauses and
  # bypass the strip silently. Catching the reference here at
  # parse-time refuses the join with `Parse::CLPScope::Denied`
  # so the bypass surfaces as an explicit error rather than a
  # quiet exfiltration. Master mode short-circuits inside the
  # scanner (no protected set on master).
  Parse::PipelineSecurity.refuse_protected_field_references!(
    pipeline, collection_name, resolution,
  )

  pipeline = Parse::LookupRewriter.auto_rewrite(
    pipeline, class_name: collection_name, enabled: rewrite_lookups,
  )

  # Three-layer ACL simulation on the mongo-direct path:
  #
  # 1. Top-level $match: filter the queried collection's rows by
  #    the session's _rperm allow-set. Mirrors Parse Server's
  #    REST find behavior.
  # 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup /
  #    $facet sub-pipeline gets the same _rperm filter embedded
  #    so joined rows from other collections are filtered at the
  #    database. Without this, includes/joins would silently leak
  #    rows the requesting session has no permission to read.
  # 3. Post-fetch redaction: walk the returned documents and
  #    scrub any embedded sub-documents whose stored _rperm
  #    doesn't match the perms set. Catches cases the rewriter
  #    can't reach (e.g., :object columns embedding raw pointer
  #    hashes, or caller-supplied $lookup stages that escaped
  #    rewriting because of unusual shapes).
  #
  # The security validator already ran on the caller's original
  # pipeline above; the injected stages reference `_rperm` but
  # are SDK-generated (not attacker-controlled), so no
  # re-validation is needed before they're handed to MongoDB.
  if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
    pipeline = [acl_stage] + pipeline
  end
  pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)

  # Class-Level Permissions boundary check. Parse Server's REST
  # aggregate endpoint runs master-key-only and does NOT enforce
  # CLP; the mongo-direct path bypasses Parse Server entirely so
  # the SDK is the only enforcement layer. Refuse the call when
  # the resolved scope can't `find` on the collection. Master-
  # key (resolution.master? / nil permission_strings) bypasses.
  perms_for_clp = resolution&.permission_strings
  unless resolution.nil? || resolution.master?
    unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
      raise Parse::CLPScope::Denied.new(
        collection_name, :find,
        "CLP refuses find on '#{collection_name}' for the current scope.",
      )
    end
  end

  # Resolve the pointerFields constraint (if any) BEFORE running
  # the query — we apply the filter post-fetch but want to fail
  # loudly when the scope can't satisfy the constraint at all
  # (acl_role-only / public agents have no user_id to match).
  pointer_fields = nil
  unless resolution.nil? || resolution.master?
    pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
    if pointer_fields && resolution.user_id.nil?
      raise Parse::CLPScope::Denied.new(
        collection_name, :find,
        "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
        "but the current scope has no user_id.",
      )
    end
  end

  agg_opts = {}
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
  coll = collection(collection_name)
  if (mode = normalize_read_preference(read_preference))
    coll = coll.with(read: { mode: mode })
  end
  results = coll.aggregate(pipeline, agg_opts).to_a
  Parse::ACLScope.redact_results!(results, resolution)

  # Post-fetch pointerFields filter: drop rows where none of the
  # named pointer fields references the requesting user. Skipped
  # for master-key and when the CLP has no pointerFields entry.
  if pointer_fields
    results = Parse::CLPScope.filter_by_pointer_fields(
      results, pointer_fields, resolution.user_id,
    )
  end

  # Protected fields stripping. Resolve the field set per the
  # session's claim composition and walk-delete from every
  # row + embedded sub-document. Top-level $project would also
  # work but doesn't reach inside `$lookup`-included sub-docs,
  # so the post-walker is the defense-in-depth layer.
  unless resolution.nil? || resolution.master?
    strip_set = Parse::CLPScope.protected_fields_for(
      collection_name, perms_for_clp,
    )
    Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
  end

  results
rescue => e
  raise_if_timeout!(e, collection_name, max_time_ms)
  raise
end

.assert_mutations_allowed!Object

Run all three gates. Returns nil on success; raises with a message naming the missing gate otherwise.



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'lib/parse/mongodb.rb', line 488

def assert_mutations_allowed!
  unless writer_configured?
    raise WriterNotConfigured,
          "Index mutations require Parse::MongoDB.configure_writer(uri: ...) " \
          "to be called with a write-capable Mongo role URI distinct from the reader."
  end
  unless @index_mutations_enabled == true
    raise MutationsDisabled,
          "Index mutations are disabled. Set Parse::MongoDB.index_mutations_enabled = true " \
          "explicitly (typically in a rake-task initializer, not in a web-process initializer)."
  end
  unless mutations_env_enabled?
    raise MutationsDisabled,
          "Index mutations require ENV[#{MUTATION_ENV_KEY.inspect}] == '1'. " \
          "Set this only in environments where index mutations are intended " \
          "(rake tasks, maintenance scripts), never on web/worker dynos."
  end
  nil
end

.assert_no_denied_operators!(node, allow_internal_fields: false) ⇒ Object

Walk a filter hash or aggregation pipeline (Hash or Array) and raise DeniedOperator if any nested key matches an entry in PipelineSecurity::DENIED_OPERATORS.

Public for testability and for callers that want to validate input before forwarding to find / aggregate.

Parameters:



2286
2287
2288
2289
2290
2291
# File 'lib/parse/mongodb.rb', line 2286

def assert_no_denied_operators!(node, allow_internal_fields: false)
  Parse::PipelineSecurity.validate_filter!(node, allow_internal_fields: allow_internal_fields)
  nil
rescue Parse::PipelineSecurity::Error => e
  raise DeniedOperator, e.message
end

.available?Boolean

Check if direct MongoDB queries are available and enabled

Returns:

  • (Boolean)


287
288
289
# File 'lib/parse/mongodb.rb', line 287

def available?
  gem_available? && enabled? && uri.present?
end

.collection(name) ⇒ Mongo::Collection

Get a MongoDB collection

Parameters:

  • name (String)

    the collection name

Returns:

  • (Mongo::Collection)


393
394
395
# File 'lib/parse/mongodb.rb', line 393

def collection(name)
  client[name]
end

.configure(uri: nil, enabled: true, database: nil, verify_role: true) ⇒ Object

Configure direct MongoDB access.

When ‘uri:` is omitted, the value is resolved from the first environment variable in ENV_URI_KEYS that is set (so `ANALYTICS_DATABASE_URI` wins over `DATABASE_URI`). Raises `ArgumentError` if neither argument nor any env var supplied a URI.

Examples:

Explicit URI

Parse::MongoDB.configure(
  uri: "mongodb://user:pass@localhost:27017/parse?authSource=admin",
  enabled: true
)

Env-var resolution (ANALYTICS_DATABASE_URI preferred,

falls back to DATABASE_URI)
Parse::MongoDB.configure(enabled: true)

Parameters:

  • uri (String, nil) (defaults to: nil)

    MongoDB connection URI. When nil, falls back to env-var resolution.

  • enabled (Boolean) (defaults to: true)

    whether to enable direct queries (default: true)

  • database (String, nil) (defaults to: nil)

    database name (optional, extracted from URI if not provided)

  • verify_role (Boolean) (defaults to: true)

    when true (the default), run a ‘connectionStatus` role check after configuring and emit a warning if the authenticated user appears to have write privileges. The direct path is read-only; a writeable role means a bug in the gem (or in caller code touching `Parse::MongoDB.client` directly) could write through it. Set to false to skip the check (no connection attempt during configure).

Raises:

  • (ArgumentError)

    if no URI can be resolved



260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/parse/mongodb.rb', line 260

def configure(uri: nil, enabled: true, database: nil, verify_role: true)
  require_gem!
  resolved = uri || resolve_uri_from_env
  if resolved.nil? || resolved.to_s.empty?
    raise ArgumentError,
          "Parse::MongoDB.configure requires a `uri:` argument or one of " \
          "#{ENV_URI_KEYS.join(", ")} set in the environment."
  end
  @uri = resolved
  @enabled = enabled
  @database = database || extract_database_from_uri(resolved)
  @client = nil # Reset client on reconfigure
  warn_if_writeable_role! if verify_role && enabled
end

.configure_writer(uri:, enabled: true, verify_role: true) ⇒ Object

Configure the writer connection used for index mutations. Opens a second ‘Mongo::Client` against `uri:`. The connection is validated via `connectionStatus` and rejected fail-closed if its role grants destructive privileges (insert/update/remove/ dropCollection/dropDatabase/etc.). The client is stored privately and is not exposed through any public accessor.

Parameters:

  • uri (String)

    writer URI, must be distinct from the reader ‘@uri`. Typically points at the same replica set with a different Mongo user holding only `createIndex`/`dropIndex` privileges.

  • enabled (Boolean) (defaults to: true)

    when false, ‘configure_writer` records the URI but does NOT open the connection. Use this to lay wiring in code without activating the writer until a separate call sets `Parse::MongoDB.index_mutations_enabled = true`.

  • verify_role (Boolean) (defaults to: true)

    when true (default), run the privilege check on the configured user and raise WriterRoleTooPermissive if it exceeds WRITER_ALLOWED_ACTIONS. Disable only in test fixtures.

Raises:

  • (ArgumentError)

    when ‘uri:` is missing or matches the reader URI verbatim.

  • (WriterRoleTooPermissive)

    when the role check fails.



456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/parse/mongodb.rb', line 456

def configure_writer(uri:, enabled: true, verify_role: true)
  require_gem!
  raise ArgumentError, "configure_writer requires a uri:" if uri.nil? || uri.to_s.empty?
  if @uri && @uri.to_s == uri.to_s
    raise ArgumentError,
          "configure_writer URI must differ from the reader URI. " \
          "The writer is meant for a separately-credentialed Mongo role."
  end
  @writer_uri = uri
  @writer_enabled = enabled
  @writer_client&.close rescue nil
  @writer_client = nil
  if enabled
    # Eagerly open so a misconfigured URI fails fast at configure time.
    assert_writer_role_acceptable! if verify_role
  end
end

.convert_aggregation_document(doc) ⇒ Hash

Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds, dates, nested documents) but preserving all field names including _id. Unlike convert_document_to_parse, this does NOT rename _id to objectId, because aggregation $group stages reuse _id as the group key (e.g. a pointer string like “Team$abc”) rather than as a Parse object identifier.

Parameters:

  • doc (Hash)

    a raw MongoDB aggregation result row

Returns:

  • (Hash)

    the coerced hash with stringified keys



1989
1990
1991
1992
1993
1994
# File 'lib/parse/mongodb.rb', line 1989

def convert_aggregation_document(doc)
  return nil unless doc.is_a?(Hash)
  doc.each_with_object({}) do |(key, value), result|
    result[key.to_s] = convert_value_to_parse(value)
  end
end

.convert_document_to_parse(doc, class_name = nil) ⇒ Hash

Convert a MongoDB document to Parse REST API format This transforms MongoDB’s internal field names to Parse’s format:

  • _id -> objectId

  • _created_at -> createdAt

  • _updated_at -> updatedAt

  • _p_fieldName -> fieldName (as pointer)

  • _acl -> ACL (with r/w converted to read/write)

  • Removes other internal fields (_rperm, _wperm, _hashed_password, etc.)

Parameters:

  • doc (Hash)

    the MongoDB document

  • class_name (String) (defaults to: nil)

    the Parse class name

Returns:

  • (Hash)

    the Parse-formatted hash



1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
# File 'lib/parse/mongodb.rb', line 1906

def convert_document_to_parse(doc, class_name = nil)
  return nil unless doc.is_a?(Hash)

  result = {}

  doc.each do |key, value|
    key_str = key.to_s

    case key_str
    when "_id"
      # MongoDB _id becomes Parse objectId
      # Guard against BSON::ObjectId not being defined when mongo gem is not loaded
      result["objectId"] = if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId)
          value.to_s
        else
          value
        end
    when "_created_at"
      # MongoDB _created_at becomes Parse createdAt
      result["createdAt"] = convert_date_to_parse(value)
    when "_updated_at"
      # MongoDB _updated_at becomes Parse updatedAt
      result["updatedAt"] = convert_date_to_parse(value)
    when /^_p_(.+)$/
      # Pointer fields: _p_author -> author
      field_name = $1
      result[field_name] = convert_pointer_to_parse(value)
    when "_acl"
      # Convert MongoDB ACL format (r/w) to Parse format (read/write)
      result["ACL"] = convert_acl_to_parse(value)
    when /^_included_(.+)$/
      # Included/resolved pointer field from $lookup - convert embedded document
      # This handles eager loading: _included_artist -> artist (as full object)
      field_name = $1
      if value.is_a?(Hash)
        # Recursively convert the embedded document to Parse format
        result[field_name] = convert_document_to_parse(value)
      elsif value.nil?
        # Preserve nil for unresolved optional relationships
        result[field_name] = nil
      else
        result[field_name] = value
      end
    when /^_include_id_/
      # Skip temporary lookup ID fields (used internally for $lookup)
      next
    when "_rperm", "_wperm", "_hashed_password", "_email_verify_token",
         "_perishable_token", "_tombstone", "_failed_login_count",
         "_account_lockout_expires_at", "_session_token"
      # Skip internal Parse Server fields (not needed since we use _acl)
      next
    when /^_/
      # Skip other internal fields starting with underscore
      next
    else
      # Regular fields - recursively convert nested documents
      result[key_str] = convert_value_to_parse(value)
    end
  end

  # Add className if provided
  result["className"] = class_name if class_name

  result
end

.convert_documents_to_parse(docs, class_name = nil) ⇒ Array<Hash>

Convert multiple MongoDB documents to Parse format

Parameters:

  • docs (Array<Hash>)

    the MongoDB documents

  • class_name (String) (defaults to: nil)

    the Parse class name

Returns:

  • (Array<Hash>)

    the Parse-formatted hashes



1976
1977
1978
# File 'lib/parse/mongodb.rb', line 1976

def convert_documents_to_parse(docs, class_name = nil)
  docs.map { |doc| convert_document_to_parse(doc, class_name) }
end

.create_index(collection_name, keys, name: nil, unique: false, sparse: false, partial_filter: nil, expire_after: nil, allow_system_classes: false) ⇒ Symbol

Create an index on the named collection. Triple-gated; refuses Parse-internal collections unless ‘allow_system_classes: true`. Idempotent: if an index with identical key+options already exists, returns `:exists` without issuing the create.

Parameters:

  • collection_name (String)

    target collection / Parse class

  • keys (Hash{String,Symbol => Integer,String})

    index key spec. Values are ‘1` (asc), `-1` (desc), `“2dsphere”`, `“text”`, `“hashed”`.

  • name (String, nil) (defaults to: nil)

    optional index name. When nil, Mongo generates ‘field_dir_field_dir` automatically.

  • unique (Boolean) (defaults to: false)

    uniqueness constraint.

  • sparse (Boolean) (defaults to: false)

    sparse index (skip docs missing the key).

  • partial_filter (Hash, nil) (defaults to: nil)

    partial index filter expression.

  • expire_after (Integer, nil) (defaults to: nil)

    TTL in seconds.

  • allow_system_classes (Boolean) (defaults to: false)

    opt-in to mutate Parse-internal collections (‘_User`, `_Role`, etc.). Default false. Audit-logged.

Returns:

  • (Symbol)

    ‘:created` on success, `:exists` when an identically-specified index was already present.

Raises:



536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'lib/parse/mongodb.rb', line 536

def create_index(collection_name, keys, name: nil, unique: false, sparse: false,
                 partial_filter: nil, expire_after: nil, allow_system_classes: false)
  assert_mutations_allowed!
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  spec_keys = normalize_index_keys(keys)
  existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
  if index_matches?(existing, spec_keys, name: name, unique: unique, sparse: sparse,
                    partial_filter: partial_filter, expire_after: expire_after)
    audit_writer_event(:create_index_skipped, collection_name, keys: spec_keys, name: name)
    return :exists
  end
  opts = build_index_options(name: name, unique: unique, sparse: sparse,
                             partial_filter: partial_filter, expire_after: expire_after)
  audit_writer_event(:create_index, collection_name, keys: spec_keys, name: name, opts: opts)
  writer_collection(collection_name).indexes.create_one(spec_keys, **opts)
  :created
end

.create_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol

Create an Atlas Search index. Triple-gated like create_index; refuses Parse-internal collections unless ‘allow_system_classes: true`. Idempotent on name: if a search index with the same name already exists, returns `:exists` without issuing the create. The mapping definition of the existing index is NOT diffed — use update_search_index to change a definition.

The build runs ASYNCHRONOUSLY on the Atlas Search node. This method returns as soon as the command is accepted; the index is not queryable until its status transitions to ‘READY`. Poll AtlasSearch::IndexManager.index_ready? to confirm.

Parameters:

  • collection_name (String)

    target collection / Parse class

  • name (String)

    the search index name. Must match ‘/A[A-Za-z0-9_-]0,63z/`.

  • definition (Hash)

    the search index definition (e.g. ‘{ mappings: { dynamic: true } }`). String/symbol keys both accepted; converted to string keys before submission.

  • allow_system_classes (Boolean) (defaults to: false)

    opt-in to mutate Parse- internal collections. Default false. Audit-logged.

Returns:

  • (Symbol)

    ‘:created` on submission, `:exists` when a search index with that name already exists.

Raises:



663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/parse/mongodb.rb', line 663

def create_search_index(collection_name, name, definition, allow_system_classes: false)
  assert_mutations_allowed!
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  validate_search_index_name!(name)
  validate_search_index_definition!(definition)
  existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
  if existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
    audit_writer_event(:create_search_index_skipped, collection_name, name: name)
    return :exists
  end
  audit_writer_event(:create_search_index, collection_name, name: name)
  writer_client.database.command(
    createSearchIndexes: collection_name.to_s,
    indexes: [{ name: name.to_s, definition: stringify_keys_deep(definition) }],
  )
  :created
end

.drop_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol

Drop a named index. Requires the operator-supplied ‘confirm:` string to match `“drop:#collection:#name”` so a stale shell session against the wrong environment can’t accidentally drop something via a rerun.

Parameters:

  • collection_name (String)

    target collection

  • name (String)

    index name to drop

  • confirm (String)

    must equal ‘“drop:#collection_name:#name”`

  • allow_system_classes (Boolean) (defaults to: false)

    opt-in for Parse-internal

Returns:

  • (Symbol)

    ‘:dropped` on success, `:absent` when the index did not exist (idempotent).



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
# File 'lib/parse/mongodb.rb', line 565

def drop_index(collection_name, name, confirm:, allow_system_classes: false)
  assert_mutations_allowed!
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  expected = "drop:#{collection_name}:#{name}"
  unless confirm.to_s == expected
    raise ArgumentError,
          "drop_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
          "to drop #{name.inspect} from #{collection_name.inspect}."
  end
  existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
  unless existing.any? { |i| (i["name"] || i[:name]) == name }
    audit_writer_event(:drop_index_absent, collection_name, name: name)
    return :absent
  end
  audit_writer_event(:drop_index, collection_name, name: name)
  writer_collection(collection_name).indexes.drop_one(name)
  :dropped
end

.drop_search_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol

Drop a named Atlas Search index. Requires the operator-supplied ‘confirm:` string to match `“drop_search:#collection:#name”`. The token deliberately differs from drop_index’s ‘“drop:”` prefix so a token meant for a regular index cannot be replayed against a search index with the same name (and vice versa).

The drop is asynchronous on the Atlas Search node but typically completes quickly; the local cache in AtlasSearch::IndexManager should be invalidated by the caller (the IndexManager wrapper does this).

Parameters:

  • collection_name (String)

    target collection

  • name (String)

    search index name to drop

  • confirm (String)

    must equal ‘“drop_search:#collection_name:#name”`

  • allow_system_classes (Boolean) (defaults to: false)

    opt-in for Parse-internal

Returns:

  • (Symbol)

    ‘:dropped` on success, `:absent` when no such search index existed (idempotent).

Raises:



700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
# File 'lib/parse/mongodb.rb', line 700

def drop_search_index(collection_name, name, confirm:, allow_system_classes: false)
  assert_mutations_allowed!
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  expected = "drop_search:#{collection_name}:#{name}"
  unless confirm.to_s == expected
    raise ArgumentError,
          "drop_search_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
          "to drop search index #{name.inspect} from #{collection_name.inspect}."
  end
  existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
  unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
    audit_writer_event(:drop_search_index_absent, collection_name, name: name)
    return :absent
  end
  audit_writer_event(:drop_search_index, collection_name, name: name)
  writer_client.database.command(
    dropSearchIndex: collection_name.to_s,
    name: name.to_s,
  )
  :dropped
end

.enabled?Boolean

Check if direct queries are enabled

Returns:

  • (Boolean)


293
294
295
# File 'lib/parse/mongodb.rb', line 293

def enabled?
  @enabled == true
end

.find(collection_name, filter = {}, **options) ⇒ Array<Hash>

Execute a find query directly on MongoDB

Parameters:

  • collection_name (String)

    the collection name

  • filter (Hash) (defaults to: {})

    the query filter

  • options (Hash)

    additional options (limit, skip, sort, projection, max_time_ms). When :limit is omitted, DEFAULT_FIND_LIMIT is applied before the cursor is materialized and a warning is emitted if the cap is hit. Pass ‘limit: 0` to explicitly request unbounded behavior. When :max_time_ms is provided, MongoDB will cancel the query if it exceeds the budget; the driver error is translated to ExecutionTimeout.

Returns:

  • (Array<Hash>)

    the raw results from MongoDB

Raises:



1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
# File 'lib/parse/mongodb.rb', line 1770

def find(collection_name, filter = {}, **options)
  allow_internal_fields = options.delete(:allow_internal_fields) || false
  assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
  max_time_ms = options.delete(:max_time_ms)
  cursor = collection(collection_name).find(filter)
  explicit_limit = options.key?(:limit)
  applied_default_limit = false

  if explicit_limit
    cursor = cursor.limit(options[:limit]) if options[:limit] > 0
  else
    # Apply the hard default BEFORE to_a so we never materialize an
    # unbounded result set. Fetch one extra row so we can detect when
    # callers hit the cap and warn them.
    cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1)
    applied_default_limit = true
  end

  cursor = cursor.skip(options[:skip]) if options[:skip]
  cursor = cursor.sort(options[:sort]) if options[:sort]
  cursor = cursor.projection(options[:projection]) if options[:projection]
  cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
  results = cursor.to_a

  if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
    # Trim the sentinel row and warn — the caller asked for everything
    # but the result set is larger than the safety cap.
    results = results.first(DEFAULT_FIND_LIMIT)
    warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \
         "#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \
         "explicit :limit to control the size, or :limit => 0 for " \
         "unbounded behavior."
  end

  results
rescue => e
  raise_if_timeout!(e, collection_name, max_time_ms)
  raise
end

.gem_available?Boolean

Check if the mongo gem is available

Returns:

  • (Boolean)

    true if mongo gem is loaded



212
213
214
215
216
217
218
219
220
# File 'lib/parse/mongodb.rb', line 212

def gem_available?
  return @gem_available if defined?(@gem_available)
  @gem_available = begin
      require "mongo"
      true
    rescue LoadError
      false
    end
end

.geo_near(collection_name, near:, distance_field: "distance", max_distance: nil, min_distance: nil, unit: :meters, spherical: true, query: nil, include_locs: nil, key: nil, distance_multiplier: nil, limit: nil, additional_stages: [], max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil) ⇒ Array<Hash>

Execute a ‘$geoNear` aggregation against a collection, returning documents sorted by proximity to `near` along with their computed distance. `$geoNear` is the aggregation-pipeline analogue of `$nearSphere`; the headline differences are that it emits the distance value on each returned doc (`distance_field:`) and that downstream pipeline stages can compose with the proximity sort.

A ‘2dsphere` index on the queried geo field is required; the operation errors loudly without one (no silent collection scan). `$geoNear` must be the first stage in the pipeline — Parse::MongoDB places it correctly. The Mongo default 100-document cap was removed in recent server versions, so pass an explicit `limit:` whenever the caller would otherwise drain the entire collection.

Examples:

center = Parse::GeoPoint.new(32.7157, -117.1611)
Parse::MongoDB.geo_near("Place",
  near: center,
  max_distance: 5,
  unit: :km,
  query: { category: "Park" },
  limit: 25,
)
# Each result document carries a `dist.calculated` field (meters).

Parameters:

  • collection_name (String)

    the MongoDB collection name. Use ‘klass.parse_class` when starting from a Parse::Object subclass.

  • near (Parse::GeoPoint, Hash, Array)

    the anchor point. Accepts a GeoPoint, a GeoJSON ‘Point` Hash, or a `[longitude, latitude]` Array. Modern Mongo (8.0+) strictly validates GeoJSON-shaped input, so GeoPoint is preferred.

  • distance_field (String) (defaults to: "distance")

    output field name on each result document for the computed distance. Dot notation is permitted (e.g. ‘“dist.calculated”`). Defaults to `“distance”`.

  • max_distance (Numeric, nil) (defaults to: nil)

    inclusive upper bound on distance. With a 2dsphere index, the wire unit is meters; pass ‘unit:` to convert from km or miles. With a legacy 2d index the wire unit is radians (advanced; caller’s burden).

  • min_distance (Numeric, nil) (defaults to: nil)

    inclusive lower bound, same unit semantics as ‘max_distance`.

  • unit (Symbol) (defaults to: :meters)

    one of ‘:meters` (default), `:km` / `:kilometers`, `:miles`. Converts the user-supplied `max_distance` and `min_distance` to meters before serializing.

  • spherical (Boolean) (defaults to: true)

    use spherical geometry. Defaults to ‘true` — the conventional pairing with 2dsphere + GeoJSON. Set to `false` only when querying a legacy planar 2d index.

  • query (Hash, nil) (defaults to: nil)

    additional filter applied to candidate documents. Cannot contain a ‘$near` predicate (Mongo rejects).

  • include_locs (String, nil) (defaults to: nil)

    when set, the matched location value is added to each result under this field name. Useful for documents that may hold multiple geo fields.

  • key (String, nil) (defaults to: nil)

    explicit geo field path. Required when the collection has multiple geo indexes; otherwise Mongo picks the unique 2d/2dsphere index automatically.

  • distance_multiplier (Numeric, nil) (defaults to: nil)

    post-computation scalar applied to every returned distance. The 2dsphere + meters path typically does not need this; legacy 2d callers can pass an Earth-radius constant to convert radians to km/miles.

  • limit (Integer, nil) (defaults to: nil)

    when provided, appends a ‘$limit` stage. The Mongo default 100-doc cap is no longer applied automatically — set `limit:` (or pass `:limit => 0` to mean “unbounded; I really mean it”) to control the size.

  • additional_stages (Array<Hash>) (defaults to: [])

    extra pipeline stages to append after ‘$geoNear` (and after `$limit` if any). Useful for `$lookup` joins, `$project` field shaping, etc. Each stage passes through the standard security validation.

  • max_time_ms (Integer, nil) (defaults to: nil)

    server-side time limit; same semantics as aggregate.

Returns:

  • (Array<Hash>)

    documents enriched with ‘distance_field` (and `include_locs` when requested), in nearest-first order.

Raises:



1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
# File 'lib/parse/mongodb.rb', line 1709

def geo_near(collection_name,
             near:,
             distance_field: "distance",
             max_distance: nil,
             min_distance: nil,
             unit: :meters,
             spherical: true,
             query: nil,
             include_locs: nil,
             key: nil,
             distance_multiplier: nil,
             limit: nil,
             additional_stages: [],
             max_time_ms: nil,
             session_token: nil,
             master: nil,
             acl_user: nil,
             acl_role: nil,
             read_preference: nil)
  stage = { :$geoNear => {
    near: geojson_point_for(near),
    distanceField: distance_field.to_s,
    spherical: spherical ? true : false,
  } }

  max_meters = convert_distance_to_meters(max_distance, unit) if max_distance
  min_meters = convert_distance_to_meters(min_distance, unit) if min_distance
  stage[:$geoNear][:maxDistance] = max_meters if max_meters
  stage[:$geoNear][:minDistance] = min_meters if min_meters
  stage[:$geoNear][:query] = query if query.is_a?(Hash) && !query.empty?
  stage[:$geoNear][:includeLocs] = include_locs.to_s if include_locs
  stage[:$geoNear][:key] = key.to_s if key
  stage[:$geoNear][:distanceMultiplier] = distance_multiplier if distance_multiplier

  pipeline = [stage]
  pipeline << { :$limit => limit } if limit && limit > 0
  pipeline.concat(Array(additional_stages))

  aggregate(collection_name, pipeline,
            max_time_ms: max_time_ms,
            session_token: session_token,
            master: master,
            acl_user: acl_user,
            acl_role: acl_role,
            read_preference: read_preference)
end

.index_stats(collection_name, master: false) ⇒ Hash{String => Hash}

Per-index usage statistics via the ‘$indexStats` aggregation stage. Returns a Hash keyed by index name with `since:` for each — `ops` is the number of times the index has been accessed since the last MongoDB restart, `since` is the timestamp of that restart (i.e. the start of the counting window). Empty Hash on access error so callers (e.g. `Model.describe(:indexes, network: true, usage: true)`) degrade gracefully when the authenticated role lacks `clusterMonitor` (the minimum privilege `$indexStats` requires).

Admin-only. This is a metadata-disclosure surface (which indexes are hot fingerprints which classes hold interesting data) and so requires explicit ‘master: true` to invoke. The previous behavior hard-coded `master: true` internally, which was a copy-paste-lethal pattern for any future row-returning path. Callers without master scope raise `ArgumentError` internally; that error is caught by the method’s own degrade-to-empty rescue so existing best-effort callers (‘Parse::Model.describe(:indexes, usage: true)`) continue to surface `usage_available: false` instead of blowing up — but the `ArgumentError` is the loud signal for anyone introducing a new caller that forgets the opt-in. Direct callers that disable the rescue (test mocks, callers wrapping with their own error handling) will see the `ArgumentError` propagate.

Parameters:

  • collection_name (String)
  • master (Boolean) (defaults to: false)

    explicit master-mode opt-in. Required.

Returns:

  • (Hash{String => Hash})

    ‘{ index_name => { ops:, since: } }`, or `{}` when called without `master: true` (degrade-to-empty rescue).



1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
# File 'lib/parse/mongodb.rb', line 1870

def index_stats(collection_name, master: false)
  unless master == true
    raise ArgumentError,
          "Parse::MongoDB.index_stats is admin-only and requires `master: true`. " \
          "$indexStats discloses cluster metadata; pass `master: true` to confirm " \
          "the caller is authorized. Callers without master scope (e.g. agent " \
          "tools, request handlers) must not invoke this method."
  end
  results = aggregate(collection_name, [{ "$indexStats" => {} }], master: true)
  results.each_with_object({}) do |row, h|
    name = row["name"] || row[:name]
    next unless name
    accesses = row["accesses"] || row[:accesses] || {}
    h[name] = {
      ops:   (accesses["ops"] || accesses[:ops]).to_i,
      since: accesses["since"] || accesses[:since],
    }
  end
rescue StandardError
  # Lack of clusterMonitor / Atlas BI restriction / NamespaceNotFound
  # all surface here — `usage:` is best-effort by design.
  {}
end

.indexes(collection_name) ⇒ Array<Hash>

List regular MongoDB indexes for a collection. Hits the system catalog via the driver’s ‘indexes.list` and returns the raw definitions — distinct from list_search_indexes, which only enumerates Atlas Search indexes. Operator-facing introspection used by `Parse::Core::Describe`.

Parameters:

  • collection_name (String)

    the Parse collection / class name

Returns:

  • (Array<Hash>)

    each entry includes at least ‘“name”` and `“key”` (`{ field => 1 | -1 | “text” | “2dsphere” }`), plus driver-reported flags like `“unique”`, `“sparse”`, `“partialFilterExpression”`, and `“expireAfterSeconds”` when set.



1830
1831
1832
1833
1834
1835
1836
1837
1838
# File 'lib/parse/mongodb.rb', line 1830

def indexes(collection_name)
  collection(collection_name).indexes.to_a
rescue StandardError => e
  # `listIndexes` raises NamespaceNotFound on collections that
  # haven't been created yet — treat as "no indexes" so describe
  # and plan paths degrade gracefully on empty databases.
  return [] if mongo_namespace_not_found?(e)
  raise
end

.list_search_indexes(collection_name) ⇒ Array<Hash>

Note:

Requires MongoDB Atlas or local Atlas deployment

List Atlas Search indexes for a collection Uses the $listSearchIndexes aggregation stage.

Parameters:

  • collection_name (String)

    the collection name

Returns:

  • (Array<Hash>)

    array of search index definitions



1815
1816
1817
# File 'lib/parse/mongodb.rb', line 1815

def list_search_indexes(collection_name)
  aggregate(collection_name, [{ "$listSearchIndexes" => {} }])
end

.mutations_env_enabled?Boolean

Returns true iff ‘ENV == “1”`.

Returns:

  • (Boolean)

    true iff ‘ENV == “1”`.



481
482
483
# File 'lib/parse/mongodb.rb', line 481

def mutations_env_enabled?
  ENV[MUTATION_ENV_KEY].to_s == "1"
end

.normalize_read_preference(value) ⇒ Symbol?

Normalize a Parse-style read-preference value into the Mongo Ruby driver’s ‘:mode` symbol. Accepts `nil` (returns `nil`), the five documented Parse strings (`PRIMARY`, `PRIMARY_PREFERRED`, `SECONDARY`, `SECONDARY_PREFERRED`, `NEAREST`) in any case with hyphens or underscores, and the equivalent symbol form. Unknown values produce a warning and return `nil` so the operation falls back to the client default rather than failing.

Parameters:

Returns:



406
407
408
409
410
411
412
413
414
415
# File 'lib/parse/mongodb.rb', line 406

def normalize_read_preference(value)
  return nil if value.nil?
  token = value.to_s.tr("-", "_").downcase
  valid = %w[primary primary_preferred secondary secondary_preferred nearest].freeze
  unless valid.include?(token)
    warn "[Parse::MongoDB] Invalid read_preference #{value.inspect}; ignoring."
    return nil
  end
  token.to_sym
end

.read_only?Boolean?

Probe whether the authenticated user on the configured URI has any write privileges. Issues the ‘connectionStatus` command with `showPrivileges: true` — a read-only call that returns the user’s role-derived privilege list.

Return values:

  • ‘true` — user’s privileges include no entries from WRITE_ACTIONS on the configured database. The role is observable read-only.

  • ‘false` — at least one write action was found.

  • ‘nil` — couldn’t determine (no privilege list returned, command not supported, network failure). Treat as “unknown” — don’t trust either answer.

Caveats:

  • This is a ROLE check, not a transport check. A ‘readPreference= secondary` URI with a write-capable user is still write-capable; the driver routes writes to primary regardless of read preference.

  • Some MongoDB configurations restrict the user’s visibility into their own privileges; an empty privilege list returns ‘nil`, not `true`.

  • Atlas Data Federation, BI Connector, and other non-standard endpoints may respond differently or refuse the command — also ‘nil`.

Returns:

  • (Boolean, nil)


332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/parse/mongodb.rb', line 332

def read_only?
  return nil unless available?
  result = client.database.command(connectionStatus: 1, showPrivileges: true).first
  privileges = result && result.dig("authInfo", "authenticatedUserPrivileges")
  return nil if privileges.nil? || privileges.empty?
  write_set = WRITE_ACTIONS.to_set
  has_write = privileges.any? do |priv|
    Array(priv["actions"]).any? { |a| write_set.include?(a.to_s) }
  end
  !has_write
rescue StandardError
  nil
end

.require_gem!Object

Ensure mongo gem is loaded, raise error if not

Raises:



224
225
226
227
228
229
# File 'lib/parse/mongodb.rb', line 224

def require_gem!
  return if gem_available?
  raise GemNotAvailable,
    "The 'mongo' gem is required for direct MongoDB queries. " \
    "Add 'gem \"mongo\"' to your Gemfile and run 'bundle install'."
end

.reset!Object

Reset the client connection (useful for testing)



381
382
383
384
385
386
387
388
# File 'lib/parse/mongodb.rb', line 381

def reset!
  @client&.close rescue nil
  @client = nil
  @enabled = false
  @uri = nil
  @database = nil
  reset_writer!
end

.reset_writer!Object

Reset the writer connection and clear gate state. Called from reset!; can be invoked directly for granular teardown.



510
511
512
513
514
515
# File 'lib/parse/mongodb.rb', line 510

def reset_writer!
  @writer_client&.close rescue nil
  @writer_client = nil
  @writer_uri = nil
  @writer_enabled = false
end

.resolve_uri_from_envString?

Returns the first env-var URI found, in ENV_URI_KEYS priority order, or nil if none is set.

Returns:

  • (String, nil)

    the first env-var URI found, in ENV_URI_KEYS priority order, or nil if none is set.



277
278
279
280
281
282
283
# File 'lib/parse/mongodb.rb', line 277

def resolve_uri_from_env
  ENV_URI_KEYS.each do |key|
    value = ENV[key]
    return value if value && !value.empty?
  end
  nil
end

.role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?

Resolve every role name a user inherits via a single ‘$graphLookup` aggregation against the Parse role-membership and role-inheritance join tables.

This is the mongo-direct fast path that Role.all_for_user falls into when an explicit authorization scope is provided. The pipeline shape is hardcoded; only ‘user_id` and `max_depth` are interpolated, and both are validated against ROLE_GRAPH_ID_RE / ROLE_GRAPH_MAX_DEPTH.

The call bypasses aggregate on purpose: that entry point injects an ‘_rperm` `$match` and rewrites `$lookup` / `$graphLookup` stages with the same predicate, which would filter every `_Join:*:_Role` row to zero (those join collections have no `_rperm` column). PipelineSecurity.validate_filter! still runs against the constructed pipeline as belt-and-braces protection against a future regression that interpolates a caller value into a denied operator.

If ‘_Join:roles:_Role` doesn’t exist (the app uses flat roles without inheritance), MongoDB treats the missing collection as empty and ‘$graphLookup` returns no parents — the result collapses to direct memberships only, matching the Parse-Server-backed walk.

## Authorization contract

The helper requires an EXPLICIT per-call authorization:

* `master: true` — explicit master-mode opt-in. Bypasses
  `_Role` CLP. Use for admin tooling, analytics jobs, and
  any code path that legitimately needs to read role graphs
  across users.

* `as: <User|Pointer>` — caller scope. The supplied user must
  be permitted to `find` on `_Role` under the cached CLP, or
  {Parse::CLPScope::Denied} is raised. `_Role`'s default CLP
  is master-only, so this path will fail closed unless the
  operator has explicitly opened `_Role` CLP for the user.

Passing neither raises ‘ArgumentError`. The previous behavior (gated only on the process-level `master_key_available?` boolean — a check on the SDK’s boot config, not the caller’s authority) is removed — it provided no per-call authorization.

## Return-value contract

  • ‘Set<String>` on success (possibly empty if the user has no direct memberships).

  • ‘nil` when the fast path is unavailable (mongo gem missing, available? false). Callers fall back to the Parse-Server N+1 walk.

  • Raises ExecutionTimeout on Mongo timeout (attack-signal — do not silently fall back), ‘ArgumentError` on input-validation failure or missing authorization, and propagates other `Mongo::Error` subclasses that aren’t recognized as benign availability errors.

Parameters:

  • user_id (String)

    a Parse ‘_User.objectId`.

  • max_depth (Integer) (defaults to: ROLE_GRAPH_DEFAULT_DEPTH)

    BFS depth bound. See ROLE_GRAPH_DEFAULT_DEPTH for the default and ROLE_GRAPH_MAX_DEPTH for the upper bound.

  • master (Boolean) (defaults to: false)

    when ‘true`, bypass `_Role` CLP. Mutually exclusive with `as:`.

  • as (Parse::User, Parse::Pointer, nil) (defaults to: nil)

    caller-scope user. When provided (and ‘master:` is not), the scope is resolved via ACLScope.resolve! and the resulting permission set is checked against `_Role` CLP before the pipeline runs.

Returns:

  • (Set<String>, nil)

    resolved role names, or nil when the fast path is unavailable.

Raises:

  • (ArgumentError)

    when neither ‘master:` nor `as:` is supplied, or when both are supplied.

  • (Parse::CLPScope::Denied)

    when ‘as:` is supplied and the scope cannot `find` on `_Role`.



1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
# File 'lib/parse/mongodb.rb', line 1048

def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
  authorize_role_graph_call!(:role_names_for_user, master: master, as: as)
  validate_role_graph_id!(user_id, "user_id")
  depth = validate_role_graph_depth!(max_depth)
  return Set.new if depth <= 0
  return nil unless available?

  graph_depth = depth - 1
  pipeline = build_user_role_names_pipeline(user_id, graph_depth)
  Parse::PipelineSecurity.validate_filter!(
    pipeline, allow_internal_fields: true,
  )

  result_set = nil
  ActiveSupport::Notifications.instrument(
    "parse.mongodb.role_graph",
    direction: :forward, target_id: user_id, depth: depth,
  ) do |payload|
    docs = collection("_Join:users:_Role").aggregate(
      pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
    ).to_a
    names = Array(docs.first && docs.first["names"])
    result_set = Set.new(
      names.reject { |n| n.nil? || n.to_s.empty? }.map(&:to_s),
    )
    payload[:result_count] = result_set.size
  end
  result_set
rescue NotEnabled, GemNotAvailable
  nil
rescue StandardError => e
  if defined?(::Mongo::Error::OperationFailure) &&
     e.is_a?(::Mongo::Error::OperationFailure)
    raise_if_timeout!(e, "_Join:users:_Role", ROLE_GRAPH_MAX_TIME_MS)
  end
  raise
end

.to_mongodb_date(value) ⇒ Time?

Convert a date value to a UTC Time object suitable for MongoDB queries. MongoDB stores all dates in UTC, so this helper ensures consistent date handling when building aggregation pipelines or direct queries.

Examples:

Converting different date types

Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 15))
# => 2024-01-15 00:00:00 UTC

Parse::MongoDB.to_mongodb_date(Time.now)
# => 2024-11-30 12:30:45 UTC (converted to UTC)

Parse::MongoDB.to_mongodb_date("2024-01-15")
# => 2024-01-15 00:00:00 UTC

Parse::MongoDB.to_mongodb_date("2024-01-15T10:30:00Z")
# => 2024-01-15 10:30:00 UTC

Using in aggregation pipelines

cutoff = Parse::MongoDB.to_mongodb_date(Date.today - 30)
pipeline = [{ "$match" => { "createdAt" => { "$gte" => cutoff } } }]
results = Song.query.aggregate(pipeline, mongo_direct: true).results

Using with query constraints

# For date comparisons in queries, this ensures UTC consistency
start_date = Parse::MongoDB.to_mongodb_date(params[:start_date])
end_date = Parse::MongoDB.to_mongodb_date(params[:end_date])
songs = Song.query(:release_date.gte => start_date, :release_date.lt => end_date)

Parameters:

Returns:

  • (Time, nil)

    a UTC Time object, or nil if value is nil

Raises:

  • (ArgumentError)

    if the value cannot be parsed as a date



2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
# File 'lib/parse/mongodb.rb', line 2027

def to_mongodb_date(value)
  return nil if value.nil?

  case value
  when ::Time
    value.utc
  when ::DateTime
    value.to_time.utc
  when ::Date
    # Convert Date to midnight UTC
    ::Time.utc(value.year, value.month, value.day)
  when ::String
    # Parse string dates - try ISO 8601 first, then Date.parse
    begin
      if value =~ /T/
        # ISO 8601 with time component
        ::Time.parse(value).utc
      else
        # Date-only string, convert to midnight UTC
        date = ::Date.parse(value)
        ::Time.utc(date.year, date.month, date.day)
      end
    rescue ::ArgumentError => e
      raise ::ArgumentError, "Cannot parse '#{value}' as a date: #{e.message}"
    end
  when ::Integer
    # Assume Unix timestamp
    ::Time.at(value).utc
  else
    raise ::ArgumentError, "Cannot convert #{value.class} to MongoDB date. " \
          "Expected Date, Time, DateTime, String, or Integer."
  end
end

.update_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol

Replace the definition of an existing Atlas Search index. The rebuild runs asynchronously on the Atlas Search node; the new mapping is not live until the index’s status transitions back to ‘READY`. Poll AtlasSearch::IndexManager.index_ready? to confirm.

Raises ‘ArgumentError` if no search index with that name exists — use create_search_index for new indexes. The mapping diff is not computed; the command is issued unconditionally for existing indexes (Atlas itself handles “definition unchanged” cases gracefully).

Parameters:

  • collection_name (String)
  • name (String)

    existing search index name

  • definition (Hash)

    replacement definition

  • allow_system_classes (Boolean) (defaults to: false)

Returns:

  • (Symbol)

    ‘:updated` on submission

Raises:



740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# File 'lib/parse/mongodb.rb', line 740

def update_search_index(collection_name, name, definition, allow_system_classes: false)
  assert_mutations_allowed!
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  validate_search_index_name!(name)
  validate_search_index_definition!(definition)
  existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
  unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
    audit_writer_event(:update_search_index_absent, collection_name, name: name)
    raise ArgumentError,
          "update_search_index: no Atlas Search index named #{name.inspect} " \
          "on collection #{collection_name.inspect}. Use create_search_index to create one."
  end
  audit_writer_event(:update_search_index, collection_name, name: name)
  writer_client.database.command(
    updateSearchIndex: collection_name.to_s,
    name: name.to_s,
    definition: stringify_keys_deep(definition),
  )
  :updated
end

.users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?

Resolve every ‘_User.objectId` whose effective role set includes `role_id` — i.e., direct members of `role_id` PLUS direct members of any descendant role in `role_id`’s inheritance subtree.

Walks DOWN the inheritance tree via ‘$graphLookup` against `_Join:roles:_Role` (parent → children → grandchildren), then joins to `_Join:users:_Role` to pluck member ids, and finally filters out tombstoned `_User` rows so the fast path matches the soft-delete semantics the Parse-Server-backed path gets for free via REST CLP enforcement.

When called with a scoped ‘as:` argument (not master mode), the `_User` `$lookup` sub-pipeline is augmented with an `_rperm` `$match` so the joined `_User` rows are filtered to ones the scope can read. Without this, the join leaks `_User._id` regardless of caller authorization.

Same authorization contract, return-value contract, and error-policy as role_names_for_user.

Parameters:

  • role_id (String)

    a Parse ‘_Role.objectId`.

  • max_depth (Integer) (defaults to: ROLE_GRAPH_DEFAULT_DEPTH)

    BFS depth bound.

  • master (Boolean) (defaults to: false)

    when ‘true`, bypass `_Role` CLP and the `_User` `_rperm` filter on the join. Mutually exclusive with `as:`.

  • as (Parse::User, Parse::Pointer, nil) (defaults to: nil)

    caller-scope user. When provided, the scope is resolved via ACLScope.resolve!, the resulting permission set is checked against ‘_Role` CLP, and the resolved `_rperm` allow-set is injected into the `_User` join sub-pipeline.

Returns:

  • (Set<String>, nil)

    resolved ‘_User.objectId`s, or nil when the fast path is unavailable.

Raises:

  • (ArgumentError)

    when neither ‘master:` nor `as:` is supplied, or when both are supplied.

  • (Parse::CLPScope::Denied)

    when ‘as:` is supplied and the scope cannot `find` on `_Role`.



1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
# File 'lib/parse/mongodb.rb', line 1122

def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
  resolution = authorize_role_graph_call!(
    :users_in_role_subtree, master: master, as: as,
  )
  validate_role_graph_id!(role_id, "role_id")
  depth = validate_role_graph_depth!(max_depth)
  return Set.new if depth <= 0
  return nil unless available?

  graph_depth = depth - 1
  # Caller-scope path injects the resolved _rperm allow-set into
  # the _User sub-pipeline so the join honors row-level ACL.
  # Master mode leaves the sub-pipeline unscoped — the explicit
  # `master: true` is the operator's intent.
  rperm_allow = nil
  unless resolution.nil? || resolution.master?
    rperm_allow = resolution.permission_strings
  end
  pipeline = build_role_subtree_users_pipeline(
    role_id, graph_depth, rperm_allow: rperm_allow,
  )
  Parse::PipelineSecurity.validate_filter!(
    pipeline, allow_internal_fields: true,
  )

  result_set = nil
  ActiveSupport::Notifications.instrument(
    "parse.mongodb.role_graph",
    direction: :reverse, target_id: role_id, depth: depth,
  ) do |payload|
    docs = collection("_Join:roles:_Role").aggregate(
      pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
    ).to_a
    ids = Array(docs.first && docs.first["user_ids"])
    result_set = Set.new(
      ids.reject { |i| i.nil? || i.to_s.empty? }.map(&:to_s),
    )
    payload[:result_count] = result_set.size
  end
  result_set
rescue NotEnabled, GemNotAvailable
  nil
rescue StandardError => e
  if defined?(::Mongo::Error::OperationFailure) &&
     e.is_a?(::Mongo::Error::OperationFailure)
    raise_if_timeout!(e, "_Join:roles:_Role", ROLE_GRAPH_MAX_TIME_MS)
  end
  raise
end

.warn_if_writeable_role!Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Emit a warning when read_only? reports a writeable role. Called from configure when ‘verify_role: true`. Silent on `true` (correctly read-only) and on `nil` (couldn’t determine — too noisy to surface in normal operation).



351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/parse/mongodb.rb', line 351

def warn_if_writeable_role!
  case read_only?
  when false
    warn "[Parse::MongoDB] WARNING: the URI configured for direct " \
         "queries authenticates a user with write privileges. The " \
         "direct path is read-only by design; using a read-only " \
         "role bounds the blast radius if caller code touches " \
         "`Parse::MongoDB.client` directly. See " \
         "docs/mongodb_direct_guide.md for routing direct reads at " \
         "an analytics replica."
  end
end

.writer_configured?Boolean

Returns true when configure_writer has been called with ‘enabled: true` and the connection is reachable.

Returns:

  • (Boolean)

    true when configure_writer has been called with ‘enabled: true` and the connection is reachable.



476
477
478
# File 'lib/parse/mongodb.rb', line 476

def writer_configured?
  !@writer_uri.nil? && @writer_enabled == true
end

.writer_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>

List indexes on a collection via the WRITER connection. Distinct from indexes which uses the reader. Used by create_index for the idempotency check so the existence read is performed on the same connection that will issue the create.

Parameters:

  • collection_name (String)
  • allow_system_classes (Boolean) (defaults to: false)

Returns:



591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/parse/mongodb.rb', line 591

def writer_indexes(collection_name, allow_system_classes: false)
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  # NOTE: listing does not require the mutation gate — operators
  # can inspect what's there even when mutations are disabled,
  # which is useful for `parse:mongo:indexes:plan` dry-runs that
  # don't intend to mutate.
  unless writer_configured?
    raise WriterNotConfigured,
          "writer_indexes requires configure_writer to have been called."
  end
  begin
    writer_collection(collection_name).indexes.to_a
  rescue StandardError => e
    # Mongo raises NamespaceNotFound (code 26) when the collection
    # has not been created yet — listing indexes on a non-existent
    # collection is "no indexes" from the SDK's perspective. Match
    # by code AND by message substring because the driver's exact
    # class path varies across versions.
    return [] if mongo_namespace_not_found?(e)
    raise
  end
end

.writer_search_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>

List Atlas Search indexes via the WRITER connection. Distinct from list_search_indexes which uses the reader’s aggregate path. Used by the search-index mutation primitives below for the existence check so the read is performed on the same connection that will issue the mutation. Returns ‘[]` for collections that do not yet exist.

Parameters:

  • collection_name (String)
  • allow_system_classes (Boolean) (defaults to: false)

Returns:

  • (Array<Hash>)

    raw search-index documents

Raises:



625
626
627
628
629
630
631
632
633
634
635
636
637
638
# File 'lib/parse/mongodb.rb', line 625

def writer_search_indexes(collection_name, allow_system_classes: false)
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
  unless writer_configured?
    raise WriterNotConfigured,
          "writer_search_indexes requires configure_writer to have been called."
  end
  begin
    writer_collection(collection_name)
      .aggregate([{ "$listSearchIndexes" => {} }]).to_a
  rescue StandardError => e
    return [] if mongo_namespace_not_found?(e)
    raise
  end
end