Skip to content

Coming from Mongoose

mongospecs takes Mongoose as its reference for what a production ODM should do. This page maps Mongoose features to their mongospecs form and to the tests that cover them, and lists where mongospecs deliberately behaves differently.

Test paths are relative to packages/mongospecs/tests/. "Source" says how the Mongoose behaviour was established: docs means it was re-read in the Mongoose documentation for this project, memory means it is stated from general knowledge of Mongoose 8.x and has not been re-checked.

Feature map

Mongoose mongospecs Covered by Source
Change tracking, delta save(), isNew, modifiedPaths() snapshot at load; save() sends the diff; doc.is_new, doc.changes() test_crud.py::test_save_new_then_delta, test_tracking_tx.py docs
lean() .lean() (models, untracked), .raw() (dicts) test_crud.py::test_read_modes docs
Model.hydrate() Model.hydrate(raw) test_crud.py::test_hydrate_and_collection_escape_hatch docs
Middleware: document, query, aggregate, model document hooks; on_insert/on_update/on_filter/on_delete; on_pipeline; before_bulk/after_bulk test_plugins_hooks.py docs
schema.plugin(), mongoose.plugin() marker plugins, SpecConfig(plugins=), Registry(plugins=) test_plugins_hooks.py::test_registry_level_plugin_applies_to_every_model docs
timestamps CreatedAt, UpdatedAt test_plugins_hooks.py::test_timestamps_on_every_write_path docs
optimisticConcurrency, versionKey Revision test_plugins_hooks.py::test_revision_increments_everywhere docs
populate() with match, select, options, perDocumentLimit, nested paths include() with match, select, sort, limit, per_parent_limit, dotted paths test_relations.py docs
Virtual populate (localField/foreignField, count) Backref(...), Backref(..., count=True) test_relations.py::test_backrefs_and_counts docs
refPath Ref[A \| B] test_relations.py::test_polymorphic_reference docs
Discriminators SpecConfig(discriminator=...) test_models_misc.py::test_discriminators_share_a_collection docs
Query casting values cast to the field type test_expr.py::test_values_are_cast_to_field_types docs
strictQuery: 'throw' always: unknown fields raise test_expr.py::test_field_names_are_translated_and_checked docs
sanitizeFilter, trusted() raw filters untrusted unless trusted() test_expr.py::test_untrusted_mappings_and_operator_injection docs
select: false Deferred test_crud.py::test_deferred_field_is_not_loaded_or_clobbered docs
immutable Immutable test_updates.py::test_immutable_and_required_fields_are_protected docs
alias Alias("x") test_codec.py::test_alias_and_transient_fields memory
Query helpers @Model.scope test_plugins_hooks.py::test_named_scopes docs
orFail() .or_fail(), .one() test_crud.py::test_find_all_first_one_and_or_fail docs
cursor(), eachAsync({parallel}) async for, .each(fn, concurrency=n) test_crud.py::test_each_with_concurrency docs
bulkWrite, bulkSave, insertMany({ordered: false}) bulk_write, bulk_save, insert_many(ordered=False) test_bulk_pagination.py docs
Connection.transaction(fn) resets document state on abort registry.transaction(fn) does the same, also before each retry test_tracking_tx.py::test_transaction_abort_rolls_back_database_and_memory docs
syncIndexes, diffIndexes, autoIndex: false plan_indexes, apply_indexes(drop=...); never automatic test_indexes.py docs
shardKey SpecConfig(shard_key=...) test_streams_sharded.py::test_shard_key_is_added_to_document_writes docs
createConnection, useDb several registries; Model.using(db=...) test_models_misc.py::test_using_another_database memory
maxTimeMS, hint, comment, collation, read/write concern .options(...), SpecConfig, Registry(max_time_ms=) test_registry_errors.py::test_default_max_time_ms_from_registry_and_model memory
Model.aggregate(), Model.collection Model.aggregate(pipeline, as_=), Model.collection() test_crud.py::test_aggregate_decodes_and_explain docs
mongoose.set('debug') Registry(on_command=...) with logging, OpenTelemetry, Sentry listeners test_registry_errors.py::test_command_events_carry_model_and_operation docs
Model.watch() Model.watch() test_streams_sharded.py::test_change_stream_events docs
exists, countDocuments, estimatedDocumentCount exists, count, estimated_count test_crud.py::test_count_exists_ids_estimated memory
Capped, time-series, TTL options SpecConfig(collection_options=), Index(ttl=) test_indexes.py::test_collection_options_are_applied docs
Subdocuments, arrays of subdocuments, Maps nested models, list[T], dict[str, T]; tracked through the parent test_crud.py::test_nested_document_diff_uses_dotted_paths memory
Getters, setters, custom validators your backend's converters and validators test_codec.py::test_validate_field_runs_the_models_own_validators memory
Virtuals Python properties; @computed to include one in API output packages/mongospecs-web/tests/test_mongo_resource.py memory
Statics and methods classmethods and methods (plain Python) docs

Deliberate differences

Mongoose mongospecs Why
Global default connection and model registry Explicit Registry; names resolved per registry No hidden state, isolated tests
bufferCommands queues operations until connected Operations fail immediately when the registry is not open A dead connection should not look like a slow one
strictQuery can strip unknown filter paths, so a typo matches everything Unknown fields raise QueryError Fail closed
strict silently strips unknown update paths Unknown fields raise Same
Update validators are off unless runValidators Atomic updates are always validated Unvalidated writes should be the explicit case
Filter casting follows schema types Same; storage validators are not applied to filters A search for a value that would be invalid to store is legitimate
findOneAndUpdate returns the document as it was unless new: true Model.update returns the updated document; returning="before" for the old one Least surprise
Model.aggregate() results are uncast and plugins must hook it Scopes apply to aggregate(); as_= decodes Soft delete and tenancy should not have a hole
Indexes are built on startup by default Never automatic Unsafe on large collections
Collection names are pluralised The class name, or what you declare No guessing
transactionAsyncLocalStorage offers an ambient session Sessions are always passed explicitly Tasks spawned inside a transaction would silently share one session
Populated paths replace the id with the document A Ref keeps .id and gains .doc One type whether or not it is loaded
Arrays are tracked per element A changed list is sent whole Per-element intent is expressed with atomic operators
Model.watch() errors out when it loses the primary The change stream resumes across a step-down (the driver's behaviour, verified by a stress scenario) Consumers should not need their own reconnect loop
Setting a nested object overwrites it (doc.set('child', {...}); {merge: true} merges) Model code is the same: assigning an embedded object replaces it. An HTTP PATCH merges into embedded objects; Resource(merge_patch=False) makes it overwrite, and PUT replaces the document A client must not clear values it is not allowed to set (private or read-only fields inside the object)
syncIndexes() drops every undeclared index apply_indexes() only drops or recreates with drop=True Dropping an index is not something a deploy should do by accident
orFail() on an update throws when nothing was modified Model.update raises when nothing matched; find(...).update(...) returns the matched and modified counts A no-op update that matched is not an error
populate() always runs a separate query per path include() does the same by default and can use $lookup instead (strategy="lookup") Both give the same result; the faster one depends on fan-out, see the benchmarks
limit in populate() is applied across all parents unless perDocumentLimit is used limit is overall, per_parent_limit is per parent, without one query per parent Same two meanings, named for what they do