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.
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/_wpermACL 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
createIndexcalls 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
-
#apply_indexes!(drop: false) ⇒ Hash
Apply additive index changes via the writer connection.
-
#indexes_plan ⇒ Hash{String=>Hash}
Dry-run reconciliation between declared indexes and what's on the collection.
-
#mongo_geo_index(field, sparse: false, name: nil) ⇒ Object
Sugar for a 2dsphere geospatial index.
-
#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.
-
#mongo_index_declarations ⇒ Array<Hash>
Storage for declared indexes.
-
#mongo_relation_index(field, bidirectional: false, dedup: false, unique: false) ⇒ Array<Hash>
Declare an index on a Parse Relation's join collection.
-
#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.
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.
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_plan ⇒ Hash{String=>Hash}
Dry-run reconciliation between declared indexes and what's on the collection. Delegates to Schema::IndexMigrator.
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.
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_declarations ⇒ Array<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).
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.
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.
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 |