bigal 16.0.0-beta.2 → 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 +28 -8
- package/CLAUDE.md +4 -4
- package/README.md +29 -43
- package/dist/index.cjs +3275 -4259
- package/dist/index.d.cts +1511 -1424
- package/dist/index.d.mts +1511 -1424
- package/dist/index.mjs +3265 -4225
- 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 -83
- 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 -384
- package/docs/reference/configuration.md +26 -113
- package/package.json +59 -73
- 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 -1526
- package/docs/guide/migration-v16.md +0 -594
- package/docs/package-lock.json +0 -3753
- package/scripts/migrate-v16.ts +0 -497
- package/skills/upgrade-v16/SKILL.md +0 -244
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: using-bigal
|
|
3
3
|
description: >-
|
|
4
4
|
Type-safe PostgreSQL ORM guidance for BigAl. Use when importing BigAl,
|
|
5
|
-
defining models with
|
|
5
|
+
defining Entity models with decorators, writing WhereQuery filters,
|
|
6
6
|
using Repository patterns, or deciding between BigAl and raw SQL.
|
|
7
7
|
Covers model definition, fluent query building, joins, subqueries,
|
|
8
8
|
pagination, JSONB querying, and common gotchas.
|
|
@@ -10,27 +10,32 @@ description: >-
|
|
|
10
10
|
|
|
11
11
|
# Using BigAl
|
|
12
12
|
|
|
13
|
-
BigAl is a PostgreSQL-optimized, type-safe TypeScript ORM. It uses
|
|
14
|
-
builder pattern for queries, and the Repository pattern for CRUD operations. All results are plain
|
|
15
|
-
objects.
|
|
13
|
+
BigAl is a PostgreSQL-optimized, type-safe TypeScript ORM. It uses decorator-based models, a fluent builder pattern for queries, and the Repository pattern for CRUD operations.
|
|
16
14
|
|
|
17
15
|
## Quick Start
|
|
18
16
|
|
|
19
17
|
```ts
|
|
20
|
-
import {
|
|
18
|
+
import { column, primaryColumn, table, Entity, initialize, Repository } from 'bigal';
|
|
21
19
|
import { Pool } from 'postgres-pool';
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
})
|
|
21
|
+
@table({ name: 'products' })
|
|
22
|
+
class Product extends Entity {
|
|
23
|
+
@primaryColumn({ type: 'integer' })
|
|
24
|
+
public id!: number;
|
|
25
|
+
|
|
26
|
+
@column({ type: 'string', required: true })
|
|
27
|
+
public name!: string;
|
|
28
|
+
|
|
29
|
+
@column({ type: 'integer', required: true, name: 'price_cents' })
|
|
30
|
+
public priceCents!: number;
|
|
31
|
+
}
|
|
29
32
|
|
|
30
33
|
const pool = new Pool('postgres://localhost/mydb');
|
|
31
|
-
const
|
|
34
|
+
const repos = initialize({ models: [Product], pool });
|
|
35
|
+
const productRepository = repos.Product as Repository<Product>;
|
|
32
36
|
|
|
33
|
-
const products = await
|
|
37
|
+
const products = await productRepository
|
|
38
|
+
.find()
|
|
34
39
|
.where({ priceCents: { '>=': 1000 } })
|
|
35
40
|
.sort('name asc')
|
|
36
41
|
.limit(10);
|
|
@@ -56,7 +61,7 @@ const products = await Product.find()
|
|
|
56
61
|
- Bulk operations with custom locking (SELECT FOR UPDATE)
|
|
57
62
|
- Database-specific features BigAl does not wrap
|
|
58
63
|
|
|
59
|
-
BigAl wraps your existing connection pool
|
|
64
|
+
BigAl wraps your existing connection pool — `postgres-pool`, `pg`, or `@neondatabase/serverless`.
|
|
60
65
|
The pool is always accessible for raw queries, so you can eject to SQL at any point:
|
|
61
66
|
|
|
62
67
|
```ts
|
|
@@ -69,256 +74,135 @@ Use BigAl for the 90% of queries that fit its fluent API, and raw SQL for the re
|
|
|
69
74
|
|
|
70
75
|
### Basic queries
|
|
71
76
|
|
|
72
|
-
| SQL | BigAl
|
|
73
|
-
| ----------------------------------------------------- |
|
|
74
|
-
| `SELECT * FROM products WHERE id = 1` | `
|
|
75
|
-
| `SELECT name FROM products WHERE id = 1` | `
|
|
76
|
-
| `SELECT * FROM products WHERE name ILIKE '%widget%'` | `
|
|
77
|
-
| `SELECT * FROM products WHERE price >= 100` | `
|
|
78
|
-
| `SELECT * FROM products WHERE status IN ('a','b')` | `
|
|
79
|
-
| `SELECT * FROM products WHERE status <> 'x'` | `
|
|
80
|
-
| `SELECT * FROM products WHERE deleted_at IS NOT NULL` | `
|
|
81
|
-
| `SELECT * FROM products ORDER BY name LIMIT 10` | `
|
|
82
|
-
| `SELECT COUNT(*) FROM products WHERE active = true` | `
|
|
77
|
+
| SQL | BigAl |
|
|
78
|
+
| ----------------------------------------------------- | ------------------------------------------------------------ |
|
|
79
|
+
| `SELECT * FROM products WHERE id = 1` | `productRepo.findOne().where({ id: 1 })` |
|
|
80
|
+
| `SELECT name FROM products WHERE id = 1` | `productRepo.findOne({ select: ['name'] }).where({ id: 1 })` |
|
|
81
|
+
| `SELECT * FROM products WHERE name ILIKE '%widget%'` | `productRepo.find().where({ name: { contains: 'widget' } })` |
|
|
82
|
+
| `SELECT * FROM products WHERE price >= 100` | `productRepo.find().where({ price: { '>=': 100 } })` |
|
|
83
|
+
| `SELECT * FROM products WHERE status IN ('a','b')` | `productRepo.find().where({ status: ['a', 'b'] })` |
|
|
84
|
+
| `SELECT * FROM products WHERE status <> 'x'` | `productRepo.find().where({ status: { '!': 'x' } })` |
|
|
85
|
+
| `SELECT * FROM products WHERE deleted_at IS NOT NULL` | `productRepo.find().where({ deletedAt: { '!': null } })` |
|
|
86
|
+
| `SELECT * FROM products ORDER BY name LIMIT 10` | `productRepo.find().where({}).sort('name asc').limit(10)` |
|
|
87
|
+
| `SELECT COUNT(*) FROM products WHERE active = true` | `productRepo.count().where({ active: true })` |
|
|
83
88
|
|
|
84
89
|
### CRUD
|
|
85
90
|
|
|
86
|
-
| SQL | BigAl
|
|
87
|
-
| ----------------------------------------------------------- |
|
|
88
|
-
| `INSERT INTO products (name) VALUES ('Widget') RETURNING *` | `
|
|
89
|
-
| `UPDATE products SET name = 'X' WHERE id = 1 RETURNING *` | `
|
|
90
|
-
| `DELETE FROM products WHERE id = 1 RETURNING *` | `
|
|
91
|
+
| SQL | BigAl |
|
|
92
|
+
| ----------------------------------------------------------- | ---------------------------------------------- |
|
|
93
|
+
| `INSERT INTO products (name) VALUES ('Widget') RETURNING *` | `productRepo.create({ name: 'Widget' })` |
|
|
94
|
+
| `UPDATE products SET name = 'X' WHERE id = 1 RETURNING *` | `productRepo.update({ id: 1 }, { name: 'X' })` |
|
|
95
|
+
| `DELETE FROM products WHERE id = 1 RETURNING *` | `productRepo.destroy({ id: 1 })` |
|
|
91
96
|
|
|
92
97
|
### Subqueries, joins, and advanced
|
|
93
98
|
|
|
94
|
-
| SQL | BigAl
|
|
95
|
-
| ------------------------------------------------------------------------------- |
|
|
96
|
-
| `WHERE store_id IN (SELECT id FROM stores WHERE active)` | `.where({ store: { in: subquery(
|
|
97
|
-
| `INNER JOIN stores ON products.store_id = stores.id WHERE stores.name = 'Acme'` | `.join('store').where({ store: { name: 'Acme' } })`
|
|
98
|
-
| `SELECT DISTINCT ON (store_id) * ... ORDER BY store_id, created_at DESC` | `.distinctOn(['store']).sort('store').sort('createdAt desc')`
|
|
99
|
-
| `ON CONFLICT (sku) DO NOTHING` | `{ onConflict: { action: 'ignore', targets: ['sku'] } }`
|
|
100
|
-
| `ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name` | `{ onConflict: { action: 'merge', targets: ['sku'], merge: ['name'] } }`
|
|
99
|
+
| SQL | BigAl |
|
|
100
|
+
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
101
|
+
| `WHERE store_id IN (SELECT id FROM stores WHERE active)` | `.where({ store: { in: subquery(storeRepo).select(['id']).where({ active: true }) } })` |
|
|
102
|
+
| `INNER JOIN stores ON products.store_id = stores.id WHERE stores.name = 'Acme'` | `.join('store').where({ store: { name: 'Acme' } })` |
|
|
103
|
+
| `SELECT DISTINCT ON (store_id) * ... ORDER BY store_id, created_at DESC` | `.distinctOn(['store']).sort('store').sort('createdAt desc')` |
|
|
104
|
+
| `ON CONFLICT (sku) DO NOTHING` | `{ onConflict: { action: 'ignore', targets: ['sku'] } }` |
|
|
105
|
+
| `ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name` | `{ onConflict: { action: 'merge', targets: ['sku'], merge: ['name'] } }` |
|
|
101
106
|
|
|
102
107
|
## Model Definition
|
|
103
108
|
|
|
104
|
-
###
|
|
109
|
+
### Decorators
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
inferred from the schema definition. Column names are auto-derived from property keys using snakeCase.
|
|
111
|
+
Every model extends `Entity` and uses decorators:
|
|
108
112
|
|
|
109
113
|
```ts
|
|
110
|
-
import {
|
|
111
|
-
|
|
112
|
-
export const Product = table('products', {
|
|
113
|
-
id: serial().primaryKey(),
|
|
114
|
-
name: text().notNull(),
|
|
115
|
-
sku: varchar({ length: 100 }),
|
|
116
|
-
priceCents: integer().notNull(),
|
|
117
|
-
isActive: boolean().notNull().default(true),
|
|
118
|
-
metadata: jsonb<{ color?: string }>(),
|
|
119
|
-
store: belongsTo('Store'),
|
|
120
|
-
categories: hasMany('Category').through('ProductCategory').via('product'),
|
|
121
|
-
createdAt: createdAt(),
|
|
122
|
-
updatedAt: updatedAt(),
|
|
123
|
-
});
|
|
124
|
-
```
|
|
114
|
+
import { column, primaryColumn, createDateColumn, updateDateColumn, versionColumn, table, Entity } from 'bigal';
|
|
125
115
|
|
|
126
|
-
|
|
116
|
+
@table({ name: 'products', schema: 'public' })
|
|
117
|
+
class Product extends Entity {
|
|
118
|
+
@primaryColumn({ type: 'integer' })
|
|
119
|
+
public id!: number;
|
|
127
120
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
| `serial()` | SERIAL | `number` |
|
|
131
|
-
| `bigserial()` | BIGSERIAL | `number` |
|
|
132
|
-
| `text<T>()` | TEXT | `T \| null` |
|
|
133
|
-
| `varchar<T>({ length })` | VARCHAR(n) | `T \| null` |
|
|
134
|
-
| `integer()` | INTEGER | `number \| null` |
|
|
135
|
-
| `bigint()` | BIGINT | `number \| null` |
|
|
136
|
-
| `smallint()` | SMALLINT | `number \| null` |
|
|
137
|
-
| `float()` / `real()` | REAL | `number \| null` |
|
|
138
|
-
| `double()` | DOUBLE PRECISION | `number \| null` |
|
|
139
|
-
| `boolean()` | BOOLEAN | `boolean \| null` |
|
|
140
|
-
| `timestamp()` | TIMESTAMP | `Date \| null` |
|
|
141
|
-
| `timestamptz()` | TIMESTAMPTZ | `Date \| null` |
|
|
142
|
-
| `date()` | DATE | `Date \| null` |
|
|
143
|
-
| `json<T>()` | JSON | `T \| null` |
|
|
144
|
-
| `jsonb<T>()` | JSONB | `T \| null` |
|
|
145
|
-
| `uuid()` | UUID | `string \| null` |
|
|
146
|
-
| `bytea()` | BYTEA | `Buffer \| null` |
|
|
147
|
-
| `textArray()` | TEXT[] | `string[] \| null` |
|
|
148
|
-
| `integerArray()` | INTEGER[] | `number[] \| null` |
|
|
149
|
-
| `booleanArray()` | BOOLEAN[] | `boolean[] \| null` |
|
|
150
|
-
| `vector({ dimensions })` | VECTOR(n) | `number[] \| null` |
|
|
151
|
-
| `createdAt()` | TIMESTAMPTZ | `Date` |
|
|
152
|
-
| `updatedAt()` | TIMESTAMPTZ | `Date` |
|
|
153
|
-
|
|
154
|
-
### Column modifiers
|
|
121
|
+
@column({ type: 'string', required: true })
|
|
122
|
+
public name!: string;
|
|
155
123
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
isActive: boolean().notNull().default(true), // optional on insert
|
|
159
|
-
id: serial().primaryKey(), // notNull + optional on insert
|
|
160
|
-
email: text().notNull().unique(), // UNIQUE constraint
|
|
161
|
-
revision: integer().version(), // optimistic locking, auto-increments on update
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
### Relationships
|
|
124
|
+
@column({ type: 'string' })
|
|
125
|
+
public sku?: string;
|
|
165
126
|
|
|
166
|
-
|
|
127
|
+
@column({ type: 'integer', required: true, name: 'price_cents' })
|
|
128
|
+
public priceCents!: number;
|
|
167
129
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// FK column auto-derived as store_id
|
|
130
|
+
@column({ type: 'json' })
|
|
131
|
+
public metadata?: Record<string, unknown>;
|
|
171
132
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
```
|
|
133
|
+
@createDateColumn()
|
|
134
|
+
public createdAt!: Date;
|
|
175
135
|
|
|
176
|
-
|
|
136
|
+
@updateDateColumn()
|
|
137
|
+
public updatedAt!: Date;
|
|
177
138
|
|
|
178
|
-
|
|
179
|
-
|
|
139
|
+
@versionColumn()
|
|
140
|
+
public version!: number;
|
|
141
|
+
}
|
|
180
142
|
```
|
|
181
143
|
|
|
182
|
-
|
|
144
|
+
### Column types
|
|
183
145
|
|
|
184
|
-
|
|
185
|
-
categories: hasMany('Category').through('ProductCategory').via('product'),
|
|
186
|
-
```
|
|
146
|
+
`'string'`, `'integer'`, `'float'`, `'boolean'`, `'date'`, `'datetime'`, `'json'`, `'string[]'`, `'integer[]'`, `'float[]'`, `'boolean[]'`, `'vector'`
|
|
187
147
|
|
|
188
|
-
|
|
148
|
+
Vector columns (pgvector) are declared with `@column({ type: 'vector', dimensions: n })` on a `number[]` property.
|
|
149
|
+
`dimensions` is informational — BigAl does not issue DDL.
|
|
189
150
|
|
|
190
|
-
###
|
|
151
|
+
### Relationships
|
|
191
152
|
|
|
192
|
-
|
|
193
|
-
columns inline in each model - avoid creating hierarchies of base objects.
|
|
153
|
+
**Many-to-one** — current entity holds the foreign key:
|
|
194
154
|
|
|
195
155
|
```ts
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
createdAt: createdAt(),
|
|
199
|
-
updatedAt: updatedAt(),
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
export const Product = table('products', {
|
|
203
|
-
...modelBase,
|
|
204
|
-
name: text().notNull(),
|
|
205
|
-
store: belongsTo('Store'),
|
|
206
|
-
});
|
|
156
|
+
@column({ model: () => 'Store', name: 'store_id' })
|
|
157
|
+
public store!: number | Store;
|
|
207
158
|
```
|
|
208
159
|
|
|
209
|
-
|
|
160
|
+
**One-to-many** — inverse side (must be optional):
|
|
210
161
|
|
|
211
162
|
```ts
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
export const ProductSummary = view('product_summaries', {
|
|
215
|
-
id: serial().primaryKey(),
|
|
216
|
-
name: text().notNull(),
|
|
217
|
-
storeName: text().notNull(),
|
|
218
|
-
categoryCount: integer().notNull(),
|
|
219
|
-
});
|
|
163
|
+
@column({ collection: () => 'Product', via: 'store' })
|
|
164
|
+
public products?: Product[];
|
|
220
165
|
```
|
|
221
166
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
### Hooks
|
|
167
|
+
**Many-to-many** — requires a join table Entity:
|
|
225
168
|
|
|
226
169
|
```ts
|
|
227
|
-
|
|
228
|
-
'
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return { ...values, slug: slugify(values.name) };
|
|
234
|
-
},
|
|
235
|
-
afterFind(results) {
|
|
236
|
-
return results;
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
);
|
|
170
|
+
@column({
|
|
171
|
+
collection: () => 'Category',
|
|
172
|
+
through: () => 'ProductCategory',
|
|
173
|
+
via: 'product',
|
|
174
|
+
})
|
|
175
|
+
public categories?: Category[];
|
|
241
176
|
```
|
|
242
177
|
|
|
243
|
-
|
|
244
|
-
`afterDestroy`, `afterFind`.
|
|
245
|
-
|
|
246
|
-
### Global filters
|
|
247
|
-
|
|
248
|
-
```ts
|
|
249
|
-
const Product = table(
|
|
250
|
-
'products',
|
|
251
|
-
{
|
|
252
|
-
/* columns */
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
filters: {
|
|
256
|
-
active: { isActive: true },
|
|
257
|
-
notDeleted: () => ({ deletedAt: null }),
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
// Override per query
|
|
263
|
-
await Product.find().where({}).filters(false); // disable all
|
|
264
|
-
await Product.find().where({}).filters({ active: false }); // disable specific
|
|
265
|
-
```
|
|
178
|
+
Reference model names by string (`'Store'`, not `Store`) to avoid circular imports. Model names are case-insensitive.
|
|
266
179
|
|
|
267
|
-
###
|
|
180
|
+
### Readonly models (views)
|
|
268
181
|
|
|
269
182
|
```ts
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
type ProductRow = InferSelect<(typeof Product)['schema']>;
|
|
273
|
-
type ProductInsert = InferInsert<(typeof Product)['schema']>;
|
|
274
|
-
|
|
275
|
-
function processProducts(repo: Repository<typeof Product>) {
|
|
276
|
-
/* ... */
|
|
277
|
-
}
|
|
278
|
-
function readSummary(repo: ReadonlyRepository<typeof ProductSummary>) {
|
|
183
|
+
@table({ name: 'product_summaries', readonly: true })
|
|
184
|
+
class ProductSummary extends Entity {
|
|
279
185
|
/* ... */
|
|
280
186
|
}
|
|
281
187
|
```
|
|
282
188
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
```ts
|
|
286
|
-
import { initialize } from 'bigal';
|
|
287
|
-
|
|
288
|
-
// Object-style (recommended) -- typed destructuring
|
|
289
|
-
const { Product, Store } = initialize({
|
|
290
|
-
models: { Product, Store, Category, ProductCategory },
|
|
291
|
-
pool,
|
|
292
|
-
readonlyPool, // optional: separate pool for reads
|
|
293
|
-
connections: {
|
|
294
|
-
// optional: multi-database
|
|
295
|
-
audit: { pool: auditPool },
|
|
296
|
-
},
|
|
297
|
-
onQuery({ sql, params, duration, error, model, operation }) {
|
|
298
|
-
logger.debug({ sql, params, duration, model, operation });
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Array-style -- use getRepository()
|
|
303
|
-
const bigal = initialize({ pool, models: [Product, Store] });
|
|
304
|
-
const ProductRepo = bigal.getRepository(Product);
|
|
305
|
-
```
|
|
189
|
+
`initialize()` returns a `ReadonlyRepository` which omits `create`, `update`, and `destroy`.
|
|
306
190
|
|
|
307
191
|
## Query Patterns
|
|
308
192
|
|
|
309
193
|
### Fluent builder
|
|
310
194
|
|
|
311
|
-
Every query method returns a new immutable instance. Queries are `PromiseLike`
|
|
195
|
+
Every query method returns a new immutable instance. Queries are `PromiseLike` — just `await` the chain.
|
|
312
196
|
|
|
313
197
|
```ts
|
|
314
198
|
// Find multiple
|
|
315
|
-
const products = await
|
|
199
|
+
const products = await productRepo.find().where({ store: storeId }).sort('name asc').limit(10);
|
|
316
200
|
|
|
317
201
|
// Find one
|
|
318
|
-
const product = await
|
|
202
|
+
const product = await productRepo.findOne().where({ id: 42 });
|
|
319
203
|
|
|
320
204
|
// Count
|
|
321
|
-
const count = await
|
|
205
|
+
const count = await productRepo.count().where({ sku: { '!': null } });
|
|
322
206
|
```
|
|
323
207
|
|
|
324
208
|
### Where operators
|
|
@@ -374,40 +258,42 @@ const count = await Product.count().where({ sku: { '!': null } });
|
|
|
374
258
|
.paginate(2, 25) // page 2, 25 per page
|
|
375
259
|
|
|
376
260
|
// With total count (single query using COUNT(*) OVER())
|
|
377
|
-
const { results, totalCount } = await
|
|
261
|
+
const { results, totalCount } = await productRepo
|
|
378
262
|
.find().where({}).sort('name').limit(10).skip(20).withCount();
|
|
379
263
|
```
|
|
380
264
|
|
|
381
265
|
### Populate (eager loading)
|
|
382
266
|
|
|
383
|
-
With object-style `initialize({ models: { Product, Store } })`, populate results are fully typed.
|
|
384
|
-
|
|
385
267
|
```ts
|
|
386
|
-
const product = await
|
|
268
|
+
const product = await productRepo
|
|
269
|
+
.findOne()
|
|
387
270
|
.where({ id: 42 })
|
|
388
271
|
.populate('store', { select: ['name'] });
|
|
389
|
-
// product.store is
|
|
272
|
+
// product.store is the full Store entity
|
|
390
273
|
```
|
|
391
274
|
|
|
392
275
|
### Joins
|
|
393
276
|
|
|
394
277
|
```ts
|
|
395
278
|
// Model join (INNER)
|
|
396
|
-
await
|
|
279
|
+
await productRepo
|
|
280
|
+
.find()
|
|
397
281
|
.join('store')
|
|
398
282
|
.where({ store: { name: 'Acme' } });
|
|
399
283
|
|
|
400
284
|
// Left join
|
|
401
|
-
await
|
|
285
|
+
await productRepo
|
|
286
|
+
.find()
|
|
402
287
|
.leftJoin('store')
|
|
403
288
|
.where({ store: { name: 'Acme' } });
|
|
404
289
|
|
|
405
290
|
// Subquery join with aggregates
|
|
406
|
-
const productCounts = subquery(
|
|
291
|
+
const productCounts = subquery(productRepo)
|
|
407
292
|
.select(['store', (sb) => sb.count().as('productCount')])
|
|
408
293
|
.groupBy(['store']);
|
|
409
294
|
|
|
410
|
-
await
|
|
295
|
+
await storeRepo
|
|
296
|
+
.find()
|
|
411
297
|
.join(productCounts, 'stats', { on: { id: 'store' } })
|
|
412
298
|
.sort('stats.productCount desc');
|
|
413
299
|
```
|
|
@@ -416,7 +302,7 @@ await Store.find()
|
|
|
416
302
|
|
|
417
303
|
```ts
|
|
418
304
|
// Most recently created product per store
|
|
419
|
-
await
|
|
305
|
+
await productRepo.find().distinctOn(['store']).sort('store').sort('createdAt desc');
|
|
420
306
|
```
|
|
421
307
|
|
|
422
308
|
ORDER BY must start with the DISTINCT ON columns. Cannot combine with `withCount()`.
|
|
@@ -424,43 +310,43 @@ ORDER BY must start with the DISTINCT ON columns. Cannot combine with `withCount
|
|
|
424
310
|
### Create
|
|
425
311
|
|
|
426
312
|
```ts
|
|
427
|
-
// Single record
|
|
428
|
-
const product = await
|
|
313
|
+
// Single record — returns the created record
|
|
314
|
+
const product = await productRepo.create({ name: 'Widget', priceCents: 999 });
|
|
429
315
|
|
|
430
316
|
// Multiple records
|
|
431
|
-
const products = await
|
|
317
|
+
const products = await productRepo.create([
|
|
432
318
|
{ name: 'Widget', priceCents: 999 },
|
|
433
319
|
{ name: 'Gadget', priceCents: 1499 },
|
|
434
320
|
]);
|
|
435
321
|
|
|
436
322
|
// Upsert: ON CONFLICT DO NOTHING
|
|
437
|
-
await
|
|
323
|
+
await productRepo.create({ name: 'Widget', sku: 'WDG-001' }, { onConflict: { action: 'ignore', targets: ['sku'] } });
|
|
438
324
|
|
|
439
325
|
// Upsert: ON CONFLICT DO UPDATE specific columns
|
|
440
|
-
await
|
|
326
|
+
await productRepo.create({ name: 'Widget', sku: 'WDG-001', priceCents: 999 }, { onConflict: { action: 'merge', targets: ['sku'], merge: ['priceCents'] } });
|
|
441
327
|
|
|
442
328
|
// Skip returning records
|
|
443
|
-
await
|
|
329
|
+
await productRepo.create({ name: 'Widget', priceCents: 999 }, { returnRecords: false });
|
|
444
330
|
|
|
445
331
|
// Return only specific columns (primary key always included)
|
|
446
|
-
await
|
|
332
|
+
await productRepo.create({ name: 'Widget', priceCents: 999 }, { returnSelect: ['name'] });
|
|
447
333
|
```
|
|
448
334
|
|
|
449
335
|
### Update
|
|
450
336
|
|
|
451
337
|
```ts
|
|
452
338
|
// Returns array of updated records
|
|
453
|
-
const products = await
|
|
339
|
+
const products = await productRepo.update({ id: 42 }, { name: 'Super Widget' });
|
|
454
340
|
|
|
455
341
|
// Update multiple
|
|
456
|
-
const products = await
|
|
342
|
+
const products = await productRepo.update({ id: [42, 43] }, { priceCents: 1299 });
|
|
457
343
|
```
|
|
458
344
|
|
|
459
345
|
### Destroy
|
|
460
346
|
|
|
461
347
|
```ts
|
|
462
348
|
// Returns array of deleted records
|
|
463
|
-
const products = await
|
|
349
|
+
const products = await productRepo.destroy({ id: 42 });
|
|
464
350
|
```
|
|
465
351
|
|
|
466
352
|
### Subqueries
|
|
@@ -469,82 +355,132 @@ const products = await Product.destroy({ id: 42 });
|
|
|
469
355
|
import { subquery } from 'bigal';
|
|
470
356
|
|
|
471
357
|
// WHERE IN
|
|
472
|
-
const activeStores = subquery(
|
|
473
|
-
await
|
|
358
|
+
const activeStores = subquery(storeRepo).select(['id']).where({ isActive: true });
|
|
359
|
+
await productRepo.find().where({ store: { in: activeStores } });
|
|
474
360
|
|
|
475
361
|
// WHERE EXISTS
|
|
476
|
-
const hasProducts = subquery(
|
|
477
|
-
await
|
|
362
|
+
const hasProducts = subquery(productRepo).where({ name: { like: 'Widget%' } });
|
|
363
|
+
await storeRepo.find().where({ exists: hasProducts });
|
|
478
364
|
|
|
479
365
|
// Scalar comparison
|
|
480
|
-
const avgPrice = subquery(
|
|
481
|
-
await
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
### toSQL()
|
|
485
|
-
|
|
486
|
-
Inspect generated SQL without executing:
|
|
487
|
-
|
|
488
|
-
```ts
|
|
489
|
-
const { sql, params } = Product.find().where({ name: 'Widget' }).toSQL();
|
|
490
|
-
const { sql, params } = Product.create({ name: 'Widget', priceCents: 999 }).toSQL();
|
|
491
|
-
const { sql, params } = Product.update({ id: 42 }, { name: 'X' }).toSQL();
|
|
492
|
-
const { sql, params } = Product.destroy({ id: 42 }).toSQL();
|
|
366
|
+
const avgPrice = subquery(productRepo).avg('price');
|
|
367
|
+
await productRepo.find().where({ price: { '>': avgPrice } });
|
|
493
368
|
```
|
|
494
369
|
|
|
495
370
|
### Vector distance queries
|
|
496
371
|
|
|
497
372
|
```ts
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// In model definition
|
|
501
|
-
embedding: vector({ dimensions: 1536 }),
|
|
373
|
+
// In the model: @column({ type: 'vector', dimensions: 1536 }) public embedding?: number[];
|
|
502
374
|
|
|
503
|
-
// Sort by similarity
|
|
504
|
-
const similar = await
|
|
505
|
-
.
|
|
375
|
+
// Sort by similarity — metric: 'cosine' (default, <=>), 'l2' (<->), 'l1' (<+>), 'innerProduct' (<#>)
|
|
376
|
+
const similar = await documentRepo
|
|
377
|
+
.find()
|
|
506
378
|
.sort({ embedding: { nearestTo: queryVector, metric: 'cosine' } })
|
|
507
379
|
.limit(10);
|
|
508
380
|
|
|
509
|
-
// Filter by distance
|
|
510
|
-
const nearby = await
|
|
381
|
+
// Filter by distance threshold
|
|
382
|
+
const nearby = await documentRepo
|
|
383
|
+
.find()
|
|
511
384
|
.where({ embedding: { nearestTo: queryVector, metric: 'cosine', distance: { '<': 0.5 } } })
|
|
512
385
|
.sort({ embedding: { nearestTo: queryVector, metric: 'cosine' } })
|
|
513
386
|
.limit(10);
|
|
387
|
+
|
|
388
|
+
// Writes serialize number[] to pgvector text format; reads parse it back to number[]
|
|
389
|
+
await documentRepo.create({ title: 'foo', embedding: [0.1, 0.2, 0.3] });
|
|
514
390
|
```
|
|
515
391
|
|
|
516
392
|
## Gotchas
|
|
517
393
|
|
|
394
|
+
### Collections must be optional
|
|
395
|
+
|
|
396
|
+
Collection properties (one-to-many, many-to-many) must use `?`, not `!`. They are only present after `.populate()`:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
// Correct
|
|
400
|
+
@column({ collection: () => 'Product', via: 'store' })
|
|
401
|
+
public products?: Product[];
|
|
402
|
+
|
|
403
|
+
// Wrong — causes QueryResult type errors
|
|
404
|
+
@column({ collection: () => 'Product', via: 'store' })
|
|
405
|
+
public products!: Product[];
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### NotEntity for JSON objects with id fields
|
|
409
|
+
|
|
410
|
+
If a JSON column contains objects with an `id` property, wrap the type with `NotEntity<T>` to prevent BigAl's type system from treating them as entities:
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
import type { NotEntity } from 'bigal';
|
|
414
|
+
|
|
415
|
+
interface IMyJsonType {
|
|
416
|
+
id: string;
|
|
417
|
+
foo: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
@column({ type: 'json' })
|
|
421
|
+
public metadata?: NotEntity<IMyJsonType>;
|
|
422
|
+
```
|
|
423
|
+
|
|
518
424
|
### Query state is immutable
|
|
519
425
|
|
|
520
426
|
Each fluent method returns a new instance. Do not ignore the return value:
|
|
521
427
|
|
|
522
428
|
```ts
|
|
523
429
|
// Correct
|
|
524
|
-
const query =
|
|
430
|
+
const query = productRepo.find().where({ store: storeId });
|
|
525
431
|
const sorted = query.sort('name asc');
|
|
526
432
|
const results = await sorted.limit(10);
|
|
527
433
|
|
|
528
|
-
// Wrong
|
|
529
|
-
const query =
|
|
434
|
+
// Wrong — .sort() result is discarded
|
|
435
|
+
const query = productRepo.find().where({ store: storeId });
|
|
530
436
|
query.sort('name asc'); // This does nothing to `query`
|
|
531
437
|
const results = await query;
|
|
532
438
|
```
|
|
533
439
|
|
|
534
|
-
### QueryResult
|
|
440
|
+
### QueryResult narrows relationship types
|
|
535
441
|
|
|
536
|
-
`QueryResult<
|
|
442
|
+
`QueryResult<T>` automatically narrows `number | Store` to `number`. Use `QueryResult<T>` (not `T`) for derived types:
|
|
537
443
|
|
|
538
444
|
```ts
|
|
539
445
|
import type { QueryResult } from 'bigal';
|
|
540
446
|
|
|
541
|
-
|
|
542
|
-
type ProductSummary = Pick<QueryResult<
|
|
447
|
+
// Correct: store is `number`
|
|
448
|
+
type ProductSummary = Pick<QueryResult<Product>, 'id' | 'name' | 'store'>;
|
|
449
|
+
|
|
450
|
+
// Wrong: store is `number | Store`
|
|
451
|
+
type ProductSummaryWrong = Pick<Product, 'id' | 'name' | 'store'>;
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Prefer count() over findOne() for existence checks
|
|
455
|
+
|
|
456
|
+
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:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
// Correct
|
|
460
|
+
const exists = (await productRepo.count().where({ sku: 'ABC123' })) > 0;
|
|
461
|
+
|
|
462
|
+
// Wasteful — findOne() selects and hydrates a full row just to check for existence
|
|
463
|
+
const exists = (await productRepo.findOne().where({ sku: 'ABC123' })) != null;
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Prefer an array to create() over looping
|
|
467
|
+
|
|
468
|
+
Passing an array to `create()` builds a single multi-row `INSERT` statement — one round trip for the whole batch. Calling `create()` in a loop issues a separate `INSERT` (and round trip) per record:
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
// Correct — one INSERT statement for all rows
|
|
472
|
+
const products = await productRepo.create(items);
|
|
473
|
+
|
|
474
|
+
// Slower — a separate INSERT statement and round trip per item
|
|
475
|
+
const products = [];
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
products.push(await productRepo.create(item));
|
|
478
|
+
}
|
|
543
479
|
```
|
|
544
480
|
|
|
545
481
|
### Debugging SQL
|
|
546
482
|
|
|
547
|
-
|
|
483
|
+
Set `DEBUG_BIGAL=true` to log all generated SQL and parameter values:
|
|
548
484
|
|
|
549
485
|
```sh
|
|
550
486
|
DEBUG_BIGAL=true node app.js
|
|
@@ -554,25 +490,26 @@ DEBUG_BIGAL=true node app.js
|
|
|
554
490
|
|
|
555
491
|
After applying this skill, verify:
|
|
556
492
|
|
|
557
|
-
- [ ] Models use
|
|
558
|
-
- [ ] Initialize uses object-style `models` with destructuring
|
|
493
|
+
- [ ] Models extend `Entity` and use `@table()`, `@primaryColumn()`, `@column()` decorators
|
|
559
494
|
- [ ] Queries use the fluent builder pattern (not raw SQL strings)
|
|
560
495
|
- [ ] CRUD uses Repository methods: `create()`, `update()`, `destroy()`
|
|
561
496
|
- [ ] Query chains are awaited (not fire-and-forget)
|
|
562
497
|
- [ ] Query state is treated as immutable (return values are used)
|
|
563
|
-
- [ ]
|
|
564
|
-
- [ ]
|
|
498
|
+
- [ ] Collection properties are optional (`?`)
|
|
499
|
+
- [ ] JSON column types with `id` fields use `NotEntity<T>`
|
|
500
|
+
- [ ] Model names in decorators are strings (`'Store'`) to avoid circular imports
|
|
501
|
+
- [ ] `QueryResult<T>` is used for derived types involving relationships
|
|
565
502
|
|
|
566
503
|
## Further Reading
|
|
567
504
|
|
|
568
|
-
- [Getting Started](https://bigalorm.github.io/bigal/getting-started)
|
|
569
|
-
- [Models](https://bigalorm.github.io/bigal/guide/models)
|
|
570
|
-
- [Querying](https://bigalorm.github.io/bigal/guide/querying)
|
|
571
|
-
- [CRUD Operations](https://bigalorm.github.io/bigal/guide/crud-operations)
|
|
572
|
-
- [Relationships](https://bigalorm.github.io/bigal/guide/relationships) -
|
|
573
|
-
- [Subqueries and Joins](https://bigalorm.github.io/bigal/guide/subqueries-and-joins)
|
|
574
|
-
- [Views](https://bigalorm.github.io/bigal/guide/views)
|
|
575
|
-
- [API Reference](https://bigalorm.github.io/bigal/reference/api)
|
|
576
|
-
- [Configuration](https://bigalorm.github.io/bigal/reference/configuration)
|
|
577
|
-
- [BigAl vs Raw SQL](https://bigalorm.github.io/bigal/advanced/bigal-vs-raw-sql)
|
|
578
|
-
- [Known Issues](https://bigalorm.github.io/bigal/advanced/known-issues)
|
|
505
|
+
- [Getting Started](https://bigalorm.github.io/bigal/getting-started) — install, first model, first query
|
|
506
|
+
- [Models](https://bigalorm.github.io/bigal/guide/models) — decorators, column options, relationships
|
|
507
|
+
- [Querying](https://bigalorm.github.io/bigal/guide/querying) — operators, pagination, JSONB, DISTINCT ON
|
|
508
|
+
- [CRUD Operations](https://bigalorm.github.io/bigal/guide/crud-operations) — create, update, destroy, upserts
|
|
509
|
+
- [Relationships](https://bigalorm.github.io/bigal/guide/relationships) — many-to-one, one-to-many, many-to-many, QueryResult
|
|
510
|
+
- [Subqueries and Joins](https://bigalorm.github.io/bigal/guide/subqueries-and-joins) — subquery builder, aggregates, GROUP BY
|
|
511
|
+
- [Views](https://bigalorm.github.io/bigal/guide/views) — readonly models and ReadonlyRepository
|
|
512
|
+
- [API Reference](https://bigalorm.github.io/bigal/reference/api) — all exports and method signatures
|
|
513
|
+
- [Configuration](https://bigalorm.github.io/bigal/reference/configuration) — pools, read replicas, multi-database
|
|
514
|
+
- [BigAl vs Raw SQL](https://bigalorm.github.io/bigal/advanced/bigal-vs-raw-sql) — decision framework
|
|
515
|
+
- [Known Issues](https://bigalorm.github.io/bigal/advanced/known-issues) — workarounds and debugging
|