Skip to content

Commit 7358f2e

Browse files
committed
refactor: introduce $populate operator for relation loading and enforce separation of scalar and relation fields
1 parent 48c7da1 commit 7358f2e

28 files changed

Lines changed: 941 additions & 325 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. Please add
44

55
date format is [yyyy-mm-dd]
66

7+
## [0.8.4] - 2026-04-11
8+
9+
### Features
10+
11+
- Added first-class `$populate` (relations) and `$exclude` (subtractive scalar projection), while keeping backward compatibility for legacy relation keys in `$select`.
12+
13+
### Breaking Changes (Public util surface)
14+
15+
- Removed support for specifying relations in `$select`; use `$populate` instead.
16+
17+
### Improvements
18+
19+
- Populate-only relation loading now works consistently in SQL dialects.
20+
- Relation keys in `$select` now emit a deduplicated deprecation warning (use `$populate`).
21+
- `$select` and `$exclude` conflicts are validated recursively, including nested relation queries.
22+
- `findManyStream` now rejects unsupported relation loading early: **MongoDB** throws if any relation is requested in `$select` / `$populate` (streams use `find` only). **SQL** throws if **to-many** relations are requested (they are filled only after `findMany`, not while streaming).
23+
24+
### Documentation
25+
26+
- README and docs site clarify projection keys (`$select` / `$exclude` / `$populate`) and streaming behavior across SQL vs MongoDB.
27+
728
## [0.8.3] - 2026-04-04
829

930
### Improvements

README.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
```ts
1010
const results = await querier.findMany(User, {
11-
$select: { name: true, profile: { $select: { picture: true } } },
11+
$select: { name: true },
12+
$populate: { profile: { $select: { picture: true } } },
1213
$where: { name: { $istartsWith: 'a' }, posts: { tags: { name: 'typescript' } } },
1314
$sort: { createdAt: 'desc' },
1415
$limit: 10,
@@ -346,10 +347,8 @@ UQL provides a straightforward API to interact with your data. **Always ensure q
346347
const querier = await pool.getQuerier();
347348
try {
348349
const results = await querier.findMany(User, {
349-
$select: {
350-
name: true,
351-
profile: { $select: { bio: true }, $required: true }, // INNER JOIN
352-
},
350+
$select: { name: true },
351+
$populate: { profile: { $select: { bio: true }, $required: true } }, // INNER JOIN
353352
$where: {
354353
status: 'active',
355354
name: { $istartsWith: 'a' },
@@ -371,6 +370,34 @@ WHERE "User"."status" = 'active' AND "User"."name" ILIKE 'a%'
371370
LIMIT 10 OFFSET 0
372371
```
373372

373+
### Projection & Relation Loading
374+
375+
Use these query keys with clear separation of concerns:
376+
377+
- `$select`: scalar field whitelist (projection)
378+
- `$exclude`: scalar field subtraction from default eager set
379+
- `$populate`: relation loading (including nested relation query options)
380+
381+
`$select` and `$exclude` are mutually exclusive when `$select` includes positive scalar keys.
382+
383+
```ts
384+
const rows = await querier.findMany(User, {
385+
$exclude: { password: true },
386+
$populate: { profile: { $select: { picture: true } } },
387+
});
388+
```
389+
390+
> **Migration note:** selecting relations inside `$select` is still supported for backward compatibility, but deprecated. Move relations to `$populate`.
391+
392+
### Streaming (`findManyStream`) and relations
393+
394+
`findManyStream` is optimized for **scalar** reads and a stable memory footprint. Relation loading differs by backend:
395+
396+
- **SQL:** Joinable relations (e.g. many-to-one, one-to-one) are still included in the streamed `SELECT` / joins. **To-many** collections are not filled on the stream path (that uses extra queries in `findMany`); requesting them in `$select` / `$populate` throws a `TypeError` with a short explanation.
397+
- **MongoDB:** Streams use a plain `find` cursor, so **any** relation keys in `$select` or `$populate` throw a `TypeError`. Use `findMany` for relation loading (aggregation + fill).
398+
399+
See [Cursor Streaming](https://uql-orm.dev/querying/streaming) on the docs site for details.
400+
374401
### Advanced Query Patterns
375402

376403
### Modern Indexing: Semantic Search

bun.lock

Lines changed: 62 additions & 82 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,22 @@
4242
}
4343
},
4444
"devDependencies": {
45-
"@biomejs/biome": "2.4.10",
45+
"@biomejs/biome": "2.4.11",
4646
"@commitlint/cli": "20.5.0",
4747
"@commitlint/config-conventional": "20.5.0",
4848
"@swc/core": "^1.15.24",
4949
"@swc/helpers": "^0.5.21",
50-
"@types/bun": "^1.3.11",
50+
"@types/bun": "^1.3.12",
5151
"@types/supertest": "^7.2.0",
52-
"@vitest/coverage-v8": "^4.1.2",
52+
"@vitest/coverage-v8": "^4.1.4",
5353
"lerna": "^9.0.7",
5454
"mongodb-memory-server": "11.0.1",
5555
"rimraf": "6.1.3",
5656
"supertest": "^7.2.2",
5757
"typescript": "^6.0.2",
5858
"unplugin-swc": "^1.5.9",
59-
"vite": "^8.0.3",
60-
"vitest": "^4.1.2"
59+
"vite": "^8.0.8",
60+
"vitest": "^4.1.4"
6161
},
6262
"author": "Roger Padilla"
6363
}

packages/uql-orm/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,12 @@
107107
"@types/express": "^5.0.6",
108108
"@types/pg": "^8.20.0",
109109
"@types/sqlstring": "^2.3.2",
110-
"better-sqlite3": "^12.8.0",
111-
"bunchee": "^6.9.4",
110+
"better-sqlite3": "^12.9.0",
111+
"bunchee": "^6.10.0",
112112
"express": "^5.2.1",
113113
"mariadb": "^3.5.2",
114114
"mongodb": "^7.1.1",
115-
"mysql2": "^3.20.0",
115+
"mysql2": "^3.22.0",
116116
"pg": "^8.20.0",
117117
"pg-query-stream": "^4.14.0"
118118
},

packages/uql-orm/src/browser/querier/querier.util.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ it('stringifyQuery', () => {
2323
expect(stringifyQuery({})).toBe('');
2424
expect(stringifyQuery({ $sort: undefined })).toBe('');
2525
const source: Query<Item> = {
26-
$select: { id: 1, name: 1, tax: true, measureUnit: { $select: { id: 1, name: 1, categoryId: 1 } } },
26+
$select: { id: 1, name: 1 },
27+
$populate: { tax: true, measureUnit: { $select: { id: 1, name: 1, categoryId: 1 } } },
2728
$where: { name: 'Batman', companyId: 38 },
2829
$sort: { companyId: 1, name: -1 },
2930
$limit: 5,
3031
};
3132
const result = stringifyQuery(source);
3233
const expected =
33-
'?$select={"id":1,"name":1,"tax":true,"measureUnit":{"$select":{"id":1,"name":1,"categoryId":1}}}&$where={"name":"Batman","companyId":38}&$sort={"companyId":1,"name":-1}&$limit=5';
34+
'?$select={"id":1,"name":1}&$populate={"tax":true,"measureUnit":{"$select":{"id":1,"name":1,"categoryId":1}}}&$where={"name":"Batman","companyId":38}&$sort={"companyId":1,"name":-1}&$limit=5';
3435
expect(result).toBe(expected);
3536
});

packages/uql-orm/src/dialect/abstractSqlDialect-spec.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,22 @@ export abstract class AbstractSqlDialectSpec implements Spec {
282282
expect(res.values).toEqual([123, 'abc']);
283283
}
284284

285+
shouldFindWithPopulateOnly() {
286+
const res = this.exec((ctx) =>
287+
this.dialect.find(ctx, User, {
288+
$populate: {
289+
profile: {
290+
$select: { picture: true },
291+
},
292+
},
293+
$where: { id: 123 },
294+
}),
295+
);
296+
expect(res.sql).toContain('LEFT JOIN `user_profile` `profile` ON `profile`.`creatorId` = `User`.`id`');
297+
expect(res.sql).toContain('`profile`.`image` `profile.picture`');
298+
expect(res.values).toEqual([123]);
299+
}
300+
285301
shouldBeSecure() {
286302
let res = this.exec((ctx) =>
287303
this.dialect.find(ctx, User, {
@@ -687,7 +703,9 @@ export abstract class AbstractSqlDialectSpec implements Spec {
687703
}
688704

689705
shouldFind$selectFields() {
690-
const { sql } = this.exec((ctx) => this.dialect.find(ctx, User, { $select: { id: true, company: true } }));
706+
const { sql } = this.exec((ctx) =>
707+
this.dialect.find(ctx, User, { $select: { id: true }, $populate: { company: true } }),
708+
);
691709
expect(sql).toBe(
692710
'SELECT `User`.`id`, `company`.`id` `company.id`, `company`.`companyId` `company.companyId`, `company`.`creatorId` `company.creatorId`' +
693711
', `company`.`createdAt` `company.createdAt`, `company`.`updatedAt` `company.updatedAt`' +
@@ -699,15 +717,16 @@ export abstract class AbstractSqlDialectSpec implements Spec {
699717
shouldFind$selectOneToOne() {
700718
let res = this.exec((ctx) =>
701719
this.dialect.find(ctx, User, {
702-
$select: { id: true, name: true, profile: { $select: { id: true, picture: true } } },
720+
$select: { id: true, name: true },
721+
$populate: { profile: { $select: { id: true, picture: true } } },
703722
}),
704723
);
705724
expect(res.sql).toBe(
706725
'SELECT `User`.`id`, `User`.`name`, `profile`.`pk` `profile.pk`, `profile`.`image` `profile.picture` FROM `User`' +
707726
' LEFT JOIN `user_profile` `profile` ON `profile`.`creatorId` = `User`.`id`',
708727
);
709728

710-
res = this.exec((ctx) => this.dialect.find(ctx, User, { $select: { profile: true } }));
729+
res = this.exec((ctx) => this.dialect.find(ctx, User, { $populate: { profile: true } }));
711730
expect(res.sql).toBe(
712731
'SELECT `User`.`id`, `User`.`companyId`, `User`.`creatorId`, `User`.`createdAt`' +
713732
', `User`.`updatedAt`, `User`.`name`, `User`.`email`' +
@@ -726,6 +745,8 @@ export abstract class AbstractSqlDialectSpec implements Spec {
726745
id: true,
727746
name: true,
728747
code: true,
748+
},
749+
$populate: {
729750
tax: { $select: { id: true, name: true }, $required: true },
730751
measureUnit: { $select: { id: true, name: true, categoryId: true } },
731752
},
@@ -749,8 +770,10 @@ export abstract class AbstractSqlDialectSpec implements Spec {
749770
$select: {
750771
id: true,
751772
name: true,
773+
},
774+
$populate: {
752775
measureUnit: { $select: { id: true, name: true }, $where: { name: { $ne: 'unidad' } }, $required: true },
753-
tax: ['id', 'name'] as any,
776+
tax: { $select: { id: true, name: true } },
754777
},
755778
$where: { salePrice: { $gte: 1000 }, name: { $istartsWith: 'A' } },
756779
$sort: { tax: { name: 1 }, measureUnit: { name: 1 }, createdAt: -1 },
@@ -793,8 +816,11 @@ export abstract class AbstractSqlDialectSpec implements Spec {
793816
name: 1,
794817
code: 1,
795818
tagsCount: 1,
819+
},
820+
$populate: {
796821
measureUnit: {
797-
$select: { id: 1, name: 1, categoryId: 1, category: ['name'] },
822+
$select: { id: 1, name: 1, categoryId: 1 },
823+
$populate: { category: { $select: { name: 1 } } },
798824
},
799825
},
800826
$limit: 100,
@@ -818,8 +844,11 @@ export abstract class AbstractSqlDialectSpec implements Spec {
818844
id: 1,
819845
name: 1,
820846
code: 1,
847+
},
848+
$populate: {
821849
measureUnit: {
822-
$select: { id: 1, name: 1, categoryId: 1, category: ['name'] },
850+
$select: { id: 1, name: 1, categoryId: 1 },
851+
$populate: { category: { $select: { name: 1 } } },
823852
},
824853
},
825854
$limit: 100,
@@ -841,8 +870,11 @@ export abstract class AbstractSqlDialectSpec implements Spec {
841870
id: true,
842871
name: true,
843872
code: true,
873+
},
874+
$populate: {
844875
measureUnit: {
845-
$select: { id: true, name: true, category: { $select: { id: true, name: true } } },
876+
$select: { id: true, name: true },
877+
$populate: { category: { $select: { id: true, name: true } } },
846878
},
847879
},
848880
$limit: 100,
@@ -863,12 +895,17 @@ export abstract class AbstractSqlDialectSpec implements Spec {
863895
id: true,
864896
buyPrice: true,
865897
number: true,
898+
},
899+
$populate: {
866900
item: {
867901
$select: {
868902
id: true,
869903
name: true,
904+
},
905+
$populate: {
870906
measureUnit: {
871-
$select: { id: true, name: true, category: ['id', 'name'] },
907+
$select: { id: true, name: true },
908+
$populate: { category: { $select: { id: true, name: true } } },
872909
},
873910
},
874911
$required: true,

0 commit comments

Comments
 (0)