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: ClientMismatch, 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

.bound_app_scopeString? (readonly)

Returns the application scope this global connection is bound to, captured at configure time. See app_scope_for.

Returns:



289
290
291
# File 'lib/parse/mongodb.rb', line 289

def bound_app_scope
  @bound_app_scope
end

.clientMongo::Client (readonly)

Get or create the MongoDB client

Returns:

  • (Mongo::Client)

Raises:



213
214
215
# File 'lib/parse/mongodb.rb', line 213

def client
  @client
end

.databaseString

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

Returns:



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

def database
  @database
end

.enabledBoolean

Feature flag to enable/disable direct MongoDB queries.

Returns:

  • (Boolean)


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

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)


642
643
644
# File 'lib/parse/mongodb.rb', line 642

def index_mutations_enabled
  @index_mutations_enabled
end

.uriString

MongoDB connection URI.

Returns:



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

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, client: nil, read_preference: nil, hint: 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 subscription (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:



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
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
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
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
# File 'lib/parse/mongodb.rb', line 1722

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, client: nil, read_preference: nil, hint: nil)
  # AS::N envelope. Payload is intentionally metadata-only —
  # `stage_count`, `stage_types`, `collection`, `scope`,
  # `result_count`, `max_time_ms`, `read_preference`. Pipeline
  # bodies are NOT included: they routinely embed user-id
  # strings, tenant identifiers, search terms, and other PII
  # that has no business in a log line or an APM span. The
  # `parse.mongodb.role_graph` notification (emitted lower in
  # this module) nests as a child event when role expansion
  # runs inside the surrounding aggregate. `result_count` and
  # `scope` are seeded nil so subscribers see a stable key set
  # even on the raise path (where the block exits before either
  # is written).
  instrument_payload = {
    collection: collection_name,
    stage_count: pipeline.is_a?(Array) ? pipeline.size : 0,
    stage_types: __extract_stage_types(pipeline),
    max_time_ms: max_time_ms,
    read_preference: read_preference&.to_s,
    scope: nil,
    result_count: nil,
  }
  ActiveSupport::Notifications.instrument("parse.mongodb.aggregate", instrument_payload) do |payload|
    # 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,
      # The client whose authorization context resolves this call. Nil
      # falls back to Parse.client at the ACLScope boundary. It is carried
      # onto the Resolution so verify_client! below can compare it against
      # the application this process-global connection is bound to.
      client: client,
    }.compact
    resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
    # The resolution above is client-scoped; the connection below is not.
    # Refuse the combination rather than reading another application's
    # database with this application's permission strings.
    verify_client!(resolution.client)
    payload[:scope] = __scope_label(resolution)

    # 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 = prepend_or_fold_acl_match(pipeline, acl_stage)
    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
    # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint`
    # on the mongo-direct path so a bad plan diagnosed with `explain` can
    # be corrected here too. Accepts an index name (String) or a key
    # pattern (Hash).
    agg_opts[:hint] = hint unless hint.nil?
    # The SAME client this call already verified against. Passing nothing
    # here made the collection lookup an unidentified caller, so once a
    # second application had been observed a perfectly legitimate
    # aggregate for the bound application was refused.
    coll = collection(collection_name, authorizing_client: Parse::ACLScope.client_of(resolution))
    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?

      # Process-level floor: recursively strip Parse-internal credential
      # columns (_hashed_password, _session_token, _auth_data_*, _rperm,
      # ...) from every row AND every embedded sub-document. The
      # protectedFields strip above is keyed on the OUTER class, and the
      # ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers
      # a foreign class (e.g. _User / _Session) pulled in via $lookup /
      # $graphLookup / $unionWith under an arbitrary alias. Runs last, for
      # scoped (non-master) callers only; master is unredacted by design.
      results.each do |row|
        Parse::PipelineSecurity.redact_internal_fields_deep!(row)
      end
    end

    payload[:result_count] = results.size
    results
  end
rescue => e
  raise_if_timeout!(e, collection_name, max_time_ms)
  raise
end

.app_scope_for(client) ⇒ String?

Identify a Parse application by BOTH its application id and its server url.

The application id alone is not enough. Parse application ids are not globally unique: the same id is routinely reused across a staging and a production deployment of the same app, which is exactly the pair most likely to be configured in one developer process. Comparing ids only would let a staging client authorize a read of the production database and call it a match, which is the failure this guard exists to prevent rather than a case it may ignore.

Mirrors Cache::Keyspace's app scope, which derives the same pair for the same reason.

Parameters:

Returns:

  • (String, nil)

    a stable scope string, or nil when the client cannot be identified.



308
309
310
311
312
313
314
315
# File 'lib/parse/mongodb.rb', line 308

def app_scope_for(client)
  return nil if client.nil?
  return nil unless client.respond_to?(:application_id)
  app_id = client.application_id
  return nil if app_id.nil? || app_id.to_s.empty?
  server = client.respond_to?(:server_url) ? client.server_url.to_s : ""
  "#{app_id}\u0000#{server}"
end

.assert_mutations_allowed!Object

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



697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
# File 'lib/parse/mongodb.rb', line 697

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:



2674
2675
2676
2677
2678
2679
# File 'lib/parse/mongodb.rb', line 2674

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)


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

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

.collection(name, authorizing_client: nil) ⇒ Mongo::Collection

Get a MongoDB collection

Parameters:

  • name (String)

    the collection name

Returns:

  • (Mongo::Collection)


585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/parse/mongodb.rb', line 585

def collection(name, authorizing_client: nil)
  # The last chokepoint before the database. {.aggregate} verifies the
  # binding too, but not every scoped read goes through it: Atlas
  # Search runs its own `$search` pipelines and hybrid vector search
  # runs its own, both reaching the driver through here.
  #
  # `authorizing_client:` must be the client that AUTHORIZED the read.
  # An earlier version compared `Parse.client` unconditionally, which
  # was worse than no check at all: a caller that resolved a token
  # against client B and then asked for a collection had its default
  # client A compared against A's own binding, so the guard reported
  # success on exactly the case it exists to catch.
  # NOT `authorizing_client || default_client_or_nil`. Substituting the
  # default presented a forgotten `client:` as the default client, so
  # it passed against a default-bound database and the fail-closed
  # branch could never fire. A missing client is an unidentified
  # caller, and {.verify_client!} decides what that means.
  verify_client!(authorizing_client)
  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



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/parse/mongodb.rb', line 265

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
  # Bind this connection to whichever Parse application is configured
  # now. See {.verify_client!} for why.
  BINDING_MUTEX.synchronize do
    @bound_app_scope = current_app_scope
    @observed_app_scopes = nil
    note_observed_scope_unlocked(@bound_app_scope)
  end
  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.



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

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 "Workspace$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



2347
2348
2349
2350
2351
2352
# File 'lib/parse/mongodb.rb', line 2347

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



2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
# File 'lib/parse/mongodb.rb', line 2264

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



2334
2335
2336
# File 'lib/parse/mongodb.rb', line 2334

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:



745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
# File 'lib/parse/mongodb.rb', line 745

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-z][A-Za-z0-9_-]{0,63}\z/.

  • 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:



872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
# File 'lib/parse/mongodb.rb', line 872

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).



774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
# File 'lib/parse/mongodb.rb', line 774

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:



909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
# File 'lib/parse/mongodb.rb', line 909

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)


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

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:



2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
# File 'lib/parse/mongodb.rb', line 2100

def find(collection_name, filter = {}, **options)
  max_time_ms = options.delete(:max_time_ms)
  # Consumed like the other auth kwargs so it never reaches the driver.
  find_client = options.delete(:client)
  # Metadata-only AS::N payload: collection, presence-of-filter
  # (NOT body), projection keys (column names, not values), limit,
  # max_time_ms, result_count. Filter / projection bodies are
  # excluded because they routinely embed user-id strings,
  # tenant IDs, and other PII that has no business in a log line
  # or a span. The `find` payload deliberately has no `:scope`
  # field — `Parse::MongoDB.find` takes no ACL kwargs, so there
  # is no resolution to label. Shared subscribers that handle
  # both event names must treat `payload[:scope]` as optional.
  # `result_count` is seeded nil so subscribers see a stable key
  # set even on the raise path.
  projection_keys = if options[:projection].is_a?(Hash)
      options[:projection].keys.map(&:to_s)
    end
  instrument_payload = {
    collection: collection_name,
    has_filter: filter.is_a?(Hash) && !filter.empty?,
    projection_keys: projection_keys,
    limit: options[:limit],
    max_time_ms: max_time_ms,
    result_count: nil,
  }
  ActiveSupport::Notifications.instrument("parse.mongodb.find", instrument_payload) do |payload|
    allow_internal_fields = options.delete(:allow_internal_fields) || false
    assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
    cursor = collection(collection_name, authorizing_client: find_client).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.hint(options[:hint]) unless options[:hint].nil?
    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

    payload[:result_count] = results.size
    results
  end
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



217
218
219
220
221
222
223
224
225
# File 'lib/parse/mongodb.rb', line 217

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, client: 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:



2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
# File 'lib/parse/mongodb.rb', line 2037

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,
             client: 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,
            client: client,
            read_preference: read_preference)
end

.geo_near_stage_key(stage) ⇒ Symbol, ...

Returns the $geoNear key (symbol or string form) if stage is a $geoNear stage, else nil.

Returns:

  • (Symbol, String, nil)

    the $geoNear key (symbol or string form) if stage is a $geoNear stage, else nil.



1954
1955
1956
1957
1958
1959
# File 'lib/parse/mongodb.rb', line 1954

def geo_near_stage_key(stage)
  return nil unless stage.is_a?(Hash)
  return :$geoNear if stage.key?(:$geoNear)
  return "$geoNear" if stage.key?("$geoNear")
  nil
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 {ops:, 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).



2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
# File 'lib/parse/mongodb.rb', line 2228

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.



2188
2189
2190
2191
2192
2193
2194
2195
2196
# File 'lib/parse/mongodb.rb', line 2188

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



2173
2174
2175
# File 'lib/parse/mongodb.rb', line 2173

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

.mutations_env_enabled?Boolean

Returns true iff ENV[MUTATION_ENV_KEY] == "1".

Returns:

  • (Boolean)

    true iff ENV[MUTATION_ENV_KEY] == "1".



690
691
692
# File 'lib/parse/mongodb.rb', line 690

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:



615
616
617
618
619
620
621
622
623
624
# File 'lib/parse/mongodb.rb', line 615

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

.prepend_or_fold_acl_match(pipeline, acl_stage) ⇒ Array<Hash>

Inject the scoped ACL $match at the front of a pipeline — UNLESS the first stage is $geoNear, which MongoDB requires to be pipeline stage 0. In that case fold the ACL predicate into $geoNear.query (a candidate-document pre-filter, semantically equivalent to a leading $match) so the stage-0 invariant holds and the scoped geo query still enforces _rperm.

A scoped geo_near previously failed CLOSED here: the prepended $match pushed $geoNear off stage 0 and MongoDB rejected the whole pipeline. Post-fetch redaction (protectedFields / sub-doc / internal-field) runs regardless, so folding loses no enforcement.

Parameters:

Returns:

  • (Array<Hash>)

    a new pipeline; caller stages are not mutated.



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
# File 'lib/parse/mongodb.rb', line 1925

def prepend_or_fold_acl_match(pipeline, acl_stage)
  geo_key = geo_near_stage_key(pipeline.first)
  return [acl_stage] + pipeline unless geo_key

  match_pred = acl_stage["$match"] || acl_stage[:$match]
  geo = pipeline.first[geo_key].dup
  # Match the stage's own key style: reuse an existing `query` key of
  # either type, else follow the `$geoNear` key's type (string stage
  # → "query", symbol stage → :query). The Mongo driver normalizes
  # either way, but keeping one style avoids a duplicate query key.
  q_key = if geo.key?("query") then "query" elsif geo.key?(:query) then :query elsif geo_key.is_a?(String) then "query" else :query end
  existing = geo[q_key]
  geo[q_key] = if existing.is_a?(Hash) && !existing.empty?
      # `existing` is still the caller's own `$geoNear.query` hash
      # (the outer `.dup` above is shallow). Embed a copy, not the
      # original, so the folded pipeline and the caller's pipeline
      # don't share a mutable hash — honoring the "caller stages are
      # not mutated" contract in both directions.
      { "$and" => [existing.dup, match_pred] }
    else
      match_pred
    end
  new_first = pipeline.first.dup
  new_first[geo_key] = geo
  [new_first] + pipeline[1..]
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)


521
522
523
524
525
526
527
528
529
530
531
532
533
# File 'lib/parse/mongodb.rb', line 521

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:



229
230
231
232
233
234
# File 'lib/parse/mongodb.rb', line 229

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)



570
571
572
573
574
575
576
577
578
579
580
# File 'lib/parse/mongodb.rb', line 570

def reset!
  @client&.close rescue nil
  @client = nil
  @enabled = false
  @uri = nil
  @bound_app_scope = nil
  @observed_app_scopes = nil
  @database = nil
  remove_instance_variable(:@gem_available) if defined?(@gem_available)
  reset_writer!
end

.reset_writer!Object

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



719
720
721
722
723
724
# File 'lib/parse/mongodb.rb', line 719

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.



466
467
468
469
470
471
472
# File 'lib/parse/mongodb.rb', line 466

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, client: nil) ⇒ Set<String>?

Resolve every role name a user inherits via a single $graphLookup aggregation against the Parse role-subscription 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 subscriptions 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 subscriptions).
  • 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.



1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
# File 'lib/parse/mongodb.rb', line 1264

def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil,
                                 client: nil)
  # Public entry point: resolve an omitted client to the default ONCE,
  # here, and use the same value for the authorization and for the
  # collection below. Passing the raw `client:` down meant an ordinary
  # default-client call reached the sink as unidentified and was
  # rejected once a second application had been observed.
  client ||= default_client_or_nil
  authorize_role_graph_call!(:role_names_for_user, master: master, as: as, client: client)
  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|
    # The role graph is an authorization input, so this read is
    # checked against the client that asked for it like any other.
    docs = collection("_Join:users:_Role", authorizing_client: client).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



2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
# File 'lib/parse/mongodb.rb', line 2385

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:



949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
# File 'lib/parse/mongodb.rb', line 949

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, client: 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.objectIds, 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.



1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
# File 'lib/parse/mongodb.rb', line 1347

def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil,
                                   client: nil)
  # See {.role_names_for_user}: resolve the omission once at the entry
  # point rather than letting nil travel to the collection lookup.
  client ||= default_client_or_nil
  resolution = authorize_role_graph_call!(
    :users_in_role_subtree, master: master, as: as, client: client,
  )
  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", authorizing_client: client).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

.verify_client!(client) ⇒ Object

Refuse a mongo-direct query issued by a client that is not the one this connection was configured for.

The connection is process-global: one URI, one database, one driver client, chosen by whoever called configure. Authorization, since 5.7, is per-client: client.authorization resolves a session token against that client's own Parse application. Those two facts are safe in isolation and dangerous together. A secondary client would resolve its token correctly, against its own application, produce a correct _rperm allow-set for a user of that application, and then run the resulting pipeline against the OTHER application's database. The user ids and role names would be matched against rows they have nothing to do with, and any collision is a cross-application read.

Failing closed is the only safe answer, because the alternative is silent and looks like a working query. The connection becomes client-owned in 6.0, at which point this guard is unnecessary.

A caller of nil is UNIDENTIFIED, and is never silently upgraded to the default client. Substituting a default there made the fail-closed branch below unreachable: a call site that forgot to forward its client was presented as the default and passed against a default-bound database, which is exactly the omission this exists to catch.

Parameters:

  • client (Parse::Client, nil)

    the client that authorized the call, or nil when the caller cannot say.

Raises:



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
382
383
384
385
386
387
# File 'lib/parse/mongodb.rb', line 354

def verify_client!(client)
  caller_scope = app_scope_for(client)
  bound = binding_scope!(caller_scope)
  return if bound.nil?

  if caller_scope.nil?
    # An unidentified caller. Where only one application has ever been
    # seen this is unambiguous, which covers every single-application
    # deployment. Once a SECOND has been seen it is a real ambiguity,
    # and allowing it meant any call site that forgot to forward its
    # client silently read whichever database happened to be bound.
    # Completeness of that plumbing should not be the only thing
    # standing between two applications, so this fails closed.
    return if observed_scopes.size <= 1
    raise ClientMismatch,
          "Parse::MongoDB received a direct query with no identifiable client, in a " \
          "process where more than one Parse application has been seen " \
          "(#{observed_scopes.map { |sc| describe_scope(sc) }.join(", ")}). " \
          "The connection is bound to #{describe_scope(bound)}. Refusing rather than " \
          "guessing: pass client: through this call path so the read can be checked."
  end

  return if caller_scope == bound

  raise ClientMismatch,
        "Parse::MongoDB is bound to #{describe_scope(bound)} but this query was " \
        "authorized by a client for #{describe_scope(caller_scope)}. The MongoDB " \
        "connection is process-global while authorization is per-client, so running " \
        "this would resolve one application's identity and then read the other " \
        "application's database. Note that a matching application id is not enough: " \
        "the same id is commonly reused across staging and production, so the server " \
        "url is compared too. Configure a separate process for each application, " \
        "or route this query through REST instead of mongo-direct."
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).



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

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.



685
686
687
# File 'lib/parse/mongodb.rb', line 685

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:



800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
# File 'lib/parse/mongodb.rb', line 800

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:



834
835
836
837
838
839
840
841
842
843
844
845
846
847
# File 'lib/parse/mongodb.rb', line 834

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