orange-orm 5.2.0 → 5.2.1
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/README.md +5 -4
- package/SKILL.md +1377 -0
- package/context7.json +4 -0
- package/dist/index.mjs +35 -20
- package/docs/changelog.md +2 -0
- package/package.json +4 -4
- package/src/bunPg/encodeJSON.js +6 -0
- package/src/bunPg/newTransaction.js +1 -1
- package/docs/docs.md +0 -2374
package/SKILL.md
ADDED
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
# Orange ORM — Skills & Reference
|
|
2
|
+
|
|
3
|
+
> Authoritative reference for [context7.com](https://context7.com/alfateam/orange-orm) MCP consumption.
|
|
4
|
+
> Orange ORM is the ultimate Object Relational Mapper for Node.js, Bun, and Deno.
|
|
5
|
+
> It uses the **Active Record Pattern** with full TypeScript IntelliSense — no code generation required.
|
|
6
|
+
> Supports: PostgreSQL, SQLite, MySQL, MS SQL, Oracle, SAP ASE, PGlite, Cloudflare D1.
|
|
7
|
+
> Works in the browser via Express/Hono adapters.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
1. [Defining a Model (Table Mapping)](#defining-a-model-table-mapping)
|
|
14
|
+
2. [Connecting to a Database](#connecting-to-a-database)
|
|
15
|
+
3. [Inserting Rows](#inserting-rows)
|
|
16
|
+
4. [Fetching Rows](#fetching-rows)
|
|
17
|
+
5. [Filtering (where)](#filtering-where)
|
|
18
|
+
6. [Ordering, Limit, Offset](#ordering-limit-offset)
|
|
19
|
+
7. [Updating Rows (saveChanges)](#updating-rows-savechanges)
|
|
20
|
+
8. [Deleting Rows](#deleting-rows)
|
|
21
|
+
9. [Relationships (hasMany, hasOne, references)](#relationships-hasmany-hasone-references)
|
|
22
|
+
10. [Transactions](#transactions)
|
|
23
|
+
11. [acceptChanges and clearChanges](#acceptchanges-and-clearchanges)
|
|
24
|
+
12. [Concurrency / Conflict Resolution](#concurrency--conflict-resolution)
|
|
25
|
+
13. [Fetching Strategies (Column Selection)](#fetching-strategies-column-selection)
|
|
26
|
+
14. [Aggregate Functions](#aggregate-functions)
|
|
27
|
+
15. [Data Types](#data-types)
|
|
28
|
+
16. [Enums](#enums)
|
|
29
|
+
17. [TypeScript Type Safety](#typescript-type-safety)
|
|
30
|
+
18. [Browser Usage (Express / Hono Adapters)](#browser-usage-express--hono-adapters)
|
|
31
|
+
19. [Raw SQL Queries](#raw-sql-queries)
|
|
32
|
+
20. [Logging](#logging)
|
|
33
|
+
21. [Bulk Operations (update, replace, updateChanges)](#bulk-operations)
|
|
34
|
+
22. [Batch Delete](#batch-delete)
|
|
35
|
+
23. [Composite Keys](#composite-keys)
|
|
36
|
+
24. [Discriminators](#discriminators)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Defining a Model (Table Mapping)
|
|
41
|
+
|
|
42
|
+
Use `orange.map()` to define tables and columns. Each column specifies its database column name, data type, and constraints.
|
|
43
|
+
|
|
44
|
+
**IMPORTANT**: The `.map()` method maps JavaScript property names to database column names. Always call `.primary()` on primary key columns. Use `.notNullExceptInsert()` for autoincrement keys. Use `.notNull()` for required columns.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import orange from 'orange-orm';
|
|
48
|
+
|
|
49
|
+
const map = orange.map(x => ({
|
|
50
|
+
product: x.table('product').map(({ column }) => ({
|
|
51
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
52
|
+
name: column('name').string().notNull(),
|
|
53
|
+
price: column('price').numeric(),
|
|
54
|
+
}))
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
export default map;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Column types available
|
|
61
|
+
|
|
62
|
+
- `column('col').string()` — text/varchar
|
|
63
|
+
- `column('col').numeric()` — integer/decimal/float
|
|
64
|
+
- `column('col').bigint()` — bigint
|
|
65
|
+
- `column('col').boolean()` — boolean/bit
|
|
66
|
+
- `column('col').uuid()` — UUID as string
|
|
67
|
+
- `column('col').date()` — date/datetime as ISO 8601 string
|
|
68
|
+
- `column('col').dateWithTimeZone()` — timestamp with timezone
|
|
69
|
+
- `column('col').binary()` — binary/blob as base64 string
|
|
70
|
+
- `column('col').json()` — JSON object
|
|
71
|
+
- `column('col').jsonOf<T>()` — typed JSON (TypeScript generic)
|
|
72
|
+
|
|
73
|
+
### Column modifiers
|
|
74
|
+
|
|
75
|
+
- `.primary()` — marks as primary key
|
|
76
|
+
- `.notNull()` — required, never null
|
|
77
|
+
- `.notNullExceptInsert()` — required on read, optional on insert (for autoincrement keys)
|
|
78
|
+
- `.default(value)` — default value or factory function
|
|
79
|
+
- `.validate(fn)` — custom validation function
|
|
80
|
+
- `.JSONSchema(schema)` — AJV JSON schema validation
|
|
81
|
+
- `.serializable(false)` — exclude from JSON serialization
|
|
82
|
+
- `.enum(values)` — restrict to enum values (array, object, or TypeScript enum)
|
|
83
|
+
|
|
84
|
+
### Multiple tables example
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import orange from 'orange-orm';
|
|
88
|
+
|
|
89
|
+
const map = orange.map(x => ({
|
|
90
|
+
customer: x.table('customer').map(({ column }) => ({
|
|
91
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
92
|
+
name: column('name').string(),
|
|
93
|
+
balance: column('balance').numeric(),
|
|
94
|
+
isActive: column('isActive').boolean(),
|
|
95
|
+
})),
|
|
96
|
+
|
|
97
|
+
order: x.table('_order').map(({ column }) => ({
|
|
98
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
99
|
+
orderDate: column('orderDate').date().notNull(),
|
|
100
|
+
customerId: column('customerId').numeric().notNullExceptInsert(),
|
|
101
|
+
})),
|
|
102
|
+
|
|
103
|
+
orderLine: x.table('orderLine').map(({ column }) => ({
|
|
104
|
+
id: column('id').numeric().primary(),
|
|
105
|
+
orderId: column('orderId').numeric(),
|
|
106
|
+
product: column('product').string(),
|
|
107
|
+
amount: column('amount').numeric(),
|
|
108
|
+
})),
|
|
109
|
+
|
|
110
|
+
deliveryAddress: x.table('deliveryAddress').map(({ column }) => ({
|
|
111
|
+
id: column('id').numeric().primary(),
|
|
112
|
+
orderId: column('orderId').numeric(),
|
|
113
|
+
name: column('name').string(),
|
|
114
|
+
street: column('street').string(),
|
|
115
|
+
postalCode: column('postalCode').string(),
|
|
116
|
+
postalPlace: column('postalPlace').string(),
|
|
117
|
+
countryCode: column('countryCode').string(),
|
|
118
|
+
}))
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
export default map;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Connecting to a Database
|
|
127
|
+
|
|
128
|
+
After defining your map, call a connector method to get a `db` client.
|
|
129
|
+
|
|
130
|
+
### SQLite
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import map from './map';
|
|
134
|
+
const db = map.sqlite('demo.db');
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
With connection pool:
|
|
138
|
+
```ts
|
|
139
|
+
const db = map.sqlite('demo.db', { size: 10 });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### PostgreSQL
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import map from './map';
|
|
146
|
+
const db = map.postgres('postgres://user:pass@host/dbname');
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### MySQL
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import map from './map';
|
|
153
|
+
const db = map.mysql('mysql://user:pass@host/dbname');
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### MS SQL
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import map from './map';
|
|
160
|
+
const db = map.mssql({
|
|
161
|
+
server: 'mssql',
|
|
162
|
+
options: { encrypt: false, database: 'test' },
|
|
163
|
+
authentication: {
|
|
164
|
+
type: 'default',
|
|
165
|
+
options: { userName: 'sa', password: 'password' }
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Oracle
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import map from './map';
|
|
174
|
+
const db = map.oracle({ user: 'sys', password: 'pass', connectString: 'oracle/XE', privilege: 2 });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### PGlite (in-memory Postgres)
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import map from './map';
|
|
181
|
+
const db = map.pglite();
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Cloudflare D1
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import map from './map';
|
|
188
|
+
const db = map.d1(env.DB);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### HTTP (browser client)
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import map from './map';
|
|
195
|
+
const db = map.http('http://localhost:3000/orange');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Closing connections (important for serverless)
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
await db.close();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Inserting Rows
|
|
207
|
+
|
|
208
|
+
Use `db.<table>.insert()` to insert one or more rows. Returns the inserted row(s) with active record methods.
|
|
209
|
+
|
|
210
|
+
### Insert a single row
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import map from './map';
|
|
214
|
+
const db = map.sqlite('demo.db');
|
|
215
|
+
|
|
216
|
+
const product = await db.product.insert({
|
|
217
|
+
name: 'Bicycle',
|
|
218
|
+
price: 250
|
|
219
|
+
});
|
|
220
|
+
// product = { id: 1, name: 'Bicycle', price: 250 }
|
|
221
|
+
// product has .saveChanges(), .delete(), etc.
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Insert multiple rows
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const products = await db.product.insert([
|
|
228
|
+
{ name: 'Bicycle', price: 250 },
|
|
229
|
+
{ name: 'Guitar', price: 150 }
|
|
230
|
+
]);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Insert with a fetching strategy
|
|
234
|
+
|
|
235
|
+
The second argument controls which relations to eager-load after insert:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const order = await db.order.insert({
|
|
239
|
+
orderDate: new Date(),
|
|
240
|
+
customer: george,
|
|
241
|
+
deliveryAddress: {
|
|
242
|
+
name: 'George',
|
|
243
|
+
street: 'Node street 1',
|
|
244
|
+
postalCode: '7059',
|
|
245
|
+
postalPlace: 'Jakobsli',
|
|
246
|
+
countryCode: 'NO'
|
|
247
|
+
},
|
|
248
|
+
lines: [
|
|
249
|
+
{ product: 'Bicycle', amount: 250 },
|
|
250
|
+
{ product: 'Guitar', amount: 150 }
|
|
251
|
+
]
|
|
252
|
+
}, { customer: true, deliveryAddress: true, lines: true });
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Insert and forget (no return value)
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
await db.product.insertAndForget({ name: 'Bicycle', price: 250 });
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Fetching Rows
|
|
264
|
+
|
|
265
|
+
### Get all rows
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const products = await db.product.getMany();
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Get a single row by primary key (getById)
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const product = await db.product.getById(1);
|
|
275
|
+
// Returns the row or undefined if not found
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
With a fetching strategy:
|
|
279
|
+
```ts
|
|
280
|
+
const order = await db.order.getById(1, {
|
|
281
|
+
customer: true,
|
|
282
|
+
deliveryAddress: true,
|
|
283
|
+
lines: true
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Composite primary key getById
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const line = await db.orderLine.getById('typeA', 100, 1);
|
|
291
|
+
// Arguments match the order of primary key columns
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Get one row (first match)
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const product = await db.product.getOne({
|
|
298
|
+
where: x => x.name.eq('Bicycle')
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Get many rows with a fetching strategy
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
const orders = await db.order.getMany({
|
|
306
|
+
customer: true,
|
|
307
|
+
deliveryAddress: true,
|
|
308
|
+
lines: {
|
|
309
|
+
packages: true
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Filtering (where)
|
|
317
|
+
|
|
318
|
+
Use the `where` option in `getMany` or `getOne`. The callback receives a row reference with column filter methods.
|
|
319
|
+
|
|
320
|
+
### Comparison operators
|
|
321
|
+
|
|
322
|
+
All column types support:
|
|
323
|
+
- `.equal(value)` / `.eq(value)` — equal
|
|
324
|
+
- `.notEqual(value)` / `.ne(value)` — not equal
|
|
325
|
+
- `.lessThan(value)` / `.lt(value)` — less than
|
|
326
|
+
- `.lessThanOrEqual(value)` / `.le(value)` — less than or equal
|
|
327
|
+
- `.greaterThan(value)` / `.gt(value)` — greater than
|
|
328
|
+
- `.greaterThanOrEqual(value)` / `.ge(value)` — greater than or equal
|
|
329
|
+
- `.between(from, to)` — between two values (inclusive)
|
|
330
|
+
- `.in(values)` — in a list of values
|
|
331
|
+
|
|
332
|
+
String columns also support:
|
|
333
|
+
- `.startsWith(value)` — starts with
|
|
334
|
+
- `.endsWith(value)` — ends with
|
|
335
|
+
- `.contains(value)` — contains substring
|
|
336
|
+
- `.iStartsWith(value)`, `.iEndsWith(value)`, `.iContains(value)`, `.iEqual(value)` — case-insensitive (Postgres only)
|
|
337
|
+
|
|
338
|
+
### Filter by column value
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
const products = await db.product.getMany({
|
|
342
|
+
where: x => x.price.greaterThan(50),
|
|
343
|
+
orderBy: 'name'
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Combining filters with and/or/not
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const products = await db.product.getMany({
|
|
351
|
+
where: x => x.price.greaterThan(50)
|
|
352
|
+
.and(x.name.startsWith('B'))
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const products = await db.product.getMany({
|
|
356
|
+
where: x => x.name.eq('Bicycle')
|
|
357
|
+
.or(x.name.eq('Guitar'))
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const products = await db.product.getMany({
|
|
361
|
+
where: x => x.name.eq('Bicycle').not()
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Filter across relations
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
const orders = await db.order.getMany({
|
|
369
|
+
where: x => x.customer.name.startsWith('Harry')
|
|
370
|
+
.and(x.lines.any(line => line.product.contains('broomstick'))),
|
|
371
|
+
customer: true,
|
|
372
|
+
lines: true
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Relation sub-filters: any, all, none, count
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// Orders that have at least one line containing 'guitar'
|
|
380
|
+
const rows = await db.order.getMany({
|
|
381
|
+
where: x => x.lines.any(line => line.product.contains('guitar'))
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Orders where ALL lines contain 'a'
|
|
385
|
+
const rows = await db.order.getMany({
|
|
386
|
+
where: x => x.lines.all(line => line.product.contains('a'))
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Orders with NO lines equal to 'Magic wand'
|
|
390
|
+
const rows = await db.order.getMany({
|
|
391
|
+
where: x => x.lines.none(line => line.product.eq('Magic wand'))
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Orders with at most 1 line
|
|
395
|
+
const rows = await db.order.getMany({
|
|
396
|
+
where: x => x.lines.count().le(1)
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### exists filter
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const rows = await db.order.getMany({
|
|
404
|
+
where: x => x.deliveryAddress.exists()
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Building filters separately (reusable)
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const filter = db.order.customer.name.startsWith('Harry');
|
|
412
|
+
const orders = await db.order.getMany({
|
|
413
|
+
where: filter,
|
|
414
|
+
customer: true
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Column-to-column comparison
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
const orders = await db.order.getMany({
|
|
422
|
+
where: x => x.deliveryAddress.name.eq(x.customer.name)
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Raw SQL filter
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
const rows = await db.customer.getMany({
|
|
430
|
+
where: () => ({
|
|
431
|
+
sql: 'name like ?',
|
|
432
|
+
parameters: ['%arry']
|
|
433
|
+
})
|
|
434
|
+
});
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Ordering, Limit, Offset
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const products = await db.product.getMany({
|
|
443
|
+
orderBy: 'name',
|
|
444
|
+
limit: 10,
|
|
445
|
+
offset: 5
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Multiple order-by columns
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
const products = await db.product.getMany({
|
|
453
|
+
orderBy: ['price desc', 'name']
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Ordering within relations
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
const orders = await db.order.getMany({
|
|
461
|
+
orderBy: ['orderDate desc', 'id'],
|
|
462
|
+
lines: {
|
|
463
|
+
orderBy: 'product'
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Complete example: filter + order + limit
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
const products = await db.product.getMany({
|
|
472
|
+
where: x => x.price.greaterThan(50),
|
|
473
|
+
orderBy: 'name',
|
|
474
|
+
limit: 10,
|
|
475
|
+
offset: 0
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Updating Rows (saveChanges)
|
|
482
|
+
|
|
483
|
+
Orange uses the **Active Record Pattern**. Fetch a row, modify its properties, then call `saveChanges()`. Only changed columns are sent to the database.
|
|
484
|
+
|
|
485
|
+
### Update a single row
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
const product = await db.product.getById(1);
|
|
489
|
+
product.price = 299;
|
|
490
|
+
await product.saveChanges();
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Update related rows (hasMany / hasOne)
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
const order = await db.order.getById(1, {
|
|
497
|
+
deliveryAddress: true,
|
|
498
|
+
lines: true
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
order.orderDate = new Date();
|
|
502
|
+
order.deliveryAddress = null; // deletes the hasOne child
|
|
503
|
+
order.lines.push({ product: 'Cloak of invisibility', amount: 600 }); // adds a new line
|
|
504
|
+
|
|
505
|
+
await order.saveChanges();
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Update multiple rows at once
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
let orders = await db.order.getMany({
|
|
512
|
+
orderBy: 'id',
|
|
513
|
+
lines: true,
|
|
514
|
+
deliveryAddress: true,
|
|
515
|
+
customer: true
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
orders[0].orderDate = new Date();
|
|
519
|
+
orders[0].deliveryAddress.street = 'Node street 2';
|
|
520
|
+
orders[0].lines[1].product = 'Big guitar';
|
|
521
|
+
|
|
522
|
+
orders[1].deliveryAddress = null;
|
|
523
|
+
orders[1].lines.push({ product: 'Cloak of invisibility', amount: 600 });
|
|
524
|
+
|
|
525
|
+
await orders.saveChanges();
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Selective update (bulk) with where
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
await db.order.update(
|
|
532
|
+
{ orderDate: new Date() },
|
|
533
|
+
{ where: x => x.id.eq(1) }
|
|
534
|
+
);
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Replace a row from JSON (complete overwrite)
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
const order = await db.order.replace({
|
|
541
|
+
id: 1,
|
|
542
|
+
orderDate: '2023-07-14T12:00:00',
|
|
543
|
+
customer: { id: 2 },
|
|
544
|
+
deliveryAddress: { name: 'Roger', street: 'Node street 1', postalCode: '7059', postalPlace: 'Jakobsli', countryCode: 'NO' },
|
|
545
|
+
lines: [
|
|
546
|
+
{ id: 1, product: 'Bicycle', amount: 250 },
|
|
547
|
+
{ product: 'Piano', amount: 800 }
|
|
548
|
+
]
|
|
549
|
+
}, { customer: true, deliveryAddress: true, lines: true });
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Partial update from JSON diff (updateChanges)
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
const original = { id: 1, orderDate: '2023-07-14T12:00:00', lines: [{ id: 1, product: 'Bicycle', amount: 250 }] };
|
|
556
|
+
const modified = JSON.parse(JSON.stringify(original));
|
|
557
|
+
modified.lines.push({ product: 'Piano', amount: 800 });
|
|
558
|
+
|
|
559
|
+
const order = await db.order.updateChanges(modified, original, { lines: true });
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Deleting Rows
|
|
565
|
+
|
|
566
|
+
### Delete a single row
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
const product = await db.product.getById(1);
|
|
570
|
+
await product.delete();
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### Delete an element from an array then save
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
const orders = await db.order.getMany({ lines: true });
|
|
577
|
+
orders.splice(1, 1); // remove second order
|
|
578
|
+
await orders.saveChanges(); // persists the deletion
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Delete many rows (filtered)
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
const orders = await db.order.getMany({
|
|
585
|
+
where: x => x.customer.name.eq('George')
|
|
586
|
+
});
|
|
587
|
+
await orders.delete();
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Batch delete by filter
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
const filter = db.order.deliveryAddress.name.eq('George');
|
|
594
|
+
await db.order.delete(filter);
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### Batch delete cascade
|
|
598
|
+
|
|
599
|
+
Cascade deletes also remove child rows (hasOne/hasMany):
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
const filter = db.order.deliveryAddress.name.eq('George');
|
|
603
|
+
await db.order.deleteCascade(filter);
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### Batch delete by primary key
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
await db.customer.delete([{ id: 1 }, { id: 2 }]);
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Relationships (hasMany, hasOne, references)
|
|
615
|
+
|
|
616
|
+
Relationships are defined in a second `.map()` call chained after the table definitions.
|
|
617
|
+
|
|
618
|
+
- **`hasMany(targetTable).by('foreignKeyColumn')`** — one-to-many. The target table has a foreign key pointing to the parent's primary key. The parent *owns* the children (cascade delete). Returns an **array**.
|
|
619
|
+
- **`hasOne(targetTable).by('foreignKeyColumn')`** — one-to-one. This is a special case of `hasMany` — the database models them identically (the target table has a foreign key pointing to the parent's primary key). The only difference is that `hasOne` returns a **single object** (or null) instead of an array. The parent *owns* the child (cascade delete).
|
|
620
|
+
- **`references(targetTable).by('foreignKeyColumn')`** — many-to-one. This is the **opposite direction** from `hasMany`/`hasOne`: the *current* table has a foreign key pointing to the target's primary key. The target is independent (no cascade delete). Returns a **single object** (or null).
|
|
621
|
+
|
|
622
|
+
### Example: Author and Book (one-to-many)
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
import orange from 'orange-orm';
|
|
626
|
+
|
|
627
|
+
const map = orange.map(x => ({
|
|
628
|
+
author: x.table('author').map(({ column }) => ({
|
|
629
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
630
|
+
name: column('name').string().notNull(),
|
|
631
|
+
})),
|
|
632
|
+
|
|
633
|
+
book: x.table('book').map(({ column }) => ({
|
|
634
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
635
|
+
authorId: column('authorId').numeric().notNull(),
|
|
636
|
+
title: column('title').string().notNull(),
|
|
637
|
+
year: column('year').numeric(),
|
|
638
|
+
}))
|
|
639
|
+
})).map(x => ({
|
|
640
|
+
author: x.author.map(({ hasMany }) => ({
|
|
641
|
+
books: hasMany(x.book).by('authorId')
|
|
642
|
+
})),
|
|
643
|
+
book: x.book.map(({ references }) => ({
|
|
644
|
+
author: references(x.author).by('authorId')
|
|
645
|
+
}))
|
|
646
|
+
}));
|
|
647
|
+
|
|
648
|
+
export default map;
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Query author with all their books
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
import map from './map';
|
|
655
|
+
const db = map.sqlite('demo.db');
|
|
656
|
+
|
|
657
|
+
const author = await db.author.getById(1, {
|
|
658
|
+
books: true
|
|
659
|
+
});
|
|
660
|
+
// author.books is an array of { id, authorId, title, year }
|
|
661
|
+
console.log(author.name);
|
|
662
|
+
author.books.forEach(book => console.log(book.title));
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### Query with nested relations
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
const orders = await db.order.getMany({
|
|
669
|
+
customer: true,
|
|
670
|
+
deliveryAddress: true,
|
|
671
|
+
lines: {
|
|
672
|
+
packages: true
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### Insert with nested relations
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
const order = await db.order.insert({
|
|
681
|
+
orderDate: new Date(),
|
|
682
|
+
customer: george,
|
|
683
|
+
deliveryAddress: { name: 'George', street: 'Main St', postalCode: '12345', postalPlace: 'City', countryCode: 'US' },
|
|
684
|
+
lines: [
|
|
685
|
+
{ product: 'Widget', amount: 100 }
|
|
686
|
+
]
|
|
687
|
+
}, { customer: true, deliveryAddress: true, lines: true });
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### Relationship ownership rules
|
|
691
|
+
|
|
692
|
+
- `hasMany` / `hasOne` = parent **owns** children. Deleting the parent cascades to children. Updating the parent can insert/update/delete children.
|
|
693
|
+
- `references` = independent reference. Deleting the referencing row does NOT delete the referenced row. You can set the reference to null to detach it.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Transactions
|
|
698
|
+
|
|
699
|
+
Wrap operations in `db.transaction()`. Use the `tx` parameter for all operations inside the transaction. If the callback throws, the transaction is rolled back.
|
|
700
|
+
|
|
701
|
+
```ts
|
|
702
|
+
import map from './map';
|
|
703
|
+
const db = map.sqlite('demo.db');
|
|
704
|
+
|
|
705
|
+
await db.transaction(async (tx) => {
|
|
706
|
+
const customer = await tx.customer.insert({
|
|
707
|
+
name: 'Alice',
|
|
708
|
+
balance: 100,
|
|
709
|
+
isActive: true
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const order = await tx.order.insert({
|
|
713
|
+
orderDate: new Date(),
|
|
714
|
+
customer: customer,
|
|
715
|
+
lines: [
|
|
716
|
+
{ product: 'Widget', amount: 50 }
|
|
717
|
+
]
|
|
718
|
+
}, { customer: true, lines: true });
|
|
719
|
+
|
|
720
|
+
// If anything throws here, both inserts are rolled back
|
|
721
|
+
});
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
### Transaction with saveChanges
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
await db.transaction(async (tx) => {
|
|
728
|
+
const customer = await tx.customer.getById(1);
|
|
729
|
+
customer.balance = customer.balance + 50;
|
|
730
|
+
await customer.saveChanges();
|
|
731
|
+
|
|
732
|
+
// This throw will rollback the balance update
|
|
733
|
+
throw new Error('This will rollback');
|
|
734
|
+
});
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### Active record methods work inside transactions
|
|
738
|
+
|
|
739
|
+
The `saveChanges()` method on rows fetched via the `tx` object runs within that transaction:
|
|
740
|
+
|
|
741
|
+
```ts
|
|
742
|
+
await db.transaction(async (tx) => {
|
|
743
|
+
const order = await tx.order.getById(1, { lines: true });
|
|
744
|
+
order.lines.push({ product: 'New item', amount: 100 });
|
|
745
|
+
await order.saveChanges();
|
|
746
|
+
// Committed when the callback completes without error
|
|
747
|
+
});
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
**NOTE**: Transactions are not supported for Cloudflare D1.
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## acceptChanges and clearChanges
|
|
755
|
+
|
|
756
|
+
These are **synchronous** Active Record methods available on both individual rows and arrays returned by `getMany`, `getById`, `insert`, etc.
|
|
757
|
+
|
|
758
|
+
### acceptChanges()
|
|
759
|
+
|
|
760
|
+
Marks the current in-memory values as the new "original" baseline for change tracking. After calling `acceptChanges()`, the ORM treats the current property values as the unchanged state. This means a subsequent `saveChanges()` will only send properties modified *after* the `acceptChanges()` call.
|
|
761
|
+
|
|
762
|
+
**Use case**: You have modified a row in memory but want to skip persisting those changes. Or you want to reset the change-tracking baseline after performing your own custom persistence logic.
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
const product = await db.product.getById(1);
|
|
766
|
+
product.name = 'New name';
|
|
767
|
+
product.price = 999;
|
|
768
|
+
|
|
769
|
+
// Instead of saving, accept the changes as the new baseline
|
|
770
|
+
product.acceptChanges();
|
|
771
|
+
|
|
772
|
+
// Now modifying only price:
|
|
773
|
+
product.price = 500;
|
|
774
|
+
await product.saveChanges(); // Only sends price=500 to the DB (name='New name' is already accepted)
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
On arrays:
|
|
778
|
+
```ts
|
|
779
|
+
const orders = await db.order.getMany({ lines: true });
|
|
780
|
+
orders[0].lines.push({ product: 'Temporary', amount: 0 });
|
|
781
|
+
orders.acceptChanges(); // Accepts the current array state as the baseline
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### clearChanges()
|
|
785
|
+
|
|
786
|
+
Reverts the row (or array) back to the last accepted/original state. It **undoes all in-memory mutations** since the last `acceptChanges()` (or since the row was fetched).
|
|
787
|
+
|
|
788
|
+
**Use case**: The user cancels an edit form and you want to revert to the original database state without re-fetching.
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
const product = await db.product.getById(1);
|
|
792
|
+
// product.name = 'Bicycle'
|
|
793
|
+
|
|
794
|
+
product.name = 'Changed name';
|
|
795
|
+
product.price = 999;
|
|
796
|
+
|
|
797
|
+
product.clearChanges();
|
|
798
|
+
// product.name is back to 'Bicycle'
|
|
799
|
+
// product.price is back to the original value
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
On arrays:
|
|
803
|
+
```ts
|
|
804
|
+
const orders = await db.order.getMany({ lines: true });
|
|
805
|
+
orders[0].lines.push({ product: 'Temporary', amount: 0 });
|
|
806
|
+
orders.clearChanges(); // Reverts the array to its original state
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### How they relate to saveChanges and refresh
|
|
810
|
+
|
|
811
|
+
- `saveChanges()` internally calls `acceptChanges()` after successfully persisting to the database.
|
|
812
|
+
- `refresh()` reloads from the database and then calls `acceptChanges()`.
|
|
813
|
+
- `clearChanges()` reverts to the last accepted state without hitting the database.
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## Concurrency / Conflict Resolution
|
|
818
|
+
|
|
819
|
+
Orange uses **optimistic concurrency** by default. If a property was changed by another user between fetch and save, an exception is thrown.
|
|
820
|
+
|
|
821
|
+
### Three strategies
|
|
822
|
+
|
|
823
|
+
- **`optimistic`** (default) — throws if the row was changed by another user.
|
|
824
|
+
- **`overwrite`** — overwrites regardless of interim changes.
|
|
825
|
+
- **`skipOnConflict`** — silently skips the update if the row was modified.
|
|
826
|
+
|
|
827
|
+
### Set concurrency per-column on saveChanges
|
|
828
|
+
|
|
829
|
+
```ts
|
|
830
|
+
const order = await db.order.getById(1);
|
|
831
|
+
order.orderDate = new Date();
|
|
832
|
+
await order.saveChanges({
|
|
833
|
+
orderDate: { concurrency: 'overwrite' }
|
|
834
|
+
});
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
### Set concurrency at the table level
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
const db2 = db({
|
|
841
|
+
vendor: {
|
|
842
|
+
balance: { concurrency: 'skipOnConflict' },
|
|
843
|
+
concurrency: 'overwrite'
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
### Upsert using overwrite strategy
|
|
849
|
+
|
|
850
|
+
```ts
|
|
851
|
+
const db2 = db({ vendor: { concurrency: 'overwrite' } });
|
|
852
|
+
await db2.vendor.insert({ id: 1, name: 'John', balance: 100, isActive: true });
|
|
853
|
+
// Insert again with same id — overwrites instead of throwing
|
|
854
|
+
await db2.vendor.insert({ id: 1, name: 'George', balance: 200, isActive: false });
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## Fetching Strategies (Column Selection)
|
|
860
|
+
|
|
861
|
+
Control which columns and relations to include in query results.
|
|
862
|
+
|
|
863
|
+
### Include a relation
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
const orders = await db.order.getMany({ deliveryAddress: true });
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
### Exclude a column
|
|
870
|
+
|
|
871
|
+
```ts
|
|
872
|
+
const orders = await db.order.getMany({ orderDate: false });
|
|
873
|
+
// Returns all columns except orderDate
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Include only specific columns of a relation
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
const orders = await db.order.getMany({
|
|
880
|
+
deliveryAddress: {
|
|
881
|
+
countryCode: true,
|
|
882
|
+
name: true
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### Filter within a relation
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
const orders = await db.order.getMany({
|
|
891
|
+
lines: {
|
|
892
|
+
where: x => x.product.contains('broomstick')
|
|
893
|
+
},
|
|
894
|
+
customer: true
|
|
895
|
+
});
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
---
|
|
899
|
+
|
|
900
|
+
## Aggregate Functions
|
|
901
|
+
|
|
902
|
+
Supported: `count`, `sum`, `min`, `max`, `avg`.
|
|
903
|
+
|
|
904
|
+
### Aggregates on each row
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
const orders = await db.order.getMany({
|
|
908
|
+
numberOfLines: x => x.count(x => x.lines.id),
|
|
909
|
+
totalAmount: x => x.sum(x => x.lines.amount),
|
|
910
|
+
balance: x => x.customer.balance // elevate related data
|
|
911
|
+
});
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Aggregates across all rows (group by)
|
|
915
|
+
|
|
916
|
+
```ts
|
|
917
|
+
const results = await db.order.aggregate({
|
|
918
|
+
where: x => x.orderDate.greaterThan(new Date(2022, 0, 1)),
|
|
919
|
+
customerId: x => x.customerId,
|
|
920
|
+
customerName: x => x.customer.name,
|
|
921
|
+
numberOfLines: x => x.count(x => x.lines.id),
|
|
922
|
+
totals: x => x.sum(x => x.lines.amount)
|
|
923
|
+
});
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Count rows
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
const count = await db.order.count();
|
|
930
|
+
|
|
931
|
+
// With a filter:
|
|
932
|
+
const filter = db.order.lines.any(line => line.product.contains('broomstick'));
|
|
933
|
+
const count = await db.order.count(filter);
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
## Data Types
|
|
939
|
+
|
|
940
|
+
| Orange Type | JS Type | SQL Types |
|
|
941
|
+
|---------------------|------------------|----------------------------------------------|
|
|
942
|
+
| `string()` | `string` | VARCHAR, TEXT |
|
|
943
|
+
| `numeric()` | `number` | INTEGER, DECIMAL, FLOAT, REAL, DOUBLE |
|
|
944
|
+
| `bigint()` | `bigint` | BIGINT, INTEGER |
|
|
945
|
+
| `boolean()` | `boolean` | BIT, TINYINT(1), INTEGER |
|
|
946
|
+
| `uuid()` | `string` | UUID, GUID, VARCHAR |
|
|
947
|
+
| `date()` | `string \| Date` | DATE, DATETIME, TIMESTAMP |
|
|
948
|
+
| `dateWithTimeZone()`| `string \| Date` | TIMESTAMP WITH TIME ZONE, DATETIMEOFFSET |
|
|
949
|
+
| `binary()` | `string` (base64)| BLOB, BYTEA, VARBINARY |
|
|
950
|
+
| `json()` | `object` | JSON, JSONB, NVARCHAR, TEXT |
|
|
951
|
+
| `jsonOf<T>()` | `T` | JSON, JSONB, NVARCHAR, TEXT (typed) |
|
|
952
|
+
|
|
953
|
+
```ts
|
|
954
|
+
import orange from 'orange-orm';
|
|
955
|
+
|
|
956
|
+
const map = orange.map(x => ({
|
|
957
|
+
demo: x.table('demo').map(x => ({
|
|
958
|
+
id: x.column('id').uuid().primary().notNull(),
|
|
959
|
+
name: x.column('name').string(),
|
|
960
|
+
balance: x.column('balance').numeric(),
|
|
961
|
+
regularDate: x.column('regularDate').date(),
|
|
962
|
+
tzDate: x.column('tzDate').dateWithTimeZone(),
|
|
963
|
+
picture: x.column('picture').binary(),
|
|
964
|
+
isActive: x.column('isActive').boolean(),
|
|
965
|
+
pet: x.column('pet').jsonOf<{ name: string; kind: string }>(),
|
|
966
|
+
data: x.column('data').json(),
|
|
967
|
+
}))
|
|
968
|
+
}));
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
## Enums
|
|
974
|
+
|
|
975
|
+
Enums can be defined using arrays, objects, `as const`, or TypeScript enums.
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
// Array
|
|
979
|
+
countryCode: column('countryCode').string().enum(['NO', 'SE', 'DK', 'FI'])
|
|
980
|
+
|
|
981
|
+
// TypeScript enum
|
|
982
|
+
enum CountryCode { NORWAY = 'NO', SWEDEN = 'SE' }
|
|
983
|
+
countryCode: column('countryCode').string().enum(CountryCode)
|
|
984
|
+
|
|
985
|
+
// as const object
|
|
986
|
+
const Countries = { NORWAY: 'NO', SWEDEN: 'SE' } as const;
|
|
987
|
+
countryCode: column('countryCode').string().enum(Countries)
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## TypeScript Type Safety
|
|
993
|
+
|
|
994
|
+
Orange provides full IntelliSense without code generation. The `map()` function returns a fully typed `db` object.
|
|
995
|
+
|
|
996
|
+
### Type-safe property access
|
|
997
|
+
|
|
998
|
+
```ts
|
|
999
|
+
const product = await db.product.getById(1);
|
|
1000
|
+
// product.name is typed as string | null | undefined
|
|
1001
|
+
// product.price is typed as number | null | undefined
|
|
1002
|
+
// product.id is typed as number (notNull)
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Type-safe inserts
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
// TypeScript error: 'name' is required (notNull)
|
|
1009
|
+
await db.product.insert({ price: 100 });
|
|
1010
|
+
|
|
1011
|
+
// OK: 'id' is optional because of notNullExceptInsert
|
|
1012
|
+
await db.product.insert({ name: 'Widget', price: 100 });
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Type-safe filters
|
|
1016
|
+
|
|
1017
|
+
```ts
|
|
1018
|
+
// TypeScript error: greaterThan expects number, not string
|
|
1019
|
+
db.product.getMany({ where: x => x.price.greaterThan('fifty') });
|
|
1020
|
+
|
|
1021
|
+
// OK
|
|
1022
|
+
db.product.getMany({ where: x => x.price.greaterThan(50) });
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
### Extract TypeScript types from your map
|
|
1026
|
+
|
|
1027
|
+
```ts
|
|
1028
|
+
type Product = ReturnType<typeof db.product.tsType>;
|
|
1029
|
+
// { id: number; name?: string | null; price?: number | null }
|
|
1030
|
+
|
|
1031
|
+
type ProductWithRelations = ReturnType<typeof db.order.tsType<{ lines: true; customer: true }>>;
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
---
|
|
1035
|
+
|
|
1036
|
+
## Browser Usage (Express / Hono Adapters)
|
|
1037
|
+
|
|
1038
|
+
Orange can run in the browser. The Express/Hono adapter replays client-side method calls on the server, never exposing raw SQL.
|
|
1039
|
+
|
|
1040
|
+
### Server (Express)
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
import map from './map';
|
|
1044
|
+
import { json } from 'body-parser';
|
|
1045
|
+
import express from 'express';
|
|
1046
|
+
import cors from 'cors';
|
|
1047
|
+
|
|
1048
|
+
const db = map.sqlite('demo.db');
|
|
1049
|
+
|
|
1050
|
+
express().disable('x-powered-by')
|
|
1051
|
+
.use(json({ limit: '100mb' }))
|
|
1052
|
+
.use(cors())
|
|
1053
|
+
.use('/orange', db.express())
|
|
1054
|
+
.listen(3000);
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
### Server (Hono)
|
|
1058
|
+
|
|
1059
|
+
```ts
|
|
1060
|
+
import map from './map';
|
|
1061
|
+
import { Hono } from 'hono';
|
|
1062
|
+
import { cors } from 'hono/cors';
|
|
1063
|
+
import { serve } from '@hono/node-server';
|
|
1064
|
+
|
|
1065
|
+
const db = map.sqlite('demo.db');
|
|
1066
|
+
const app = new Hono();
|
|
1067
|
+
|
|
1068
|
+
app.use('/orange', cors());
|
|
1069
|
+
app.use('/orange/*', cors());
|
|
1070
|
+
app.all('/orange', db.hono());
|
|
1071
|
+
app.all('/orange/*', db.hono());
|
|
1072
|
+
|
|
1073
|
+
serve({ fetch: app.fetch, port: 3000 });
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
### Browser client
|
|
1077
|
+
|
|
1078
|
+
```ts
|
|
1079
|
+
import map from './map';
|
|
1080
|
+
|
|
1081
|
+
const db = map.http('http://localhost:3000/orange');
|
|
1082
|
+
|
|
1083
|
+
const orders = await db.order.getMany({
|
|
1084
|
+
where: x => x.customer.name.startsWith('Harry'),
|
|
1085
|
+
lines: true
|
|
1086
|
+
});
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
### Interceptors (authentication)
|
|
1090
|
+
|
|
1091
|
+
```ts
|
|
1092
|
+
db.interceptors.request.use((config) => {
|
|
1093
|
+
config.headers.Authorization = 'Bearer <token>';
|
|
1094
|
+
return config;
|
|
1095
|
+
});
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### Base filter (row-level security)
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
.use('/orange', db.express({
|
|
1102
|
+
order: {
|
|
1103
|
+
baseFilter: (db, req, _res) => {
|
|
1104
|
+
const customerId = Number(req.headers.authorization.split(' ')[1]);
|
|
1105
|
+
return db.order.customerId.eq(customerId);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}))
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### Transaction hooks (e.g., Postgres RLS)
|
|
1112
|
+
|
|
1113
|
+
```ts
|
|
1114
|
+
.use('/orange', db.express({
|
|
1115
|
+
hooks: {
|
|
1116
|
+
transaction: {
|
|
1117
|
+
afterBegin: async (db, req) => {
|
|
1118
|
+
await db.query('set local role rls_app_user');
|
|
1119
|
+
await db.query({ sql: "select set_config('app.tenant_id', ?, true)", parameters: [tenantId] });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}))
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## Raw SQL Queries
|
|
1129
|
+
|
|
1130
|
+
```ts
|
|
1131
|
+
const rows = await db.query({
|
|
1132
|
+
sql: 'SELECT * FROM customer WHERE name LIKE ?',
|
|
1133
|
+
parameters: ['%arry']
|
|
1134
|
+
});
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
Raw SQL queries are **blocked via HTTP/browser clients** (returns 403) to prevent SQL injection.
|
|
1138
|
+
|
|
1139
|
+
---
|
|
1140
|
+
|
|
1141
|
+
## Logging
|
|
1142
|
+
|
|
1143
|
+
```ts
|
|
1144
|
+
import orange from 'orange-orm';
|
|
1145
|
+
|
|
1146
|
+
orange.on('query', (e) => {
|
|
1147
|
+
console.log(e.sql);
|
|
1148
|
+
if (e.parameters.length > 0) console.log(e.parameters);
|
|
1149
|
+
});
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
---
|
|
1153
|
+
|
|
1154
|
+
## Bulk Operations
|
|
1155
|
+
|
|
1156
|
+
### update (selective bulk update)
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
await db.order.update(
|
|
1160
|
+
{ orderDate: new Date(), customerId: 2 },
|
|
1161
|
+
{ where: x => x.id.eq(1) }
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
// With fetching strategy to return updated rows:
|
|
1165
|
+
const orders = await db.order.update(
|
|
1166
|
+
{ orderDate: new Date() },
|
|
1167
|
+
{ where: x => x.id.eq(1) },
|
|
1168
|
+
{ customer: true, lines: true }
|
|
1169
|
+
);
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
### replace (complete overwrite from JSON)
|
|
1173
|
+
|
|
1174
|
+
```ts
|
|
1175
|
+
await db.order.replace({
|
|
1176
|
+
id: 1,
|
|
1177
|
+
orderDate: '2023-07-14',
|
|
1178
|
+
lines: [{ id: 1, product: 'Bicycle', amount: 250 }]
|
|
1179
|
+
}, { lines: true });
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
### updateChanges (partial diff update)
|
|
1183
|
+
|
|
1184
|
+
```ts
|
|
1185
|
+
const original = { id: 1, name: 'George' };
|
|
1186
|
+
const modified = { id: 1, name: 'Harry' };
|
|
1187
|
+
await db.customer.updateChanges(modified, original);
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
---
|
|
1191
|
+
|
|
1192
|
+
## Batch Delete
|
|
1193
|
+
|
|
1194
|
+
```ts
|
|
1195
|
+
// By filter
|
|
1196
|
+
await db.order.delete(db.order.customer.name.eq('George'));
|
|
1197
|
+
|
|
1198
|
+
// Cascade (also deletes children)
|
|
1199
|
+
await db.order.deleteCascade(db.order.customer.name.eq('George'));
|
|
1200
|
+
|
|
1201
|
+
// By primary keys
|
|
1202
|
+
await db.customer.delete([{ id: 1 }, { id: 2 }]);
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
---
|
|
1206
|
+
|
|
1207
|
+
## Composite Keys
|
|
1208
|
+
|
|
1209
|
+
Mark multiple columns as `.primary()`:
|
|
1210
|
+
|
|
1211
|
+
```ts
|
|
1212
|
+
const map = orange.map(x => ({
|
|
1213
|
+
order: x.table('_order').map(({ column }) => ({
|
|
1214
|
+
orderType: column('orderType').string().primary().notNull(),
|
|
1215
|
+
orderNo: column('orderNo').numeric().primary().notNull(),
|
|
1216
|
+
orderDate: column('orderDate').date().notNull(),
|
|
1217
|
+
})),
|
|
1218
|
+
|
|
1219
|
+
orderLine: x.table('orderLine').map(({ column }) => ({
|
|
1220
|
+
orderType: column('orderType').string().primary().notNull(),
|
|
1221
|
+
orderNo: column('orderNo').numeric().primary().notNull(),
|
|
1222
|
+
lineNo: column('lineNo').numeric().primary().notNull(),
|
|
1223
|
+
product: column('product').string(),
|
|
1224
|
+
}))
|
|
1225
|
+
})).map(x => ({
|
|
1226
|
+
order: x.order.map(v => ({
|
|
1227
|
+
lines: v.hasMany(x.orderLine).by('orderType', 'orderNo'),
|
|
1228
|
+
}))
|
|
1229
|
+
}));
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1234
|
+
## Discriminators
|
|
1235
|
+
|
|
1236
|
+
### Column discriminators
|
|
1237
|
+
|
|
1238
|
+
Automatically set a discriminator column value on insert and filter by it on read/delete:
|
|
1239
|
+
|
|
1240
|
+
```ts
|
|
1241
|
+
const map = orange.map(x => ({
|
|
1242
|
+
customer: x.table('client').map(({ column }) => ({
|
|
1243
|
+
id: column('id').numeric().primary(),
|
|
1244
|
+
name: column('name').string()
|
|
1245
|
+
})).columnDiscriminators(`client_type='customer'`),
|
|
1246
|
+
|
|
1247
|
+
vendor: x.table('client').map(({ column }) => ({
|
|
1248
|
+
id: column('id').numeric().primary(),
|
|
1249
|
+
name: column('name').string()
|
|
1250
|
+
})).columnDiscriminators(`client_type='vendor'`),
|
|
1251
|
+
}));
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
### Formula discriminators
|
|
1255
|
+
|
|
1256
|
+
Use a logical expression instead of a static column value:
|
|
1257
|
+
|
|
1258
|
+
```ts
|
|
1259
|
+
const map = orange.map(x => ({
|
|
1260
|
+
customerBooking: x.table('booking').map(({ column }) => ({
|
|
1261
|
+
id: column('id').uuid().primary(),
|
|
1262
|
+
bookingNo: column('booking_no').numeric()
|
|
1263
|
+
})).formulaDiscriminators('@this.booking_no between 10000 and 99999'),
|
|
1264
|
+
|
|
1265
|
+
internalBooking: x.table('booking').map(({ column }) => ({
|
|
1266
|
+
id: column('id').uuid().primary(),
|
|
1267
|
+
bookingNo: column('booking_no').numeric()
|
|
1268
|
+
})).formulaDiscriminators('@this.booking_no between 1000 and 9999'),
|
|
1269
|
+
}));
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
---
|
|
1273
|
+
|
|
1274
|
+
## SQLite User-Defined Functions
|
|
1275
|
+
|
|
1276
|
+
```ts
|
|
1277
|
+
const db = map.sqlite('demo.db');
|
|
1278
|
+
|
|
1279
|
+
await db.function('add_prefix', (text, prefix) => `${prefix}${text}`);
|
|
1280
|
+
|
|
1281
|
+
const rows = await db.query(
|
|
1282
|
+
"select id, name, add_prefix(name, '[VIP] ') as prefixedName from customer"
|
|
1283
|
+
);
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
---
|
|
1287
|
+
|
|
1288
|
+
## Default Values
|
|
1289
|
+
|
|
1290
|
+
```ts
|
|
1291
|
+
import orange from 'orange-orm';
|
|
1292
|
+
import crypto from 'crypto';
|
|
1293
|
+
|
|
1294
|
+
const map = orange.map(x => ({
|
|
1295
|
+
myTable: x.table('myTable').map(({ column }) => ({
|
|
1296
|
+
id: column('id').uuid().primary().default(() => crypto.randomUUID()),
|
|
1297
|
+
name: column('name').string(),
|
|
1298
|
+
isActive: column('isActive').boolean().default(true),
|
|
1299
|
+
}))
|
|
1300
|
+
}));
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
---
|
|
1304
|
+
|
|
1305
|
+
## Validation
|
|
1306
|
+
|
|
1307
|
+
```ts
|
|
1308
|
+
function validateName(value?: string) {
|
|
1309
|
+
if (value && value.length > 10)
|
|
1310
|
+
throw new Error('Length cannot exceed 10 characters');
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const map = orange.map(x => ({
|
|
1314
|
+
demo: x.table('demo').map(x => ({
|
|
1315
|
+
id: x.column('id').uuid().primary().notNullExceptInsert(),
|
|
1316
|
+
name: x.column('name').string().validate(validateName),
|
|
1317
|
+
pet: x.column('pet').jsonOf<Pet>().JSONSchema(petSchema),
|
|
1318
|
+
}))
|
|
1319
|
+
}));
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
---
|
|
1323
|
+
|
|
1324
|
+
## Excluding Sensitive Data
|
|
1325
|
+
|
|
1326
|
+
```ts
|
|
1327
|
+
const map = orange.map(x => ({
|
|
1328
|
+
customer: x.table('customer').map(({ column }) => ({
|
|
1329
|
+
id: column('id').numeric().primary().notNullExceptInsert(),
|
|
1330
|
+
name: column('name').string(),
|
|
1331
|
+
balance: column('balance').numeric().serializable(false),
|
|
1332
|
+
}))
|
|
1333
|
+
}));
|
|
1334
|
+
|
|
1335
|
+
// When serialized: balance is excluded
|
|
1336
|
+
const george = await db.customer.insert({ name: 'George', balance: 177 });
|
|
1337
|
+
JSON.stringify(george); // '{"id":1,"name":"George"}'
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
---
|
|
1341
|
+
|
|
1342
|
+
## Quick Reference: Active Record Methods
|
|
1343
|
+
|
|
1344
|
+
Methods available on rows returned by `getMany`, `getById`, `getOne`, `insert`:
|
|
1345
|
+
|
|
1346
|
+
| Method | On row | On array | Description |
|
|
1347
|
+
|--------|--------|----------|-------------|
|
|
1348
|
+
| `saveChanges()` | ✅ | ✅ | Persist modified properties to the database |
|
|
1349
|
+
| `saveChanges(concurrency)` | ✅ | ✅ | Persist with concurrency strategy |
|
|
1350
|
+
| `acceptChanges()` | ✅ | ✅ | Accept current values as the new baseline (sync) |
|
|
1351
|
+
| `clearChanges()` | ✅ | ✅ | Revert to last accepted/original state (sync) |
|
|
1352
|
+
| `refresh()` | ✅ | ✅ | Reload from database |
|
|
1353
|
+
| `refresh(strategy)` | ✅ | ✅ | Reload with fetching strategy |
|
|
1354
|
+
| `delete()` | ✅ | ✅ | Delete the row(s) from the database |
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
## Quick Reference: Table Client Methods
|
|
1359
|
+
|
|
1360
|
+
Methods available on `db.<tableName>`:
|
|
1361
|
+
|
|
1362
|
+
| Method | Description |
|
|
1363
|
+
|--------|-------------|
|
|
1364
|
+
| `getMany(strategy?)` | Fetch multiple rows with optional filter/strategy |
|
|
1365
|
+
| `getOne(strategy?)` | Fetch first matching row |
|
|
1366
|
+
| `getById(...keys, strategy?)` | Fetch by primary key |
|
|
1367
|
+
| `insert(row, strategy?)` | Insert one row |
|
|
1368
|
+
| `insert(rows, strategy?)` | Insert multiple rows |
|
|
1369
|
+
| `insertAndForget(row)` | Insert without returning |
|
|
1370
|
+
| `update(props, {where}, strategy?)` | Bulk update matching rows |
|
|
1371
|
+
| `replace(row, strategy?)` | Complete overwrite from JSON |
|
|
1372
|
+
| `updateChanges(modified, original, strategy?)` | Partial diff update |
|
|
1373
|
+
| `delete(filter?)` | Batch delete |
|
|
1374
|
+
| `deleteCascade(filter?)` | Batch delete with cascade |
|
|
1375
|
+
| `count(filter?)` | Count matching rows |
|
|
1376
|
+
| `aggregate(strategy)` | Aggregate query (group by) |
|
|
1377
|
+
| `proxify(row, strategy?)` | Wrap plain object with active record methods |
|