Deep Relations
Querying relations
Section titled “Querying relations”UQL’s query syntax is context-aware. When you query a relation, the available fields and operators are automatically suggested and validated based on that related entity.
Basic Population
Section titled “Basic Population”You can load a relation and select its specific fields using $populate.
import { pool } from './uql.config.js';
import { User } from './shared/models/index.js';
const users = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { profile: { $select: { picture: true } } // Load specific fields from a 1-1 relation }, $where: { email: { $iincludes: '@example.com' } }});-- Main query with LEFT JOIN for OneToOne relationSELECT "User"."id", "User"."name", "profile"."id" "profile.id", -- the relation's primary key is always selected "profile"."picture" "profile.picture" -- Prefixed alias for unflatteningFROM "User"LEFT JOIN "Profile" "profile" ON "profile"."userId" = "User"."id" AND "profile"."deletedAt" IS NULLWHERE "User"."email" ILIKE $1-- values: ['%@example.com%']Advanced: Deep Selection & Mandatory Relations
Section titled “Advanced: Deep Selection & Mandatory Relations”Use $required: true inside a $populate block to enforce an INNER JOIN (by default UQL uses LEFT JOIN).
import { User } from './shared/models/index.js';
const latestUsersWithProfiles = await pool.findOne(User, { $select: { id: true, name: true }, $populate: { profile: { $select: { picture: true, bio: true }, $where: { bio: { $ne: null } }, $required: true // Enforce INNER JOIN } }, $sort: { createdAt: 'desc' },});-- INNER JOIN enforced by $required: trueSELECT "User"."id", "User"."name", "profile"."id" "profile.id", "profile"."picture" "profile.picture", "profile"."bio" "profile.bio"FROM "User"INNER JOIN "Profile" "profile" ON "profile"."userId" = "User"."id" AND "profile"."bio" IS NOT NULL AND "profile"."deletedAt" IS NULLORDER BY "User"."createdAt" DESCLIMIT 1Filtering on Related Collections
Section titled “Filtering on Related Collections”You can filter and sort when populating collections (One-to-Many or Many-to-Many).
import { User } from './shared/models/index.js';
const authorsWithPopularPosts = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true, createdAt: true }, $where: { title: { $iincludes: 'typescript' } }, $sort: { createdAt: 'desc' }, $limit: 5 } }, $where: { name: { $istartsWith: 'a' } }});-- Main query (parent rows)SELECT "User"."id", "User"."name" FROM "User"WHERE "User"."name" ILIKE $1-- OneToMany relation loaded via a second querySELECT "Post"."title", "Post"."createdAt", "Post"."authorId"FROM "Post"WHERE "Post"."authorId" IN ($1, $2, ...) AND "Post"."title" ILIKE $3ORDER BY "Post"."createdAt" DESCLIMIT 5-- values: [...parentIds, '%typescript%']Sorting by Related Fields
Section titled “Sorting by Related Fields”UQL allows sorting by fields of related entities directly in the $sort object.
import { Item } from './shared/models/index.js';
const items = await pool.findMany(Item, { $select: { id: true, name: true }, $sort: { tax: { name: 1 }, measureUnit: { name: 1 }, createdAt: 'desc' }});SELECT "Item"."id", "Item"."name"FROM "Item"LEFT JOIN "Tax" "tax" ON "tax"."id" = "Item"."taxId" AND "tax"."deletedAt" IS NULLLEFT JOIN "MeasureUnit" "measureUnit" ON "measureUnit"."id" = "Item"."measureUnitId" AND "measureUnit"."deletedAt" IS NULLORDER BY "tax"."name", "measureUnit"."name", "Item"."createdAt" DESC
Relation Filtering (EXISTS Subqueries)
Section titled “Relation Filtering (EXISTS Subqueries)”Filter parent entities based on conditions on their ManyToMany or OneToMany relations. UQL compiles the condition to an EXISTS subquery, so it never joins in and duplicates parent rows.
The related entity’s own filters apply inside the subquery (the junction’s too, for ManyToMany), so a parent never matches through a row the query could not read - a trashed child, or one outside a security: true filter’s scope. On MongoDB the same conditions compile to correlated $lookup stages, so the query is unchanged across drivers.
ManyToMany
Section titled “ManyToMany”import { Post } from './shared/models/index.js';
// Find all posts that have a tag named 'typescript'const posts = await pool.findMany(Post, { $where: { tags: { name: 'typescript' } },});SELECT * FROM "Post"WHERE EXISTS ( SELECT 1 FROM "PostTag" WHERE "PostTag"."postId" = "Post"."id" AND "PostTag"."tagId" IN ( SELECT "Tag"."id" FROM "Tag" WHERE "Tag"."name" = $1 AND "Tag"."deletedAt" IS NULL )) AND "Post"."deletedAt" IS NULLOneToMany
Section titled “OneToMany”// Find users who have authored posts with 'typescript' in the titleconst users = await pool.findMany(User, { $where: { posts: { title: { $iincludes: 'typescript' } } },});SELECT * FROM "User"WHERE EXISTS ( SELECT 1 FROM "Post" WHERE "Post"."authorId" = "User"."id" AND "Post"."title" ILIKE $1 AND "Post"."deletedAt" IS NULL) AND "User"."deletedAt" IS NULL-- values: ['%typescript%']Relation Count Filtering ($size Subqueries)
Section titled “Relation Count Filtering ($size Subqueries)”Filter parent entities by the number of related records using $size on a relation key, type-checked against your entity’s relations and compiled to a COUNT(*) subquery. Accepts a number for exact match or any comparison operator ($eq, $ne, $gt, $gte, $lt, $lte, $between).
The count is scoped by the related entity’s filters, exactly like the EXISTS form above, so it never counts rows the same query could not read.
OneToMany
Section titled “OneToMany”// Find categories with at least 2 measure unitsconst categories = await pool.findMany(MeasureUnitCategory, { $where: { measureUnits: { $size: { $gte: 2 } } },});SELECT * FROM "MeasureUnitCategory"WHERE (SELECT COUNT(*) FROM "MeasureUnit" WHERE "MeasureUnit"."categoryId" = "MeasureUnitCategory"."id" AND "MeasureUnit"."deletedAt" IS NULL) >= $1 AND "deletedAt" IS NULLManyToMany
Section titled “ManyToMany”// Find items with more than 5 tagsconst items = await pool.findMany(Item, { $where: { tags: { $size: { $gt: 5 } } },});-- Tag has no filters of its own, so the junction count stands alone; when it does have-- them, the counted junction rows narrow to the target ids that satisfy them.SELECT * FROM "Item"WHERE (SELECT COUNT(*) FROM "ItemTag" WHERE "ItemTag"."itemId" = "Item"."id") > $1Multiple Comparison Operators
Section titled “Multiple Comparison Operators”// Find items with between 2 and 10 tagsconst items = await pool.findMany(Item, { $where: { tags: { $size: { $between: [2, 10] } } },});Next Steps
Section titled “Next Steps”- Sub-Queries: Correlated sub-queries when the built-in ones are not enough.
- Relation Mapping: How the relations you query here are declared.
- Soft Delete: The filter that joins add to populated relations.
- Streaming: Relation loading rules change when streaming.