bigal 16.0.0-beta.3 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,18 @@
1
1
  ---
2
- description: Fluent query builder for find, findOne, and count with WHERE operators, JSONB querying, pagination, sorting, vector distance, global filters, toSQL, and populate. All results are plain objects.
2
+ description: Fluent query builder for find, findOne, and count with WHERE operators, JSONB querying, pagination, sorting, DISTINCT ON, and populate.
3
3
  ---
4
4
 
5
5
  # Querying
6
6
 
7
- BigAl provides `findOne()`, `find()`, and `count()` methods on repositories. Queries use a fluent
8
- builder pattern - each method returns a new immutable instance, and queries are `PromiseLike` so you
9
- can `await` them directly.
7
+ BigAl provides `findOne()`, `find()`, and `count()` methods on repositories. Queries use a fluent builder pattern —
8
+ each method returns a new immutable instance, and queries are `PromiseLike` so you can `await` them directly.
10
9
 
11
10
  ## findOne
12
11
 
13
12
  Returns a single record or `null`:
14
13
 
15
14
  ```ts
16
- const product = await Product.findOne().where({ id: 42 });
15
+ const product = await productRepository.findOne().where({ id: 42 });
17
16
  ```
18
17
 
19
18
  ### Query projection
@@ -21,9 +20,11 @@ const product = await Product.findOne().where({ id: 42 });
21
20
  Select specific columns:
22
21
 
23
22
  ```ts
24
- const product = await Product.findOne({
25
- select: ['name', 'sku'],
26
- }).where({ id: 42 });
23
+ const product = await productRepository
24
+ .findOne({
25
+ select: ['name', 'sku'],
26
+ })
27
+ .where({ id: 42 });
27
28
  ```
28
29
 
29
30
  ### Pool override
@@ -31,9 +32,11 @@ const product = await Product.findOne({
31
32
  Use an explicit connection pool:
32
33
 
33
34
  ```ts
34
- const product = await Product.findOne({
35
- pool: poolOverride,
36
- }).where({ id: 42 });
35
+ const product = await productRepository
36
+ .findOne({
37
+ pool: poolOverride,
38
+ })
39
+ .where({ id: 42 });
37
40
  ```
38
41
 
39
42
  ## find
@@ -41,7 +44,7 @@ const product = await Product.findOne({
41
44
  Returns an array of records:
42
45
 
43
46
  ```ts
44
- const products = await Product.find().where({ store: storeId });
47
+ const products = await productRepository.find().where({ store: storeId });
45
48
  ```
46
49
 
47
50
  ## count
@@ -49,11 +52,17 @@ const products = await Product.find().where({ store: storeId });
49
52
  Returns the number of matching records:
50
53
 
51
54
  ```ts
52
- const count = await Product.count().where({
55
+ const count = await productRepository.count().where({
53
56
  name: { like: 'Widget%' },
54
57
  });
55
58
  ```
56
59
 
60
+ If you only need to know whether a match exists, use `count()` instead of `findOne()` — it performs better since it doesn't select or hydrate a row:
61
+
62
+ ```ts
63
+ const exists = (await productRepository.count().where({ sku: 'ABC123' })) > 0;
64
+ ```
65
+
57
66
  ## Where operators
58
67
 
59
68
  ### String matching
@@ -68,10 +77,10 @@ All string operators use case-insensitive matching (`ILIKE`) and accept arrays f
68
77
  | `endsWith` | Suffix match | `%value` |
69
78
 
70
79
  ```ts
71
- await Product.find().where({ name: { contains: 'widget' } });
80
+ await productRepository.find().where({ name: { contains: 'widget' } });
72
81
  // SQL: WHERE name ILIKE '%widget%'
73
82
 
74
- await Product.find().where({ name: { startsWith: 'Pro' } });
83
+ await productRepository.find().where({ name: { startsWith: 'Pro' } });
75
84
  // SQL: WHERE name ILIKE 'Pro%'
76
85
  ```
77
86
 
@@ -85,10 +94,10 @@ await Product.find().where({ name: { startsWith: 'Pro' } });
85
94
  | `>=` | Greater than or equal |
86
95
 
87
96
  ```ts
88
- await Product.find().where({ price: { '>=': 100 } });
97
+ await productRepository.find().where({ price: { '>=': 100 } });
89
98
 
90
99
  // Multiple operators on same field (AND)
91
- await Product.find().where({
100
+ await productRepository.find().where({
92
101
  createdAt: { '>=': startDate, '<': endDate },
93
102
  });
94
103
  ```
@@ -103,13 +112,13 @@ await personRepository.find().where({ age: [22, 23, 24] });
103
112
  ### Negation (`!`)
104
113
 
105
114
  ```ts
106
- await Product.find().where({ status: { '!': 'discontinued' } });
115
+ await productRepository.find().where({ status: { '!': 'discontinued' } });
107
116
  // SQL: WHERE status <> $1
108
117
 
109
- await Product.find().where({ status: { '!': ['a', 'b'] } });
118
+ await productRepository.find().where({ status: { '!': ['a', 'b'] } });
110
119
  // SQL: WHERE status NOT IN ($1, $2)
111
120
 
112
- await Product.find().where({ deletedAt: { '!': null } });
121
+ await productRepository.find().where({ deletedAt: { '!': null } });
113
122
  // SQL: WHERE deleted_at IS NOT NULL
114
123
  ```
115
124
 
@@ -122,7 +131,7 @@ await personRepository.find().where({
122
131
  // SQL: WHERE (first_name = $1) OR (last_name = $2)
123
132
  ```
124
133
 
125
- ### Nested AND / OR
134
+ ### AND with nested OR
126
135
 
127
136
  ```ts
128
137
  await personRepository.find().where({
@@ -177,28 +186,11 @@ await repo.find().where({ bar: { theme: { '!': null } } });
177
186
  // SQL: WHERE "bar"->>'theme' IS NOT NULL
178
187
  ```
179
188
 
180
- Note that `IS NULL` on a JSONB property is true both when the key is missing from the object and when
181
- it is explicitly set to `null`. This matches PostgreSQL's behavior - the `->>` operator returns
182
- `NULL` in both cases.
183
-
184
- Properties set to `undefined` in a where clause are silently ignored (standard JavaScript -
185
- `undefined` values are dropped by `Object.entries`). To query for missing or null properties, always
186
- use `null` explicitly.
189
+ Note that `IS NULL` on a JSONB property is true both when the key is missing from the object and when it is
190
+ explicitly set to `null`. This matches PostgreSQL's behavior the `->>` operator returns `NULL` in both cases.
187
191
 
188
- ### String matching on JSON properties
189
-
190
- The same string operators available on regular columns work on JSONB properties:
191
-
192
- ```ts
193
- await repo.find().where({ bar: { theme: { contains: 'dark' } } });
194
- // SQL: WHERE "bar"->>'theme' ILIKE '%dark%'
195
-
196
- await repo.find().where({ bar: { theme: { startsWith: 'mid' } } });
197
- // SQL: WHERE "bar"->>'theme' ILIKE 'mid%'
198
-
199
- await repo.find().where({ bar: { failure: { message: { contains: 'timeout' } } } });
200
- // SQL: WHERE "bar"->'failure'->>'message' ILIKE '%timeout%'
201
- ```
192
+ Properties set to `undefined` in a where clause are silently ignored (standard JavaScript — `undefined` values are
193
+ dropped by `Object.entries`). To query for missing or null properties, always use `null` explicitly.
202
194
 
203
195
  ### JSONB containment
204
196
 
@@ -216,49 +208,51 @@ await repo.find().where({
216
208
  ### String syntax
217
209
 
218
210
  ```ts
219
- await Product.find().where({}).sort('name asc');
220
- await Product.find().where({}).sort('name asc, createdAt desc');
211
+ await productRepository.find().where({}).sort('name asc');
212
+ await productRepository.find().where({}).sort('name asc, createdAt desc');
221
213
  ```
222
214
 
223
215
  ### Object syntax
224
216
 
225
217
  ```ts
226
- await Product.find().where({}).sort({ name: 1 }); // ASC
227
- await Product.find().where({}).sort({ name: 1, createdAt: -1 }); // ASC, DESC
218
+ await productRepository.find().where({}).sort({ name: 1 }); // ASC
219
+ await productRepository.find().where({}).sort({ name: 1, createdAt: -1 }); // ASC, DESC
228
220
  ```
229
221
 
230
222
  ## Vector distance queries
231
223
 
232
- BigAl supports nearest-neighbor queries on `vector()` columns using pgvector. Four distance metrics
233
- are available: `cosine`, `l2`, `l1`, and `innerProduct`.
224
+ BigAl supports nearest-neighbor queries on columns declared with `@column({ type: 'vector', dimensions: n })`,
225
+ backed by the [pgvector](https://github.com/pgvector/pgvector) extension. Four distance metrics are
226
+ available: `cosine`, `l2`, `l1`, and `innerProduct`. The `l1` metric requires pgvector >= 0.7.0.
227
+
228
+ | Metric | PostgreSQL operator | Description |
229
+ | -------------- | ------------------- | ------------------------- |
230
+ | `cosine` | `<=>` | Cosine distance (default) |
231
+ | `l2` | `<->` | Euclidean distance |
232
+ | `l1` | `<+>` | Manhattan distance |
233
+ | `innerProduct` | `<#>` | Negative inner product |
234
234
 
235
235
  ### Sorting by distance
236
236
 
237
237
  Use the `nearestTo` sort to order results by vector similarity:
238
238
 
239
239
  ```ts
240
- const similar = await documentRepo
240
+ const similar = await documentRepository
241
241
  .find()
242
- .where({})
242
+ .where({ title: { contains: 'biology' } })
243
243
  .sort({ embedding: { nearestTo: queryVector, metric: 'cosine' } })
244
244
  .limit(10);
245
+ // SQL: ... WHERE "title" ILIKE $1 ORDER BY "embedding" <=> $2 LIMIT 10
245
246
  ```
246
247
 
247
- The `metric` option defaults to `'cosine'` if omitted.
248
-
249
- | Metric | PostgreSQL operator | Description |
250
- | -------------- | ------------------- | ------------------------- |
251
- | `cosine` | `<=>` | Cosine distance (default) |
252
- | `l2` | `<->` | Euclidean distance |
253
- | `l1` | `<+>` | Manhattan distance |
254
- | `innerProduct` | `<#>` | Negative inner product |
248
+ The `metric` option defaults to `'cosine'` if omitted. An unknown metric throws a `QueryError`.
255
249
 
256
250
  ### Filtering by distance
257
251
 
258
252
  Combine `nearestTo` in the where clause with a distance threshold:
259
253
 
260
254
  ```ts
261
- const nearby = await documentRepo
255
+ const nearby = await documentRepository
262
256
  .find()
263
257
  .where({
264
258
  embedding: {
@@ -269,6 +263,21 @@ const nearby = await documentRepo
269
263
  })
270
264
  .sort({ embedding: { nearestTo: queryVector, metric: 'cosine' } })
271
265
  .limit(10);
266
+ // SQL: ... WHERE "embedding" <=> $1 < $2 ORDER BY "embedding" <=> $3 LIMIT 10
267
+ ```
268
+
269
+ At least one `distance` bound is required in where clauses; multiple bounds are combined with `AND`
270
+ (for example `distance: { '>': 0.1, '<': 0.5 }` finds a distance band). Vectors must be non-empty
271
+ arrays of finite numbers.
272
+
273
+ ### Equality and writes
274
+
275
+ Vector values round-trip as `number[]`. Where clauses compare whole vectors, and create/update
276
+ serialize the array to pgvector's text format:
277
+
278
+ ```ts
279
+ await documentRepository.create({ title: 'foo', embedding: [0.1, 0.2, 0.3] }); // Sends '[0.1,0.2,0.3]'
280
+ await documentRepository.findOne({ embedding: queryVector }); // WHERE "embedding"=$1
272
281
  ```
273
282
 
274
283
  ## Pagination
@@ -276,7 +285,7 @@ const nearby = await documentRepo
276
285
  ### skip and limit
277
286
 
278
287
  ```ts
279
- await Product.find().where({}).skip(20).limit(10);
288
+ await productRepository.find().where({}).skip(20).limit(10);
280
289
  ```
281
290
 
282
291
  ### paginate
@@ -284,7 +293,7 @@ await Product.find().where({}).skip(20).limit(10);
284
293
  ```ts
285
294
  const page = 2;
286
295
  const pageSize = 25;
287
- await Product.find().where({}).paginate(page, pageSize);
296
+ await productRepository.find().where({}).paginate(page, pageSize);
288
297
  ```
289
298
 
290
299
  ### withCount
@@ -292,7 +301,7 @@ await Product.find().where({}).paginate(page, pageSize);
292
301
  Get paginated results with total count in a single query using `COUNT(*) OVER()`:
293
302
 
294
303
  ```ts
295
- const { results, totalCount } = await Product.find().where({ store: storeId }).sort('name').limit(10).skip(20).withCount();
304
+ const { results, totalCount } = await productRepository.find().where({ store: storeId }).sort('name').limit(10).skip(20).withCount();
296
305
 
297
306
  const totalPages = Math.ceil(totalCount / 10);
298
307
  ```
@@ -303,7 +312,7 @@ PostgreSQL's `DISTINCT ON` returns one row per unique combination of columns:
303
312
 
304
313
  ```ts
305
314
  // Most recently created product per store
306
- const latest = await Product.find().distinctOn(['store']).sort('store').sort('createdAt desc');
315
+ const latest = await productRepository.find().distinctOn(['store']).sort('store').sort('createdAt desc');
307
316
  ```
308
317
 
309
318
  Requirements:
@@ -311,49 +320,24 @@ Requirements:
311
320
  - `ORDER BY` is required and must start with the `DISTINCT ON` columns
312
321
  - Cannot be combined with `withCount()`
313
322
 
314
- ## Global filters
315
-
316
- When a table defines named filters (see [Models > Global filters](/guide/models#global-filters)),
317
- they are automatically applied to every `find` and `findOne` query. Override them per query:
318
-
319
- ```ts
320
- // Disable all filters for this query
321
- await Product.find().where({}).filters(false);
322
-
323
- // Disable a specific filter
324
- await Product.find().where({}).filters({ active: false });
325
- ```
326
-
327
- Filters are not applied to `count()` queries.
328
-
329
- ## toSQL()
330
-
331
- Inspect the generated SQL and parameters without executing the query:
332
-
333
- ```ts
334
- const { sql, params } = Product.find()
335
- .where({ name: { contains: 'widget' } })
336
- .sort('name')
337
- .toSQL();
338
-
339
- console.log(sql); // SELECT ... FROM "products" WHERE "name" ILIKE $1 ORDER BY ...
340
- console.log(params); // ['%widget%']
341
- ```
342
-
343
- Available on `find`, `findOne`, `create`, `update`, and `destroy`. Useful for debugging, logging,
344
- and testing SQL generation.
345
-
346
323
  ## Populate
347
324
 
348
325
  Load related entities:
349
326
 
350
327
  ```ts
351
- const product = await Product.findOne()
328
+ const product = await productRepository
329
+ .findOne()
352
330
  .where({ id: 42 })
353
331
  .populate('store', { select: ['name'] });
354
332
 
355
- // product.store is the full Store entity (not just the FK number)
333
+ // product.store is the full Store entity
356
334
  console.log(product.store.name);
357
335
  ```
358
336
 
359
- All query results are plain objects - no `.toJSON()` conversion needed.
337
+ ## toJSON
338
+
339
+ Return plain objects without class prototypes:
340
+
341
+ ```ts
342
+ const product = await productRepository.findOne().where({ id: 42 }).toJSON();
343
+ ```
@@ -1,154 +1,188 @@
1
1
  ---
2
- description: Many-to-one, one-to-many, and many-to-many relationships with belongsTo and hasMany builders, QueryResult type narrowing, and populate options.
2
+ description: Many-to-one, one-to-many, and many-to-many relationships with decorators, QueryResult type narrowing, and populate options.
3
3
  ---
4
4
 
5
5
  # Relationships
6
6
 
7
- BigAl supports three relationship patterns: many-to-one, one-to-many, and many-to-many. Relationships
8
- use string model name references by default. Model names are auto-derived from table names (e.g.,
9
- `products` becomes `Product`).
7
+ BigAl supports three relationship patterns via the `@column` decorator: many-to-one, one-to-many, and many-to-many.
10
8
 
11
- ## Many-to-one (belongsTo)
9
+ ## Many-to-one (model)
12
10
 
13
- Use `belongsTo` when this table holds the foreign key:
11
+ Use `model` when the current entity holds the foreign key:
14
12
 
15
13
  ```ts
16
- import { belongsTo, table, serial, text, createdAt, updatedAt } from 'bigal';
17
-
18
- export const Product = table('products', {
19
- id: serial().primaryKey(),
20
- name: text().notNull(),
21
- store: belongsTo('Store'),
22
- createdAt: createdAt(),
23
- updatedAt: updatedAt(),
24
- });
14
+ import { column, Entity, primaryColumn, table } from 'bigal';
15
+ import type { Store } from './Store';
16
+
17
+ @table({ name: 'products' })
18
+ export class Product extends Entity {
19
+ @primaryColumn({ type: 'integer' })
20
+ public id!: number;
21
+
22
+ @column({ type: 'string', required: true })
23
+ public name!: string;
24
+
25
+ @column({ model: () => 'Store', name: 'store_id' })
26
+ public store!: number | Store;
27
+ }
25
28
  ```
26
29
 
27
- - The inferred select type for `store` is `number` (the FK value)
28
- - After `.populate('store')`, the type changes to the full Store entity
29
- - The FK column name is auto-derived as `snakeCase(propertyKey) + '_id'` (e.g., `store_id`)
30
- - Override the FK column name: `belongsTo('Store', { name: 'shop_id' })`
30
+ - The property type is `number | Store` foreign key when not populated, full entity after `.populate()`
31
+ - Use `name: 'store_id'` when the database column differs from the property name
32
+ - Reference the model by string name (`'Store'`) to avoid circular imports
33
+ - Model names are case-insensitive
31
34
 
32
- ## One-to-many (hasMany)
35
+ ## One-to-many (collection)
33
36
 
34
- Use `hasMany` on the inverse side:
37
+ Use `collection` on the inverse side:
35
38
 
36
39
  ```ts
37
- import { table, hasMany, serial, text, createdAt, updatedAt } from 'bigal';
38
-
39
- export const Store = table('stores', {
40
- id: serial().primaryKey(),
41
- name: text(),
42
- products: hasMany('Product').via('store'),
43
- createdAt: createdAt(),
44
- updatedAt: updatedAt(),
45
- });
40
+ import { column, Entity, primaryColumn, table } from 'bigal';
41
+ import type { Product } from './Product';
42
+
43
+ @table({ name: 'stores' })
44
+ export class Store extends Entity {
45
+ @primaryColumn({ type: 'integer' })
46
+ public id!: number;
47
+
48
+ @column({ type: 'string' })
49
+ public name?: string;
50
+
51
+ @column({ collection: () => 'Product', via: 'store' })
52
+ public products?: Product[];
53
+ }
46
54
  ```
47
55
 
48
- - `.via('store')` references the property name on the related table (not the database column)
49
- - `hasMany` columns appear in `InferSelect` as optional `Record<string, unknown>[]` to support
50
- populate. `QueryResult` strips them from results, so they only appear after `.populate()`
56
+ - `via` references the property name on the related model (not the database column)
57
+ - Collections **must** be optional (`?`) they are only present after `.populate()`
51
58
 
52
59
  ## Many-to-many (through)
53
60
 
54
- Use `.through()` for relationships via a junction table:
61
+ Use `through` for relationships that require a join table:
55
62
 
56
63
  ```ts
57
64
  // Product.ts
58
- export const Product = table('products', {
59
- id: serial().primaryKey(),
60
- name: text().notNull(),
61
- categories: hasMany('Category').through('ProductCategory').via('product'),
62
- });
65
+ @table({ name: 'products' })
66
+ export class Product extends Entity {
67
+ @primaryColumn({ type: 'integer' })
68
+ public id!: number;
69
+
70
+ @column({ type: 'string', required: true })
71
+ public name!: string;
72
+
73
+ @column({
74
+ collection: () => 'Category',
75
+ through: () => 'ProductCategory',
76
+ via: 'product',
77
+ })
78
+ public categories?: Category[];
79
+ }
63
80
  ```
64
81
 
65
82
  ```ts
66
83
  // Category.ts
67
- export const Category = table('categories', {
68
- id: serial().primaryKey(),
69
- name: text().notNull(),
70
- products: hasMany('Product').through('ProductCategory').via('category'),
71
- });
84
+ @table({ name: 'categories' })
85
+ export class Category extends Entity {
86
+ @primaryColumn({ type: 'integer' })
87
+ public id!: number;
88
+
89
+ @column({ type: 'string', required: true })
90
+ public name!: string;
91
+
92
+ @column({
93
+ collection: () => 'Product',
94
+ through: () => 'ProductCategory',
95
+ via: 'category',
96
+ })
97
+ public products?: Product[];
98
+ }
72
99
  ```
73
100
 
74
101
  ```ts
75
- // ProductCategory.ts (junction table)
76
- export const ProductCategory = table('product__category', {
77
- id: serial().primaryKey(),
78
- product: belongsTo('Product'),
79
- category: belongsTo('Category'),
80
- ordering: integer(),
81
- isPrimary: boolean(),
82
- });
102
+ // ProductCategory.ts (join table)
103
+ @table({ name: 'product__category' })
104
+ export class ProductCategory extends Entity {
105
+ @primaryColumn({ type: 'integer' })
106
+ public id!: number;
107
+
108
+ @column({ model: () => 'Product', name: 'product_id' })
109
+ public product!: number | Product;
110
+
111
+ @column({ model: () => 'Category', name: 'category_id' })
112
+ public category!: number | Category;
113
+ }
83
114
  ```
84
115
 
85
- - `.through()` specifies the junction table model name
86
- - `.via()` references the property on the junction table that points back to this table
87
- - The junction table must have `belongsTo` relationships to both sides
116
+ - `through` specifies the join table model
117
+ - `via` references the property on the join table that points back to this entity
118
+ - The join table must have `model` relationships to both sides
88
119
 
89
120
  ## Self-referencing relationships
90
121
 
91
- Models can reference themselves for hierarchical data. Use the model name string (auto-derived
92
- from the table name, or set via `modelName` option):
122
+ Entities can reference themselves for hierarchical data:
93
123
 
94
124
  ```ts
95
- export const Category = table('categories', {
96
- id: serial().primaryKey(),
97
- name: text().notNull(),
98
- parent: belongsTo('Category'),
99
- children: hasMany('Category').via('parent'),
100
- });
101
- ```
125
+ @table({ name: 'categories' })
126
+ export class Category extends Entity {
127
+ @primaryColumn({ type: 'integer' })
128
+ public id!: number;
129
+
130
+ @column({ type: 'string', required: true })
131
+ public name!: string;
102
132
 
103
- String references are resolved at `initialize()` time, so there is no circular import issue.
133
+ @column({ model: () => 'Category', name: 'parent_id' })
134
+ public parent?: number | Category | null;
135
+
136
+ @column({ collection: () => 'Category', via: 'parent' })
137
+ public children?: Category[];
138
+ }
139
+ ```
104
140
 
105
141
  ## QueryResult type narrowing
106
142
 
107
- When you query entities, BigAl returns `QueryResult<T>` which narrows relationship fields
108
- automatically:
143
+ When you query entities, BigAl returns `QueryResult<T>` which automatically narrows relationship fields:
109
144
 
110
145
  ```ts
111
- const product = await Product.findOne().where({ id: 1 });
146
+ const product = await productRepository.findOne().where({ id: 1 });
112
147
 
113
- // product.store is `number`, not a union type
148
+ // product.store is `number`, not `number | Store`
149
+ // QueryResult narrows the union automatically
114
150
  console.log(product.store); // number (the foreign key ID)
115
151
  ```
116
152
 
117
153
  The narrowing rules:
118
154
 
119
- | InferSelect type | QueryResult type |
120
- | -------------------------------------------------- | -------------------- |
121
- | `number` (belongsTo FK) | `number` |
122
- | `Record<string, unknown>[] \| undefined` (hasMany) | Stripped from result |
155
+ | Entity property type | QueryResult type |
156
+ | ------------------------- | -------------------- |
157
+ | `number \| Store` | `number` |
158
+ | `number \| Store \| null` | `number \| null` |
159
+ | `Product[]` (collection) | Excluded from result |
123
160
 
124
161
  ### Using QueryResult in type definitions
125
162
 
126
- `QueryResult` accepts a `TableDefinition` directly - no need to manually call `InferSelect`:
163
+ Use `Pick<QueryResult<T>, ...>` instead of `Pick<T, ...>` for derived types:
127
164
 
128
165
  ```ts
129
166
  import type { QueryResult } from 'bigal';
130
167
 
131
- // store is `number`, categories (hasMany) is stripped
132
- type ProductRow = QueryResult<typeof Product>;
168
+ // Correct: store is `number`
169
+ type ProductSummary = Pick<QueryResult<Product>, 'id' | 'name' | 'store'>;
133
170
 
134
- // Pick specific fields
135
- type ProductSummary = Pick<QueryResult<typeof Product>, 'id' | 'name' | 'store'>;
171
+ // Wrong: store is `number | Store`
172
+ type ProductSummaryWrong = Pick<Product, 'id' | 'name' | 'store'>;
136
173
  ```
137
174
 
138
- ## Typed populate
175
+ ## QueryResultPopulated
139
176
 
140
- When using object-style `initialize({ models: { Product, Store } })`, populate results are fully
141
- typed. The full models map is threaded through repositories, so `.populate('store')` resolves to the
142
- related entity type rather than `Record<string, unknown>`.
177
+ For type safety with populated relations:
143
178
 
144
179
  ```ts
145
- const product = await Product.findOne().where({ id: 1 }).populate('store');
180
+ import type { QueryResultPopulated } from 'bigal';
146
181
 
147
- // product.store is typed as the full Store entity, not unknown
182
+ // store is QueryResult<Store>
183
+ type ProductWithStore = QueryResultPopulated<Product, 'store'>;
148
184
  ```
149
185
 
150
- Array-style `initialize({ models: [...] })` still works but does not provide typed populate results.
151
-
152
186
  ## Populate with junction table filtering
153
187
 
154
188
  For many-to-many relationships, you can filter and sort by columns on the junction table:
@@ -173,7 +207,8 @@ const compilation = await compilationRepository
173
207
 
174
208
  ## Best practices
175
209
 
176
- 1. **Use `QueryResult<typeof Model>` for return types** - strips hasMany, narrows FK types
177
- 2. **Use string references for model relationships** - avoids circular import issues
178
- 3. **Use object-style `initialize()`** - enables typed populate results
179
- 4. **All relationships are validated at startup** - `initialize()` throws if a referenced model is missing from the `models` object/array
210
+ 1. **Use `QueryResult<T>` for return types** avoids union type ambiguity
211
+ 2. **Use string references for model names** prevents circular imports
212
+ 3. **Mark collections as optional** they are `undefined` unless populated
213
+ 4. **Avoid type assertions** `QueryResult` narrows types automatically
214
+ 5. **Use `.toJSON()` for serializable results** — strips class prototypes