Skillversitas

Querying Relationships

Querying through relationships is where the NoSQL shape shows: there is no JOIN operator, so "posts by authors named Ada" is not one query but a composition - resolve the author(s) first, then filter the posts by their ObjectIds. MongoEngine's querysets make the composition ergonomic, and a few patterns - subquery-style __in, dot-path filters into embedded documents, and bulk dereferencing - cover essentially all relationship queries in an application.

Forward: Through a Reference

# step 1: resolve the author
ada = User.objects.get(username="ada")

# step 2: filter the other collection by the reference value
posts = Post.objects(author=ada)            # or author=ada.id - same query
recent = Post.objects(author=ada).order_by("-published_at")[:10]

A typed reference filters by ObjectId directly, and the author index (declared in the post's meta) makes it a fast indexed scan. This two-step shape is the fundamental unit - everything else is a variation on it.

The Subquery Pattern: Filter by Target Properties

# "posts by any admin user" - resolve the set, then filter by membership
admin_ids = User.objects(roles__in=["admin"])
Post.objects(author__in=admin_ids)

# as a one-liner - the queryset inside __in is executed as a subquery
Post.objects(author__in=User.objects(roles__in=["admin"]))

# multi-hop: "posts co-authored by someone who wrote a verified account"
verified = User.objects(verified=True)
Post.objects(co_authors__in=verified)

Putting a queryset inside __in is MongoEngine's subquery: it executes the inner query, collects the ObjectIds, and builds an $in on the outer. It is two server round trips that read like one expression, and it is the standard way to filter a collection by properties of the documents it references. The practical limit is scale - if the inner set is huge, fetch just the ids with .only("_id") or page the inner query.

Into Embedded Documents

class Order(Document):
    customer = ReferenceField(User)
    items = ListField(EmbeddedDocumentField(OrderItem))   # qty, product...

# orders containing a specific product, via dot-path into the array
Order.objects(items__product="sku-42")

# with conditions on the embedded fields
Order.objects(items__qty__gte=5)

# the parent reference and the embedded array in one query
Order.objects(customer=cust, items__product="sku-42")

Dot-path filters cross the embedded boundary without a second query: items__product="x" matches any order whose item array contains that product. Combined with a reference filter, one query answers "this customer's orders containing this product" - the embedded children make the parent queryable by their contents, which is the main reason to embed in the first place.

Rendering the Other Side: Bulk Dereference

posts = list(Post.objects(author__in=admins).limit(50))

# N+1 trap: one dereference per post
# good: collect ids, load once, zip back
author_ids = [p.author for p in posts]
authors = {u.id: u for u in User.objects(in_=author_ids)}
for p in posts:
    p.author_doc = authors.get(p.author)      # no extra queries in the loop

The list-rendering pattern: fetch the result set, collect the referenced ObjectIds, run one in_ query, and join in memory with a dictionary. Fifty posts means fifty dereferences the naive way and two queries the bulk way - the difference is usually the difference between a fast page and a slow one.

Reverse: What Points at X

# per referencing field - the honest MongoDB way
ada_posts = Post.objects(author=ada)
ada_comments = Comment.objects(parent=ada)       # if comments reference users

# a denormalized feed avoids querying N collections per user
class User(Document):
    recent_activity = ListField(ReferenceField(), default=list)

Reverse views are either a union of per-collection queries (fine for a few known types) or a maintained denormalization (a feed list on the target, updated when children change). Choose based on how often the reverse view is read and how many collections can contribute.

Summary

Compose relationship queries as resolve-then-filter: fetch the referencing set (a document, a list of ids, or a subquery via __in), then filter the target collection by reference value - with the reference field indexed. Use dot-path filters to reach into embedded documents in the same query, render lists with one bulk in_ load instead of per-row dereferences, and treat reverse views as an explicit design: per-collection queries for few types, a denormalized feed for hot ones.