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.
@@ -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 table() or view(), writing WhereQuery filters,
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 function-based models, a fluent
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 { defineTable as table, serial, text, integer, belongsTo, initialize, subquery } from 'bigal';
18
+ import { column, primaryColumn, table, Entity, initialize, Repository } from 'bigal';
21
19
  import { Pool } from 'postgres-pool';
22
20
 
23
- const Product = table('products', {
24
- id: serial().primaryKey(),
25
- name: text().notNull(),
26
- priceCents: integer().notNull(),
27
- store: belongsTo('Store'),
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 { Product, Store } = initialize({ models: { Product, Store }, pool });
34
+ const repos = initialize({ models: [Product], pool });
35
+ const productRepository = repos.Product as Repository<Product>;
32
36
 
33
- const products = await Product.find()
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 - `postgres-pool`, `pg`, or `@neondatabase/serverless`.
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` | `Product.findOne().where({ id: 1 })` |
75
- | `SELECT name FROM products WHERE id = 1` | `Product.findOne({ select: ['name'] }).where({ id: 1 })` |
76
- | `SELECT * FROM products WHERE name ILIKE '%widget%'` | `Product.find().where({ name: { contains: 'widget' } })` |
77
- | `SELECT * FROM products WHERE price >= 100` | `Product.find().where({ price: { '>=': 100 } })` |
78
- | `SELECT * FROM products WHERE status IN ('a','b')` | `Product.find().where({ status: ['a', 'b'] })` |
79
- | `SELECT * FROM products WHERE status <> 'x'` | `Product.find().where({ status: { '!': 'x' } })` |
80
- | `SELECT * FROM products WHERE deleted_at IS NOT NULL` | `Product.find().where({ deletedAt: { '!': null } })` |
81
- | `SELECT * FROM products ORDER BY name LIMIT 10` | `Product.find().where({}).sort('name asc').limit(10)` |
82
- | `SELECT COUNT(*) FROM products WHERE active = true` | `Product.count().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 *` | `Product.create({ name: 'Widget' })` |
89
- | `UPDATE products SET name = 'X' WHERE id = 1 RETURNING *` | `Product.update({ id: 1 }, { name: 'X' })` |
90
- | `DELETE FROM products WHERE id = 1 RETURNING *` | `Product.destroy({ id: 1 })` |
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(Store).select(['id']).where({ active: true }) } })` |
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
- ### Defining a model
109
+ ### Decorators
105
110
 
106
- Models are defined with the `table()` function and PostgreSQL-native column builders. Types are
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 { defineTable as table, serial, text, varchar, integer, boolean, jsonb, createdAt, updatedAt, belongsTo, hasMany } from 'bigal';
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
- ### Column types
116
+ @table({ name: 'products', schema: 'public' })
117
+ class Product extends Entity {
118
+ @primaryColumn({ type: 'integer' })
119
+ public id!: number;
127
120
 
128
- | Builder | PostgreSQL type | TypeScript type |
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
- ```ts
157
- name: text().notNull(), // removes null from type
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
- **Many-to-one** - this model holds the foreign key:
127
+ @column({ type: 'integer', required: true, name: 'price_cents' })
128
+ public priceCents!: number;
167
129
 
168
- ```ts
169
- store: belongsTo('Store'),
170
- // FK column auto-derived as store_id
130
+ @column({ type: 'json' })
131
+ public metadata?: Record<string, unknown>;
171
132
 
172
- store: belongsTo('Store', { name: 'shop_id' }),
173
- // Override FK column name
174
- ```
133
+ @createDateColumn()
134
+ public createdAt!: Date;
175
135
 
176
- **One-to-many** - inverse side:
136
+ @updateDateColumn()
137
+ public updatedAt!: Date;
177
138
 
178
- ```ts
179
- products: hasMany('Product').via('store'),
139
+ @versionColumn()
140
+ public version!: number;
141
+ }
180
142
  ```
181
143
 
182
- **Many-to-many** - via junction model:
144
+ ### Column types
183
145
 
184
- ```ts
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
- Reference model names by string (`'Store'`, not `Store`) to avoid circular imports.
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
- ### Shared columns
151
+ ### Relationships
191
152
 
192
- Extract a shared base only for universal columns (id + timestamps). Keep domain-specific
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
- const modelBase = {
197
- id: serial().primaryKey(),
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
- ### Readonly models (views)
160
+ **One-to-many** inverse side (must be optional):
210
161
 
211
162
  ```ts
212
- import { view, serial, text, integer } from 'bigal';
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
- `initialize()` returns a `ReadonlyRepository` which exposes only `find()`, `findOne()`, and `count()`.
223
-
224
- ### Hooks
167
+ **Many-to-many** requires a join table Entity:
225
168
 
226
169
  ```ts
227
- export const Product = table(
228
- 'products',
229
- { id: serial().primaryKey(), name: text().notNull(), slug: text().notNull() },
230
- {
231
- hooks: {
232
- beforeCreate(values) {
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
- Available hooks: `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDestroy`,
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
- ### Type inference
180
+ ### Readonly models (views)
268
181
 
269
182
  ```ts
270
- import type { InferSelect, InferInsert, Repository, ReadonlyRepository } from 'bigal';
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
- ## Initialization
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` - just `await` the chain.
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 Product.find().where({ store: storeId }).sort('name asc').limit(10);
199
+ const products = await productRepo.find().where({ store: storeId }).sort('name asc').limit(10);
316
200
 
317
201
  // Find one
318
- const product = await Product.findOne().where({ id: 42 });
202
+ const product = await productRepo.findOne().where({ id: 42 });
319
203
 
320
204
  // Count
321
- const count = await Product.count().where({ sku: { '!': null } });
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 Product
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 Product.findOne()
268
+ const product = await productRepo
269
+ .findOne()
387
270
  .where({ id: 42 })
388
271
  .populate('store', { select: ['name'] });
389
- // product.store is typed as the full Store entity
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 Product.find()
279
+ await productRepo
280
+ .find()
397
281
  .join('store')
398
282
  .where({ store: { name: 'Acme' } });
399
283
 
400
284
  // Left join
401
- await Product.find()
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(Product)
291
+ const productCounts = subquery(productRepo)
407
292
  .select(['store', (sb) => sb.count().as('productCount')])
408
293
  .groupBy(['store']);
409
294
 
410
- await Store.find()
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 Product.find().distinctOn(['store']).sort('store').sort('createdAt desc');
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 -- returns the created record
428
- const product = await Product.create({ name: 'Widget', priceCents: 999 });
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 Product.create([
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 Product.create({ name: 'Widget', sku: 'WDG-001' }, { onConflict: { action: 'ignore', targets: ['sku'] } });
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 Product.create({ name: 'Widget', sku: 'WDG-001', priceCents: 999 }, { onConflict: { action: 'merge', targets: ['sku'], merge: ['priceCents'] } });
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 Product.create({ name: 'Widget', priceCents: 999 }, { returnRecords: false });
329
+ await productRepo.create({ name: 'Widget', priceCents: 999 }, { returnRecords: false });
444
330
 
445
331
  // Return only specific columns (primary key always included)
446
- await Product.create({ name: 'Widget', priceCents: 999 }, { returnSelect: ['name'] });
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 Product.update({ id: 42 }, { name: 'Super Widget' });
339
+ const products = await productRepo.update({ id: 42 }, { name: 'Super Widget' });
454
340
 
455
341
  // Update multiple
456
- const products = await Product.update({ id: [42, 43] }, { priceCents: 1299 });
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 Product.destroy({ id: 42 });
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(Store).select(['id']).where({ isActive: true });
473
- await Product.find().where({ store: { in: activeStores } });
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(Product).where({ name: { like: 'Widget%' } });
477
- await Store.find().where({ exists: hasProducts });
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(Product).avg('priceCents');
481
- await Product.find().where({ priceCents: { '>': avgPrice } });
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
- import { vector } from 'bigal';
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 Document.find()
505
- .where({})
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 Document.find()
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 = Product.find().where({ store: storeId });
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 -- .sort() result is discarded
529
- const query = Product.find().where({ store: storeId });
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 accepts TableDefinition directly
440
+ ### QueryResult narrows relationship types
535
441
 
536
- `QueryResult<typeof Model>` produces the row type with hasMany stripped and FKs narrowed:
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
- type ProductRow = QueryResult<typeof Product>;
542
- type ProductSummary = Pick<QueryResult<typeof Product>, 'id' | 'name' | 'store'>;
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
- Use `onQuery` for structured logging, or set `DEBUG_BIGAL=true` for console output:
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 `table()` with column builders (not decorators)
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
- - [ ] Model names in relationships are strings (`'Store'`) to avoid circular imports
564
- - [ ] `QueryResult<typeof Model>` is used for derived types involving relationships
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) - install, first model, first query
569
- - [Models](https://bigalorm.github.io/bigal/guide/models) - table(), column types, relationships, hooks, filters
570
- - [Querying](https://bigalorm.github.io/bigal/guide/querying) - operators, pagination, JSONB, DISTINCT ON
571
- - [CRUD Operations](https://bigalorm.github.io/bigal/guide/crud-operations) - create, update, destroy, upserts
572
- - [Relationships](https://bigalorm.github.io/bigal/guide/relationships) - belongsTo, hasMany, QueryResult
573
- - [Subqueries and Joins](https://bigalorm.github.io/bigal/guide/subqueries-and-joins) - subquery builder, aggregates, GROUP BY
574
- - [Views](https://bigalorm.github.io/bigal/guide/views) - readonly models and ReadonlyRepository
575
- - [API Reference](https://bigalorm.github.io/bigal/reference/api) - all exports and method signatures
576
- - [Configuration](https://bigalorm.github.io/bigal/reference/configuration) - pools, read replicas, multi-database
577
- - [BigAl vs Raw SQL](https://bigalorm.github.io/bigal/advanced/bigal-vs-raw-sql) - decision framework
578
- - [Known Issues](https://bigalorm.github.io/bigal/advanced/known-issues) - workarounds and debugging
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