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.
- package/.devcontainer/devcontainer.json +1 -1
- package/.vite-hooks/pre-commit +1 -0
- package/CHANGELOG.md +25 -11
- package/CLAUDE.md +4 -4
- package/README.md +29 -43
- package/dist/index.cjs +3274 -4277
- package/dist/index.d.cts +1511 -1425
- package/dist/index.d.mts +1511 -1425
- package/dist/index.mjs +3265 -4244
- package/docs/.vitepress/config.ts +0 -1
- package/docs/advanced/bigal-vs-raw-sql.md +23 -23
- package/docs/advanced/known-issues.md +26 -21
- package/docs/getting-started.md +35 -29
- package/docs/guide/crud-operations.md +42 -61
- package/docs/guide/models.md +97 -396
- package/docs/guide/querying.md +82 -98
- package/docs/guide/relationships.md +125 -90
- package/docs/guide/subqueries-and-joins.md +39 -32
- package/docs/guide/views.md +51 -79
- package/docs/index.md +31 -27
- package/docs/package.json +3 -2
- package/docs/pnpm-lock.yaml +2338 -0
- package/docs/pnpm-workspace.yaml +3 -0
- package/docs/reference/api.md +86 -383
- package/docs/reference/configuration.md +26 -113
- package/package.json +59 -78
- package/pnpm-workspace.yaml +14 -0
- package/release.config.mjs +1 -1
- package/skills/using-bigal/SKILL.md +213 -276
- package/vite.config.ts +83 -0
- package/.husky/pre-commit +0 -4
- package/.oxfmtrc.json +0 -38
- package/.oxlintrc.json +0 -219
- package/dist/index.d.ts +0 -1527
- package/docs/guide/migration-v16.md +0 -594
- package/docs/package-lock.json +0 -3753
- package/scripts/migrate-v16.ts +0 -578
- package/skills/upgrade-v16/SKILL.md +0 -296
package/docs/guide/querying.md
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Fluent query builder for find, findOne, and count with WHERE operators, JSONB querying, pagination, sorting,
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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
|
|
80
|
+
await productRepository.find().where({ name: { contains: 'widget' } });
|
|
72
81
|
// SQL: WHERE name ILIKE '%widget%'
|
|
73
82
|
|
|
74
|
-
await
|
|
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
|
|
97
|
+
await productRepository.find().where({ price: { '>=': 100 } });
|
|
89
98
|
|
|
90
99
|
// Multiple operators on same field (AND)
|
|
91
|
-
await
|
|
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
|
|
115
|
+
await productRepository.find().where({ status: { '!': 'discontinued' } });
|
|
107
116
|
// SQL: WHERE status <> $1
|
|
108
117
|
|
|
109
|
-
await
|
|
118
|
+
await productRepository.find().where({ status: { '!': ['a', 'b'] } });
|
|
110
119
|
// SQL: WHERE status NOT IN ($1, $2)
|
|
111
120
|
|
|
112
|
-
await
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
await
|
|
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
|
|
227
|
-
await
|
|
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
|
|
233
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
333
|
+
// product.store is the full Store entity
|
|
356
334
|
console.log(product.store.name);
|
|
357
335
|
```
|
|
358
336
|
|
|
359
|
-
|
|
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
|
|
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.
|
|
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 (
|
|
9
|
+
## Many-to-one (model)
|
|
12
10
|
|
|
13
|
-
Use `
|
|
11
|
+
Use `model` when the current entity holds the foreign key:
|
|
14
12
|
|
|
15
13
|
```ts
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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 (
|
|
35
|
+
## One-to-many (collection)
|
|
33
36
|
|
|
34
|
-
Use `
|
|
37
|
+
Use `collection` on the inverse side:
|
|
35
38
|
|
|
36
39
|
```ts
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
-
|
|
49
|
-
-
|
|
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
|
|
61
|
+
Use `through` for relationships that require a join table:
|
|
55
62
|
|
|
56
63
|
```ts
|
|
57
64
|
// Product.ts
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
-
|
|
86
|
-
-
|
|
87
|
-
- The
|
|
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
|
-
|
|
92
|
-
from the table name, or set via `modelName` option):
|
|
122
|
+
Entities can reference themselves for hierarchical data:
|
|
93
123
|
|
|
94
124
|
```ts
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
146
|
+
const product = await productRepository.findOne().where({ id: 1 });
|
|
112
147
|
|
|
113
|
-
// product.store is `number`, not
|
|
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
|
-
|
|
|
120
|
-
|
|
|
121
|
-
| `number
|
|
122
|
-
| `
|
|
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
|
|
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
|
|
132
|
-
type
|
|
168
|
+
// Correct: store is `number`
|
|
169
|
+
type ProductSummary = Pick<QueryResult<Product>, 'id' | 'name' | 'store'>;
|
|
133
170
|
|
|
134
|
-
//
|
|
135
|
-
type
|
|
171
|
+
// Wrong: store is `number | Store`
|
|
172
|
+
type ProductSummaryWrong = Pick<Product, 'id' | 'name' | 'store'>;
|
|
136
173
|
```
|
|
137
174
|
|
|
138
|
-
##
|
|
175
|
+
## QueryResultPopulated
|
|
139
176
|
|
|
140
|
-
|
|
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
|
-
|
|
180
|
+
import type { QueryResultPopulated } from 'bigal';
|
|
146
181
|
|
|
147
|
-
//
|
|
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<
|
|
177
|
-
2. **Use string references for model
|
|
178
|
-
3. **
|
|
179
|
-
4. **
|
|
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
|