Skip to content

feat: add .exclude() method for exclusion projection on find queries - #1352

Open
smahn9123 wants to merge 1 commit into
BeanieODM:mainfrom
smahn9123:feat/exclusion-projection
Open

feat: add .exclude() method for exclusion projection on find queries#1352
smahn9123 wants to merge 1 commit into
BeanieODM:mainfrom
smahn9123:feat/exclusion-projection

Conversation

@smahn9123

@smahn9123 smahn9123 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Beanie's projection_model only supports inclusion — you define a model listing every field you want. Sometimes you just want to skip a couple of fields (a large blob, sensitive data) without spelling out all the others. MongoDB supports this natively with {field: 0}:

products = await Product.find().exclude("internal_notes", "api_key").to_list()

# find_one, fetch_links, aliases and ExpressionField refs all work;
# repeated calls accumulate; dotted paths reach into embedded models
await Product.find_one(Product.id == pid).exclude(Product.internal_notes)
await Product.find(fetch_links=True).exclude("supplier.contact_email").to_list()

Inheritance (is_root = True) and UnionDoc are supported: the exclusion is applied to the concrete class each document turns out to belong to, so with_children=True still returns properly typed children.

Related: #163
Supersedes #1322

Write protection

Excluded fields are None on the returned instance, so writing the whole document back would overwrite the stored values. save(), replace() and insert() therefore raise DocumentWasPartiallyLoaded (new in beanie.exceptions), while save_changes() and set() still work since they only touch named fields. The guard fires only on instances produced by an exclusion projection, so no existing behaviour changes.

Restrictions

Combinations that cannot work raise rather than silently misbehaving: .project() with a model other than the document model, .aggregate() (and the sum/avg/min/max helpers), and nested paths that cannot be rebuilt — the last from .exclude() itself, before the query runs.

Implementation notes

Results are parsed with a cached subclass where the excluded fields become Optional = None; it still passes isinstance checks. Two details are worth flagging for review:

  • get_exclusion_model() re-declares every inherited field, not just the excluded ones. This looks redundant but is load-bearing: init_beanie installs ExpressionField class attributes on document models, and Pydantic adopts those as field defaults in a subclass unless the field is re-declared. Without it every field defaults to the string of its own name — revision_id becomes "revision_id", which lands on every excluded document and is written back on the next save.
  • The exclusion is applied inside parse_obj, after union / inheritance dispatch. Applying it earlier would relax the class the query was issued on, and the _class_id dispatch would then hand the document to the original child class, which still requires the excluded field.

fetch_links=True runs through an aggregation, where exclusion uses $unset rather than $project, which is unreliable for non-_id fields on some MongoDB versions. Field names are normalised to Python paths at .exclude() call time and converted to MongoDB names only when the query is built.

Tests

18 tests covering query building, parsing, inheritance/UnionDoc, nested paths, caching, lazy_parse, the restrictions and the write guard. The cache-key and lazy_parse tests were checked against deliberately reintroduced regressions to confirm they fail when the behaviour breaks.

Full suite: 596 passed, verified against MongoDB 8.0 with a replica set. ruff, ruff-format and mypy report nothing new versus main.

Add .exclude(*fields) to FindMany and FindOne, so a query can drop a few
fields without defining a projection model listing every other one.

- Accepts Python names, MongoDB aliases, ExpressionField refs and dotted
  paths into embedded models; repeated calls accumulate.
- Results are parsed with a cached subclass where excluded fields become
  Optional=None. Every inherited field is re-declared explicitly, since
  init_beanie installs ExpressionField class attributes that Pydantic
  would otherwise adopt as field defaults.
- The exclusion is applied inside parse_obj, after union / inheritance
  dispatch, so with_children and UnionDoc queries relax the concrete
  class each document belongs to.
- Uses $unset in aggregation pipelines, a plain projection otherwise.
- Rejects combinations that cannot work: project() with a different
  model, aggregate(), and nested paths that cannot be rebuilt.
- Whole-document writes raise DocumentWasPartiallyLoaded instead of
  overwriting excluded values with None; save_changes() and set() still
  work, as they only touch named fields.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant