Module: Parse::Core::Indexing

Included in:
Object
Defined in:
lib/parse/model/core/indexing.rb

Overview

Model-declarative MongoDB index DSL. Mixed into Parse::Object so subclasses can declare the indexes they expect to exist on their collection. Declarations are inert at load time — they only land on MongoDB when Schema::IndexMigrator reads them and apply_indexes! is invoked through the writer connection.

SECURITY POSTURE — purely declarative. No network I/O, no class introspection that could leak data, no LLM-visible surface. The validation rules below run at declaration time so a typo surfaces as a load-time error, not a runtime surprise during rake parse:mongo:indexes:apply in prod.

Examples:

Declaring indexes

class Car < Parse::Object
  property :make, :string
  property :model, :string
  property :year, :integer
  property :tags, :array
  property :location, :geopoint
  belongs_to :owner, as: :user

  mongo_index :make, :model, :year                 # compound
  mongo_index :vin, unique: true
  mongo_index :owner                               # → _p_owner (pointer auto-rewrite)
  mongo_geo_index :location                        # 2dsphere
  mongo_index :tags                                # array column
  # mongo_index :tags, :categories                 # REJECTED: parallel arrays
end

Constant Summary collapse

MAX_INDEXES_PER_COLLECTION =

MongoDB limits each collection to 64 indexes total (including the implicit _id_ index). The migrator's plan phase reports remaining capacity using this constant.

64
PARSE_MANAGED_ARRAY_FIELDS =

Parse-managed array columns we can know about without introspecting actual data. Used by #assert_at_most_one_array_field! to catch parallel-array compounds at declaration time even when the parallel field is the _rperm/_wperm ACL array.

%w[_rperm _wperm].to_set.freeze
SENSITIVE_FIELDS =

Wire-format column names that hold Parse-internal secret material (password hashes, session tokens, verification tokens, auth provider blobs). The DSL refuses to declare an index on any of these because a queryable index on bcrypt hashes or session tokens turns $indexStats / collection-scan access into a credential-enumeration oracle. Parse Server already manages the legitimate indexes for these columns (see Schema::IndexMigrator::PARSE_MANAGED_INDEX_PATTERNS); this guard exists so a typo or malicious PR can't add a new one.

%w[
  _hashed_password _session_token _email_verify_token
  _perishable_token _password_history authData _auth_data
].freeze
INDEX_REGISTRY_MUTEX =

Guards the check-then-append in the index registration helpers. Index declarations happen at class-load time, but an app server that eager- loads models across multiple threads can otherwise have two threads both pass the idempotency check on the same (still-empty) array and append duplicate declarations — producing duplicate createIndex calls at migration time. A single shared mutex is sufficient: this is not a hot path, so coarse locking trades nothing for correctness.

Mutex.new

Instance Method Summary collapse

Instance Method Details

#apply_indexes!(drop: false) ⇒ Hash

Apply additive index changes via the writer connection. Pass drop: true to also drop orphan indexes; each drop carries its own audit log and confirmation envelope.

Returns:



273
274
275
# File 'lib/parse/model/core/indexing.rb', line 273

def apply_indexes!(drop: false)
  Parse::Schema::IndexMigrator.new(self).apply!(drop: drop)
end

#indexes_planHash{String=>Hash}

Dry-run reconciliation between declared indexes and what's on the collection. Delegates to Schema::IndexMigrator.

Returns:

  • (Hash{String=>Hash})

    keyed by collection name; one entry per unique target collection across the declaration list (parent collection + any _Join:* collections from mongo_relation_index).



265
266
267
# File 'lib/parse/model/core/indexing.rb', line 265

def indexes_plan
  Parse::Schema::IndexMigrator.new(self).plan
end

#mongo_geo_index(field, sparse: false, name: nil) ⇒ Object

Sugar for a 2dsphere geospatial index. Geopoint columns are stored in Mongo as GeoJSON { type: "Point", coordinates: [lng, lat] } which 2dsphere indexes natively.



176
177
178
179
# File 'lib/parse/model/core/indexing.rb', line 176

def mongo_geo_index(field, sparse: false, name: nil)
  register_index([field], key_value: "2dsphere", unique: false,
                 sparse: sparse, partial: nil, expire_after: nil, name: name)
end

#mongo_index(*fields, unique: false, sparse: false, partial: nil, expire_after: nil, name: nil) ⇒ Hash

Declare a regular (B-tree) index on one or more fields. Symbols in fields are looked up against the class's references table — pointers auto-rewrite to _p_<field> so callers think in property names. Use mongo_geo_index for 2dsphere indexes.

Parameters:

  • fields (Array<Symbol>)

    property names; compound indexes are formed by passing more than one. Order matters for query prefix matching (MongoDB compound-index rule).

  • unique (Boolean) (defaults to: false)
  • sparse (Boolean) (defaults to: false)
  • partial (Hash, nil) (defaults to: nil)

    partial-index filter expression. Owner is responsible for lifecycle — Parse Server will not manage it.

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

    TTL in seconds (only valid on single-field indexes per MongoDB's TTL rules).

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

    explicit index name; defaults to field_dir_field_dir via MongoDB's auto-naming.

Returns:

  • (Hash)

    the registered declaration (frozen)

Raises:

  • (ArgumentError)

    when validation rules fail (no fields, unknown field, parallel arrays, relation field, etc.)



95
96
97
98
99
# File 'lib/parse/model/core/indexing.rb', line 95

def mongo_index(*fields, unique: false, sparse: false, partial: nil,
                expire_after: nil, name: nil)
  register_index(fields, key_value: 1, unique: unique, sparse: sparse,
                 partial: partial, expire_after: expire_after, name: name)
end

#mongo_index_declarationsArray<Hash>

Storage for declared indexes. Each entry is a frozen Hash with the keys :keys, :options, :declared_for (the source-of-truth symbol list from the mongo_index call, for diagnostics).

Returns:



72
73
74
# File 'lib/parse/model/core/indexing.rb', line 72

def mongo_index_declarations
  @mongo_index_declarations ||= []
end

#mongo_relation_index(field, bidirectional: false, dedup: false, unique: false) ⇒ Array<Hash>

Declare an index on a Parse Relation's join collection. Relations are stored in _Join:<field>:<ParentClass> collections — these have no Ruby model, so an add_index :field against the parent class would index the wrong collection. This method routes the declaration to the correct join-collection name, with the conventional column shape: owningId is the parent-side foreign key, relatedId is the related-side.

Default: single declaration on {owningId: 1} — the forward lookup ("what's related to this owner"), which is the dominant pattern for most Parse Relation queries.

bidirectional: true adds a second declaration on {relatedId: 1} — the reverse lookup ("which owners contain this related object"). For high-traffic auth patterns like Parse::Role.users, the reverse direction is often the heavier-used index.

Uniqueness on a single-direction relation index is NOT supported — unique: true on just owningId (or just relatedId) would assert each owner can hold at most one related, contradicting has_many. That mistake is rejected at declaration time.

dedup: true is semantically different and IS supported: it registers a compound {owningId: 1, relatedId: 1} unique index on the join collection. The compound key prevents duplicate (owner, related) pair rows from accumulating (a real failure mode under concurrent .add calls on a Parse Relation), without constraining how many distinct relateds an owner may hold or vice versa. Default off — the index buys correctness at the cost of a write-time uniqueness check on every relation insert, and existing collections with duplicate pairs will fail the migrator's apply step until reconciled.

Examples:

Canonical case — role membership with dedup

class Parse::Role < Parse::Object
  has_many :users, through: :relation
  mongo_relation_index :users, bidirectional: true, dedup: true
  # creates: _Join:users:_Role { owningId: 1 }
  #         _Join:users:_Role { relatedId: 1 }
  #         _Join:users:_Role { owningId: 1, relatedId: 1 } unique
end

Parameters:

  • field (Symbol)

    the relation field name (must be declared via has_many :field, through: :relation)

  • bidirectional (Boolean) (defaults to: false)

    when true, register two declarations — one each for owningId and relatedId

  • dedup (Boolean) (defaults to: false)

    when true, also register a compound {owningId: 1, relatedId: 1} unique index that prevents duplicate-pair membership rows

  • unique (Boolean) (defaults to: false)

    rejected — see above

Returns:

  • (Array<Hash>)

    the registered declarations

Raises:

  • (ArgumentError)

    when field is not a declared relation or unique: is passed



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/parse/model/core/indexing.rb', line 236

def mongo_relation_index(field, bidirectional: false, dedup: false, unique: false)
  if unique
    raise ArgumentError,
          "#{self}.mongo_relation_index does not support unique: — uniqueness on " \
          "a single-direction relation column breaks has_many semantics. Use " \
          "`dedup: true` for a compound `{owningId, relatedId}` unique index that " \
          "prevents duplicate-pair membership without constraining cardinality."
  end
  field = field.to_sym
  unless respond_to?(:relations) && relations.key?(field)
    raise ArgumentError,
          "#{self}.mongo_relation_index requires #{field.inspect} to be declared " \
          "via `has_many :#{field}, through: :relation`. Got non-relation field."
  end
  join_collection = "_Join:#{field}:#{parse_class}"
  decls = [register_relation_index(join_collection, "owningId", source: field)]
  decls << register_relation_index(join_collection, "relatedId", source: field) if bidirectional
  if dedup
    decls << register_relation_dedup_index(join_collection, source: field)
  end
  decls
end

#unique_index_on(*fields, sparse: false, partial: nil, name: nil) ⇒ Hash

Declare a UNIQUE index on the exact dedup tuple that first_or_create! / create_or_update! key on. This is the correctness floor for the synchronize-create race.

The Redis-backed synchronize: lock (see #first_or_create!) is a latency optimization: in the common path it collapses concurrent callers so only one issues the create. But a lock can be bypassed — a Redis outage, a TTL expiring between the existence check and the write, a caller passing synchronize: false, or two app servers whose lock secrets disagree. When that happens, the database is the last line of defense. A unique index guarantees, unconditionally, that two racing inserts can't both land: the loser fails with DuplicateValue (Parse error 137), which first_or_create! rescues and resolves to the winning row via _recover_from_duplicate_value. Lock + index together make the net invariant "exactly one row, every caller sees the same id" hold under any race, not just the happy path.

This is thin sugar over mongo_index(*fields, unique: true, ...) — it shares the same registration, validation (sensitive-field guard, pointer auto-rewrite, parallel-array / relation / _id rejection), and IndexMigrator apply path. The name states the intent: these fields form the dedup identity for create-or-update.

Defaults match mongo_index: non-sparse. The index key is kept identical to the query first_or_create! re-runs on recovery, so a 137 always corresponds to a row the recovery query (_scoped_first on the same query_attrs) can find. A sparse or partial index that fires on a condition the recovery query doesn't reproduce would surface a 137 the rescue can't resolve, and the error would re-raise. sparse: is meaningful only when a document is missing every field in the tuple (a compound sparse index indexes a doc when it has at least one key); since first_or_create! always writes the full tuple, it never produces such a row, so sparse does not weaken the floor — leave it off unless out-of-band writers create tuple-less rows you want excluded.

Examples:

Single-field dedup floor

class Account < Parse::Object
  property :email, :string
  unique_index_on :email
end
Account.apply_indexes!   # provisions { email: 1 } unique via the writer

Compound tuple with a pointer component

class Subscription < Parse::Object
  property :email, :string
  belongs_to :tenant, as: :user
  unique_index_on :email, :tenant   # key: { email: 1, _p_tenant: 1 } unique
end

Unique within a subset (partial filter escape hatch)

# Unique email per tenant, but rows with no tenant may repeat. You
# own the filter's lifecycle and must keep first_or_create!'s
# recovery query consistent with it.
unique_index_on :email, :tenant,
                partial: { "_p_tenant" => { "$exists" => true } }

Parameters:

  • fields (Array<Symbol>)

    the dedup tuple, in declaration order. Pointer fields auto-rewrite to _p_<field> like mongo_index.

  • sparse (Boolean) (defaults to: false)

    default false; see the note above on why it does not weaken the floor and when it actually changes behavior.

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

    partial-index filter for "unique within a subset". Owner-managed; keep it consistent with the recovery query.

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

    explicit index name; defaults to MongoDB auto-naming.

Returns:

  • (Hash)

    the registered declaration (frozen)

Raises:

  • (ArgumentError)

    same guards as mongo_index.

See Also:

  • #first_or_create!


169
170
171
# File 'lib/parse/model/core/indexing.rb', line 169

def unique_index_on(*fields, sparse: false, partial: nil, name: nil)
  mongo_index(*fields, unique: true, sparse: sparse, partial: partial, name: name)
end