@avelonjs/orm 0.1.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/LICENSE +21 -0
- package/README.md +153 -0
- package/package.json +50 -0
- package/src/index.ts +9 -0
- package/src/model.ts +655 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Yannelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# @avelonjs/orm
|
|
2
|
+
|
|
3
|
+
`@avelonjs/orm` is Scrivener, Avelon's model layer. It builds serializable `QueryIR` and never imports a database vendor. Reach for this package when application code needs models, fluent queries, ward injection, relation loads, casts, timestamps, soft deletes, or lifecycle hooks.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @avelonjs/orm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Basic Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Model } from '@avelonjs/orm'
|
|
15
|
+
import { defineConfig } from '@avelonjs/core'
|
|
16
|
+
import type { DatabaseDriver } from '@avelonjs/core'
|
|
17
|
+
|
|
18
|
+
export function boot(database: DatabaseDriver) {
|
|
19
|
+
defineConfig({
|
|
20
|
+
name: 'app',
|
|
21
|
+
drivers: { database },
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Post extends Model {
|
|
26
|
+
static override table = 'posts'
|
|
27
|
+
static override fillable = ['title', 'body', 'user_id']
|
|
28
|
+
static override casts = { published_at: 'datetime' } as const
|
|
29
|
+
static override timestamps = true
|
|
30
|
+
static override softDeletes = true
|
|
31
|
+
|
|
32
|
+
declare id: string
|
|
33
|
+
declare title: string
|
|
34
|
+
declare body: string
|
|
35
|
+
declare user_id: string
|
|
36
|
+
declare published_at: Date | null
|
|
37
|
+
|
|
38
|
+
author() {
|
|
39
|
+
return this.belongsTo(User, 'user_id')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static published() {
|
|
43
|
+
return this.query().whereNotNull('published_at')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class User extends Model {
|
|
48
|
+
static override table = 'users'
|
|
49
|
+
static override fillable = ['email', 'name']
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function latestPublished() {
|
|
53
|
+
return Post.published().with('author').latest('published_at').paginate(15)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Query Builder
|
|
58
|
+
|
|
59
|
+
The builder accumulates predicates, ordering, limits, relations, and wards, then emits `QueryIR` for the configured database driver. `where('age', 20)` equals `where('age', '=', 20)`. `orWhere` wraps the accumulated predicates in an `or` node. `paginate` uses count mode for the total.
|
|
60
|
+
|
|
61
|
+
## Models
|
|
62
|
+
|
|
63
|
+
Models declare a table, fillable attributes, optional casts, timestamps, and soft deletes. Use `declare` for attributes so hydration is not overwritten by emitted class fields. `findOrFail` raises `NotFound`. `forActor` injects the actor's ward. `Scrivener.unwarded(Post).query()` skips ward injection and is restricted by Bailiff to errands and seeds.
|
|
64
|
+
|
|
65
|
+
## Lifecycle Hooks
|
|
66
|
+
|
|
67
|
+
Writes dispatch `ModelLifecycle` on the same event bus as application events. A `creating` listener that returns `false` (or calls `stopPropagation`) aborts the insert.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { Events, defineListener } from '@avelonjs/core'
|
|
71
|
+
import { ModelLifecycle } from '@avelonjs/orm'
|
|
72
|
+
|
|
73
|
+
Events.listen(
|
|
74
|
+
ModelLifecycle,
|
|
75
|
+
defineListener({
|
|
76
|
+
handle: (event) => {
|
|
77
|
+
if (event.hook === 'creating' && event.modelName === 'Post') return false
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Method Reference
|
|
84
|
+
|
|
85
|
+
| Method | Signature | Description |
|
|
86
|
+
| ----------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
87
|
+
| `query` | `(table: string, driver?: DatabaseDriver) => QueryBuilder` | Starts a fluent builder for a table. |
|
|
88
|
+
| `QueryBuilder.select` | `(...columns: string[]) => this` | Sets the projection. |
|
|
89
|
+
| `QueryBuilder.where` | `(column: string, opOrValue: CompareOp \| unknown, value?: unknown) => this` | Adds a comparison predicate. |
|
|
90
|
+
| `QueryBuilder.wherePredicate` | `(predicate: Predicate) => this` | Adds an arbitrary predicate. |
|
|
91
|
+
| `QueryBuilder.whereNull` | `(column: string) => this` | Constrains a column to be null. |
|
|
92
|
+
| `QueryBuilder.whereNotNull` | `(column: string) => this` | Constrains a column to be non-null. |
|
|
93
|
+
| `QueryBuilder.whereIn` | `(column: string, values: readonly unknown[]) => this` | Constrains a column to a list. |
|
|
94
|
+
| `QueryBuilder.orWhere` | `(column: string, opOrValue: CompareOp \| unknown, value?: unknown) => this` | ORs a comparison with accumulated predicates. |
|
|
95
|
+
| `QueryBuilder.orderBy` | `(column: string, direction?: 'asc' \| 'desc', nulls?: 'first' \| 'last') => this` | Orders results. |
|
|
96
|
+
| `QueryBuilder.latest` | `(column?: string) => this` | Orders descending by a timestamp column. |
|
|
97
|
+
| `QueryBuilder.limit` | `(count: number) => this` | Limits rows. |
|
|
98
|
+
| `QueryBuilder.offset` | `(count: number) => this` | Offsets rows. |
|
|
99
|
+
| `QueryBuilder.with` | `(relation: string \| RelationLoad) => this` | Eager-loads a relation method or descriptor. |
|
|
100
|
+
| `QueryBuilder.ward` | `(predicate: WardInput) => this` | Injects a ward predicate. |
|
|
101
|
+
| `QueryBuilder.unwarded` | `() => this` | Skips registered ward injection. |
|
|
102
|
+
| `QueryBuilder.withTrashed` | `() => this` | Includes soft-deleted rows. |
|
|
103
|
+
| `QueryBuilder.toIR` | `(mode?: QueryIR['mode']) => QueryIR` | Builds serializable IR. |
|
|
104
|
+
| `QueryBuilder.get` | `() => Promise<readonly TModel[]>` | Executes a select and hydrates models. |
|
|
105
|
+
| `QueryBuilder.first` | `() => Promise<TModel \| null>` | Returns the first hydrated model. |
|
|
106
|
+
| `QueryBuilder.count` | `() => Promise<number>` | Executes a count. |
|
|
107
|
+
| `QueryBuilder.paginate` | `(perPage: number, page?: number) => Promise<Page<TModel>>` | Counts and selects one page. |
|
|
108
|
+
| `QueryBuilder.insert` | `(values: Row \| Row[], returning?: string[] \| '*') => Promise<QueryResult>` | Inserts rows. |
|
|
109
|
+
| `QueryBuilder.update` | `(values: Row, returning?: string[] \| '*') => Promise<QueryResult>` | Updates matching rows. |
|
|
110
|
+
| `QueryBuilder.delete` | `(returning?: string[] \| '*') => Promise<QueryResult>` | Deletes or soft-deletes matching rows. |
|
|
111
|
+
| `Model.query` | `(driver?) => QueryBuilder` | Starts a warded query for the model. |
|
|
112
|
+
| `Model.find` | `(id: string \| number) => Promise<TModel \| null>` | Finds by primary key. |
|
|
113
|
+
| `Model.findOrFail` | `(id: string \| number) => Promise<TModel>` | Finds by primary key or throws `NotFound`. |
|
|
114
|
+
| `Model.create` | `(values: Row) => Promise<TModel>` | Mass-assigns fillable attributes and saves. |
|
|
115
|
+
| `Model.forActor` | `(actor: GateActor) => QueryBuilder` | Starts a query warded for an actor. |
|
|
116
|
+
| `Model.withTrashed` | `() => QueryBuilder` | Starts a query that includes soft-deleted rows. |
|
|
117
|
+
| `Model.fill` | `(values: Row, raw?: boolean) => this` | Copies fillable attributes onto the instance. |
|
|
118
|
+
| `Model.save` | `(driver?) => Promise<this>` | Persists the instance and fires lifecycle hooks. |
|
|
119
|
+
| `Model.update` | `(values: Row) => Promise<this>` | Fills and saves. |
|
|
120
|
+
| `Model.delete` | `(driver?) => Promise<void>` | Deletes or soft-deletes the instance. |
|
|
121
|
+
| `Model.restore` | `(driver?) => Promise<this>` | Clears `deleted_at` on a soft-deleted instance. |
|
|
122
|
+
| `Model.belongsTo` | `(related, foreignKey, ownerKey?) => RelationLoad` | Builds a belongsTo descriptor. |
|
|
123
|
+
| `Model.hasOne` | `(related, foreignKey, localKey?) => RelationLoad` | Builds a hasOne descriptor. |
|
|
124
|
+
| `Model.hasMany` | `(related, foreignKey, localKey?) => RelationLoad` | Builds a hasMany descriptor. |
|
|
125
|
+
| `Scrivener.unwarded` | `(model) => { query: () => QueryBuilder }` | Starts an unwarded query. |
|
|
126
|
+
| `ModelLifecycle` | `class ModelLifecycle extends Event` | Hook name, model name, and instance for observers. |
|
|
127
|
+
| `ModelHook` | `type` | `'creating' \| 'created' \| 'updating' \| 'updated' \| 'saving' \| 'saved' \| 'deleting' \| 'deleted' \| 'restored'` |
|
|
128
|
+
| `Page` | `interface Page<TModel>` | `data`, `total`, `perPage`, `page`, and `lastPage` from `paginate`. |
|
|
129
|
+
|
|
130
|
+
## Testing
|
|
131
|
+
|
|
132
|
+
Point `defineConfig` at `FakeDatabase` from `@avelonjs/conformance` and assert on `toIR()` output or hydrated models.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { defineConfig } from '@avelonjs/core'
|
|
136
|
+
import { FakeDatabase } from '@avelonjs/conformance'
|
|
137
|
+
import { Model } from '@avelonjs/orm'
|
|
138
|
+
|
|
139
|
+
defineConfig({ name: 'test', drivers: { database: new FakeDatabase() } })
|
|
140
|
+
|
|
141
|
+
export class User extends Model {
|
|
142
|
+
static override table = 'assay_users'
|
|
143
|
+
static override fillable = ['id', 'email', 'name', 'age', 'nickname']
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await User.create({
|
|
147
|
+
id: 'u1',
|
|
148
|
+
email: 'one@example.test',
|
|
149
|
+
name: 'One',
|
|
150
|
+
age: 20,
|
|
151
|
+
nickname: null,
|
|
152
|
+
})
|
|
153
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avelonjs/orm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Model layer and QueryIR builder for Avelon applications.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ryan Yannelli <ryanyannelli@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/yannelli/avelon",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yannelli/avelon.git",
|
|
12
|
+
"directory": "packages/orm"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yannelli/avelon/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"avelon",
|
|
19
|
+
"typescript",
|
|
20
|
+
"orm",
|
|
21
|
+
"query-ir"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./src/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@avelonjs/core": "workspace:*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@avelonjs/conformance": "workspace:*",
|
|
44
|
+
"@types/bun": "1.3.14",
|
|
45
|
+
"typescript": "5.9.3"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.3.14"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
package/src/model.ts
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DB,
|
|
3
|
+
Event,
|
|
4
|
+
Events,
|
|
5
|
+
Gate,
|
|
6
|
+
Invalid,
|
|
7
|
+
NotFound,
|
|
8
|
+
injectWard,
|
|
9
|
+
resolveWard,
|
|
10
|
+
type CompareOp,
|
|
11
|
+
type DatabaseDriver,
|
|
12
|
+
type DatabaseTransaction,
|
|
13
|
+
type GateActor,
|
|
14
|
+
type OrderTerm,
|
|
15
|
+
type Predicate,
|
|
16
|
+
type QueryIR,
|
|
17
|
+
type QueryResult,
|
|
18
|
+
type RelationLoad,
|
|
19
|
+
type WardInput,
|
|
20
|
+
} from '@avelonjs/core'
|
|
21
|
+
|
|
22
|
+
type Row = Record<string, unknown>
|
|
23
|
+
type CastKind = 'datetime' | 'json' | 'number' | 'boolean'
|
|
24
|
+
|
|
25
|
+
/** Lifecycle hooks dispatched on the shared event bus. */
|
|
26
|
+
export type ModelHook =
|
|
27
|
+
| 'creating'
|
|
28
|
+
| 'created'
|
|
29
|
+
| 'updating'
|
|
30
|
+
| 'updated'
|
|
31
|
+
| 'saving'
|
|
32
|
+
| 'saved'
|
|
33
|
+
| 'deleting'
|
|
34
|
+
| 'deleted'
|
|
35
|
+
| 'restored'
|
|
36
|
+
|
|
37
|
+
/** Event fired for every model lifecycle hook so observers are ordinary listeners. */
|
|
38
|
+
export class ModelLifecycle extends Event {
|
|
39
|
+
constructor(
|
|
40
|
+
readonly modelName: string,
|
|
41
|
+
readonly hook: ModelHook,
|
|
42
|
+
readonly model: Model,
|
|
43
|
+
) {
|
|
44
|
+
super()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Page of hydrated models returned by `paginate`. */
|
|
49
|
+
export interface Page<TModel> {
|
|
50
|
+
data: readonly TModel[]
|
|
51
|
+
total: number
|
|
52
|
+
perPage: number
|
|
53
|
+
page: number
|
|
54
|
+
lastPage: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ModelClass = typeof Model & { new (): Model }
|
|
58
|
+
|
|
59
|
+
function ctorOf(model: Model): ModelClass {
|
|
60
|
+
return model.constructor as ModelClass
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tableOf(modelClass: ModelClass): string {
|
|
64
|
+
return modelClass.table
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function keyOf(modelClass: ModelClass): string {
|
|
68
|
+
return modelClass.key
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function asRecord(model: Model): Row {
|
|
72
|
+
return model as unknown as Row
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function read(model: Model, column: string): unknown {
|
|
76
|
+
return asRecord(model)[column]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function write(model: Model, column: string, value: unknown): void {
|
|
80
|
+
asRecord(model)[column] = value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function castIn(kind: CastKind | undefined, value: unknown): unknown {
|
|
84
|
+
if (kind === undefined || value === null || value === undefined) return value
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case 'datetime':
|
|
87
|
+
return value instanceof Date ? value : new Date(String(value))
|
|
88
|
+
case 'json':
|
|
89
|
+
return typeof value === 'string' ? (JSON.parse(value) as unknown) : value
|
|
90
|
+
case 'number':
|
|
91
|
+
return typeof value === 'number' ? value : Number(value)
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return Boolean(value)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function castOut(kind: CastKind | undefined, value: unknown): unknown {
|
|
98
|
+
if (kind === undefined || value === null || value === undefined) return value
|
|
99
|
+
switch (kind) {
|
|
100
|
+
case 'datetime':
|
|
101
|
+
return value instanceof Date ? value.toISOString() : value
|
|
102
|
+
case 'json':
|
|
103
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
104
|
+
case 'number':
|
|
105
|
+
case 'boolean':
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fillableKeys(modelClass: ModelClass, values: Row, raw: boolean): string[] {
|
|
111
|
+
if (raw) return Object.keys(values)
|
|
112
|
+
return modelClass.fillable.filter((key) => Object.hasOwn(values, key))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fire(model: Model, hook: ModelHook): Promise<boolean> {
|
|
116
|
+
const event = new ModelLifecycle(ctorOf(model).name, hook, model)
|
|
117
|
+
await Events.dispatch(event)
|
|
118
|
+
return !event.isPropagationStopped()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fluent query builder that emits portable `QueryIR` and hydrates models. */
|
|
122
|
+
export class QueryBuilder<TModel extends Model = Model> {
|
|
123
|
+
readonly #model: ModelClass
|
|
124
|
+
readonly #driver: DatabaseDriver | DatabaseTransaction
|
|
125
|
+
#select: string[] | '*' = '*'
|
|
126
|
+
#where: Predicate[] = []
|
|
127
|
+
#order: OrderTerm[] = []
|
|
128
|
+
#relations: RelationLoad[] = []
|
|
129
|
+
#limit: number | undefined
|
|
130
|
+
#offset: number | undefined
|
|
131
|
+
#ward: WardInput | undefined
|
|
132
|
+
#unwarded = false
|
|
133
|
+
#withTrashed = false
|
|
134
|
+
#actor: GateActor | undefined
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
model: ModelClass,
|
|
138
|
+
driver: DatabaseDriver | DatabaseTransaction = DB.driver,
|
|
139
|
+
options: { unwarded?: boolean; withTrashed?: boolean; actor?: GateActor } = {},
|
|
140
|
+
) {
|
|
141
|
+
this.#model = model
|
|
142
|
+
this.#driver = driver
|
|
143
|
+
this.#unwarded = options.unwarded ?? false
|
|
144
|
+
this.#withTrashed = options.withTrashed ?? false
|
|
145
|
+
this.#actor = options.actor
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Starts an unwarded builder. Restricted by Bailiff to errands and seeds. */
|
|
149
|
+
unwarded(): this {
|
|
150
|
+
this.#unwarded = true
|
|
151
|
+
return this
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Includes soft-deleted rows. */
|
|
155
|
+
withTrashed(): this {
|
|
156
|
+
this.#withTrashed = true
|
|
157
|
+
return this
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Restricts the projection. */
|
|
161
|
+
select(...columns: string[]): this {
|
|
162
|
+
this.#select = columns.length === 0 ? '*' : columns
|
|
163
|
+
return this
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Adds a comparison predicate.
|
|
168
|
+
*
|
|
169
|
+
* `where('age', 20)` equals `where('age', '=', 20)`.
|
|
170
|
+
*/
|
|
171
|
+
where(column: string, opOrValue: CompareOp | unknown, value?: unknown): this {
|
|
172
|
+
if (value === undefined) {
|
|
173
|
+
this.#where.push({ kind: 'compare', column, op: '=', value: opOrValue })
|
|
174
|
+
} else {
|
|
175
|
+
this.#where.push({
|
|
176
|
+
kind: 'compare',
|
|
177
|
+
column,
|
|
178
|
+
op: opOrValue as CompareOp,
|
|
179
|
+
value,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return this
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Adds a raw predicate object. */
|
|
186
|
+
wherePredicate(predicate: Predicate): this {
|
|
187
|
+
this.#where.push(predicate)
|
|
188
|
+
return this
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Constrains a column to be null. */
|
|
192
|
+
whereNull(column: string): this {
|
|
193
|
+
this.#where.push({ kind: 'null', column, negated: false })
|
|
194
|
+
return this
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Constrains a column to be non-null. */
|
|
198
|
+
whereNotNull(column: string): this {
|
|
199
|
+
this.#where.push({ kind: 'null', column, negated: true })
|
|
200
|
+
return this
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Constrains a column to a list of values. */
|
|
204
|
+
whereIn(column: string, values: readonly unknown[]): this {
|
|
205
|
+
this.#where.push({ kind: 'in', column, values: [...values], negated: false })
|
|
206
|
+
return this
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** ORs a comparison with the predicates accumulated so far. */
|
|
210
|
+
orWhere(column: string, opOrValue: CompareOp | unknown, value?: unknown): this {
|
|
211
|
+
const next: Predicate =
|
|
212
|
+
value === undefined
|
|
213
|
+
? { kind: 'compare', column, op: '=', value: opOrValue }
|
|
214
|
+
: { kind: 'compare', column, op: opOrValue as CompareOp, value }
|
|
215
|
+
if (this.#where.length === 0) {
|
|
216
|
+
this.#where.push(next)
|
|
217
|
+
return this
|
|
218
|
+
}
|
|
219
|
+
const previous = this.#where.splice(0, this.#where.length)
|
|
220
|
+
this.#where.push({
|
|
221
|
+
kind: 'or',
|
|
222
|
+
predicates: [
|
|
223
|
+
previous.length === 1 ? (previous[0] as Predicate) : { kind: 'and', predicates: previous },
|
|
224
|
+
next,
|
|
225
|
+
],
|
|
226
|
+
})
|
|
227
|
+
return this
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Orders the result. */
|
|
231
|
+
orderBy(column: string, direction: 'asc' | 'desc' = 'asc', nulls?: 'first' | 'last'): this {
|
|
232
|
+
this.#order.push(nulls === undefined ? { column, direction } : { column, direction, nulls })
|
|
233
|
+
return this
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Newest-first helper for timestamp columns. */
|
|
237
|
+
latest(column = 'created_at'): this {
|
|
238
|
+
return this.orderBy(column, 'desc')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Limits the result count. */
|
|
242
|
+
limit(count: number): this {
|
|
243
|
+
this.#limit = count
|
|
244
|
+
return this
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Offsets the result window. */
|
|
248
|
+
offset(count: number): this {
|
|
249
|
+
this.#offset = count
|
|
250
|
+
return this
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Eager-loads a relation name declared on the model, or a fully resolved descriptor. */
|
|
254
|
+
with(relation: string | RelationLoad): this {
|
|
255
|
+
this.#relations.push(typeof relation === 'string' ? this.#resolveRelation(relation) : relation)
|
|
256
|
+
return this
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Applies a ward predicate for row scoping. */
|
|
260
|
+
ward(predicate: WardInput): this {
|
|
261
|
+
this.#ward = predicate
|
|
262
|
+
return this
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Builds the serializable query IR without executing it. */
|
|
266
|
+
toIR(mode: QueryIR['mode'] = 'select'): QueryIR {
|
|
267
|
+
const where = [...this.#where]
|
|
268
|
+
if (this.#model.softDeletes && !this.#withTrashed) {
|
|
269
|
+
where.push({ kind: 'null', column: 'deleted_at', negated: false })
|
|
270
|
+
}
|
|
271
|
+
const base: QueryIR = {
|
|
272
|
+
table: tableOf(this.#model),
|
|
273
|
+
mode,
|
|
274
|
+
select: mode === 'count' ? [] : this.#select,
|
|
275
|
+
where,
|
|
276
|
+
relations: mode === 'select' ? this.#relations : [],
|
|
277
|
+
order: mode === 'count' ? [] : this.#order,
|
|
278
|
+
...(this.#limit !== undefined && mode !== 'count' ? { limit: this.#limit } : {}),
|
|
279
|
+
...(this.#offset !== undefined && mode !== 'count' ? { offset: this.#offset } : {}),
|
|
280
|
+
}
|
|
281
|
+
if (this.#unwarded) return this.#ward === undefined ? base : injectWard(base, this.#ward)
|
|
282
|
+
const resolved = this.#resolvedWard()
|
|
283
|
+
if (resolved === undefined && this.#ward === undefined) return base
|
|
284
|
+
return injectWard(base, this.#ward ?? resolved ?? true)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Executes a select and hydrates models. */
|
|
288
|
+
async get(): Promise<readonly TModel[]> {
|
|
289
|
+
const result = await this.#driver.execute<Row>(this.toIR('select'))
|
|
290
|
+
return result.rows.map((row) => hydrate(this.#model, row) as TModel)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Executes a select and returns the first model or null. */
|
|
294
|
+
async first(): Promise<TModel | null> {
|
|
295
|
+
const rows = await this.limit(1).get()
|
|
296
|
+
return rows[0] ?? null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Executes a count query. */
|
|
300
|
+
async count(): Promise<number> {
|
|
301
|
+
const result = await this.#driver.execute(this.toIR('count'))
|
|
302
|
+
return result.count ?? 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Paginates using count plus a limited select. */
|
|
306
|
+
async paginate(perPage: number, page = 1): Promise<Page<TModel>> {
|
|
307
|
+
if (!Number.isInteger(perPage) || perPage < 1 || !Number.isInteger(page) || page < 1) {
|
|
308
|
+
throw new Invalid('Pagination requires positive integer perPage and page.', {
|
|
309
|
+
metadata: { fields: { page: ['perPage and page must be positive integers.'] } },
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
const total = await this.count()
|
|
313
|
+
const data = await this.offset((page - 1) * perPage)
|
|
314
|
+
.limit(perPage)
|
|
315
|
+
.get()
|
|
316
|
+
return {
|
|
317
|
+
data,
|
|
318
|
+
total,
|
|
319
|
+
perPage,
|
|
320
|
+
page,
|
|
321
|
+
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Inserts one or more rows. */
|
|
326
|
+
async insert(values: Row | Row[], returning?: string[] | '*'): Promise<QueryResult<Row>> {
|
|
327
|
+
return this.#driver.execute<Row>({
|
|
328
|
+
table: tableOf(this.#model),
|
|
329
|
+
mode: 'insert',
|
|
330
|
+
select: [],
|
|
331
|
+
where: [],
|
|
332
|
+
relations: [],
|
|
333
|
+
order: [],
|
|
334
|
+
values,
|
|
335
|
+
returning,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Updates matching rows. */
|
|
340
|
+
async update(values: Row, returning?: string[] | '*'): Promise<QueryResult<Row>> {
|
|
341
|
+
return this.#driver.execute<Row>({
|
|
342
|
+
...this.toIR('update'),
|
|
343
|
+
mode: 'update',
|
|
344
|
+
select: [],
|
|
345
|
+
values,
|
|
346
|
+
returning,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Deletes matching rows, soft-deleting when the model enables it. */
|
|
351
|
+
async delete(returning?: string[] | '*'): Promise<QueryResult<Row>> {
|
|
352
|
+
if (this.#model.softDeletes) {
|
|
353
|
+
return this.update({ deleted_at: new Date().toISOString() }, returning)
|
|
354
|
+
}
|
|
355
|
+
return this.#driver.execute<Row>({
|
|
356
|
+
...this.toIR('delete'),
|
|
357
|
+
mode: 'delete',
|
|
358
|
+
select: [],
|
|
359
|
+
returning,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#resolvedWard(): WardInput | undefined {
|
|
364
|
+
try {
|
|
365
|
+
return resolveWard(
|
|
366
|
+
this.#model,
|
|
367
|
+
'read',
|
|
368
|
+
this.#actor === undefined ? Gate.actor() : this.#actor,
|
|
369
|
+
)
|
|
370
|
+
} catch {
|
|
371
|
+
return this.#model.ward?.(this.#actor === undefined ? Gate.actor() : this.#actor)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#resolveRelation(path: string): RelationLoad {
|
|
376
|
+
if (path.includes('.')) {
|
|
377
|
+
throw new Invalid('Nested with() paths need RelationLoad.relations.', {
|
|
378
|
+
metadata: { fields: { with: [`${path} is nested; pass a RelationLoad with relations.`] } },
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
if (path.length === 0) {
|
|
382
|
+
throw new Invalid('Relation name is required.', {
|
|
383
|
+
metadata: { fields: { with: ['Relation name is required.'] } },
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
const instance = new this.#model()
|
|
387
|
+
const method = Reflect.get(instance, path)
|
|
388
|
+
if (typeof method !== 'function') {
|
|
389
|
+
throw new Invalid(`Unknown relation ${path} on ${this.#model.name}.`, {
|
|
390
|
+
metadata: { fields: { with: [`${this.#model.name} has no ${path}() relation.`] } },
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
const descriptor = method.call(instance) as RelationLoad
|
|
394
|
+
return { ...descriptor, relation: path }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function hydrate(modelClass: ModelClass, row: Row): Model {
|
|
399
|
+
const model = new modelClass()
|
|
400
|
+
for (const [column, value] of Object.entries(row)) {
|
|
401
|
+
write(model, column, castIn(modelClass.casts[column], value))
|
|
402
|
+
}
|
|
403
|
+
model.exists = true
|
|
404
|
+
return model
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function serialize(model: Model, keys: readonly string[]): Row {
|
|
408
|
+
const modelClass = ctorOf(model)
|
|
409
|
+
const values: Row = {}
|
|
410
|
+
for (const key of keys) {
|
|
411
|
+
values[key] = castOut(modelClass.casts[key], read(model, key))
|
|
412
|
+
}
|
|
413
|
+
return values
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Base Scrivener model. Subclasses use `declare` for attributes, never emitted fields. */
|
|
417
|
+
export class Model {
|
|
418
|
+
/** Database table name. */
|
|
419
|
+
static table = ''
|
|
420
|
+
/** Primary key column. */
|
|
421
|
+
static key = 'id'
|
|
422
|
+
/** Mass-assignable attributes. */
|
|
423
|
+
static fillable: readonly string[] = []
|
|
424
|
+
/** Attribute casts applied on hydrate and persist. */
|
|
425
|
+
static casts: Readonly<Record<string, CastKind>> = {}
|
|
426
|
+
/** When true, `created_at` and `updated_at` are maintained. */
|
|
427
|
+
static timestamps = false
|
|
428
|
+
/** When true, `delete()` writes `deleted_at` instead of removing the row. */
|
|
429
|
+
static softDeletes = false
|
|
430
|
+
/** Optional ward factory used when no `defineWard` registration exists. */
|
|
431
|
+
static ward?: (actor: GateActor) => WardInput
|
|
432
|
+
|
|
433
|
+
/** Whether this instance has been persisted. */
|
|
434
|
+
exists = false
|
|
435
|
+
|
|
436
|
+
/** Starts a query for this model. */
|
|
437
|
+
static query<T extends typeof Model>(
|
|
438
|
+
this: T,
|
|
439
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
440
|
+
): QueryBuilder<InstanceType<T>> {
|
|
441
|
+
return new QueryBuilder(this as ModelClass, driver ?? DB.driver)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Includes soft-deleted rows. */
|
|
445
|
+
static withTrashed<T extends typeof Model>(this: T): QueryBuilder<InstanceType<T>> {
|
|
446
|
+
return this.query().withTrashed()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Applies the model ward for the supplied actor. */
|
|
450
|
+
static forActor<T extends typeof Model>(
|
|
451
|
+
this: T,
|
|
452
|
+
actor: GateActor,
|
|
453
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
454
|
+
): QueryBuilder<InstanceType<T>> {
|
|
455
|
+
return new QueryBuilder(this as ModelClass, driver ?? DB.driver, { actor })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Finds a row by primary key or returns null. */
|
|
459
|
+
static async find<T extends typeof Model>(
|
|
460
|
+
this: T,
|
|
461
|
+
id: string | number,
|
|
462
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
463
|
+
): Promise<InstanceType<T> | null> {
|
|
464
|
+
return this.query(driver).where(this.key, id).first()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Finds a row by primary key or throws `NotFound`. */
|
|
468
|
+
static async findOrFail<T extends typeof Model>(
|
|
469
|
+
this: T,
|
|
470
|
+
id: string | number,
|
|
471
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
472
|
+
): Promise<InstanceType<T>> {
|
|
473
|
+
const model = await this.find(id, driver)
|
|
474
|
+
if (model === null) {
|
|
475
|
+
throw new NotFound(`${this.name} was not found.`, {
|
|
476
|
+
metadata: { resource: this.table || this.name, identifier: id },
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
return model
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Mass-assigns fillable attributes, persists, and returns the hydrated model. */
|
|
483
|
+
static async create<T extends typeof Model>(
|
|
484
|
+
this: T,
|
|
485
|
+
values: Row,
|
|
486
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
487
|
+
): Promise<InstanceType<T>> {
|
|
488
|
+
const model = new this() as InstanceType<T>
|
|
489
|
+
model.fill(values)
|
|
490
|
+
await model.save(driver)
|
|
491
|
+
return model
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Copies fillable attributes onto the instance. */
|
|
495
|
+
fill(values: Row, raw = false): this {
|
|
496
|
+
const modelClass = ctorOf(this)
|
|
497
|
+
for (const key of fillableKeys(modelClass, values, raw)) {
|
|
498
|
+
const value = values[key]
|
|
499
|
+
write(this, key, castIn(modelClass.casts[key], value))
|
|
500
|
+
}
|
|
501
|
+
return this
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Persists the instance, firing lifecycle hooks. */
|
|
505
|
+
async save(driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
|
|
506
|
+
const modelClass = ctorOf(this)
|
|
507
|
+
const saving = await fire(this, 'saving')
|
|
508
|
+
if (!saving) return this
|
|
509
|
+
if (!this.exists) {
|
|
510
|
+
if (!(await fire(this, 'creating'))) return this
|
|
511
|
+
if (modelClass.timestamps) {
|
|
512
|
+
const now = new Date().toISOString()
|
|
513
|
+
if (read(this, 'created_at') === undefined) write(this, 'created_at', now)
|
|
514
|
+
write(this, 'updated_at', now)
|
|
515
|
+
}
|
|
516
|
+
const keys = persistKeys(this)
|
|
517
|
+
const result = await modelClass.query(driver).insert(serialize(this, keys), '*')
|
|
518
|
+
const row = result.rows[0]
|
|
519
|
+
if (row !== undefined) {
|
|
520
|
+
for (const [column, value] of Object.entries(row)) {
|
|
521
|
+
write(this, column, castIn(modelClass.casts[column], value))
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
this.exists = true
|
|
525
|
+
await fire(this, 'created')
|
|
526
|
+
} else {
|
|
527
|
+
if (!(await fire(this, 'updating'))) return this
|
|
528
|
+
if (modelClass.timestamps) write(this, 'updated_at', new Date().toISOString())
|
|
529
|
+
const keys = persistKeys(this)
|
|
530
|
+
const id = read(this, keyOf(modelClass))
|
|
531
|
+
await modelClass.query(driver).where(keyOf(modelClass), id).update(serialize(this, keys))
|
|
532
|
+
await fire(this, 'updated')
|
|
533
|
+
}
|
|
534
|
+
await fire(this, 'saved')
|
|
535
|
+
return this
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Mass-assigns fillable attributes and persists. */
|
|
539
|
+
async update(values: Row, driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
|
|
540
|
+
this.fill(values)
|
|
541
|
+
return this.save(driver)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Deletes the instance, soft-deleting when enabled. */
|
|
545
|
+
async delete(driver?: DatabaseDriver | DatabaseTransaction): Promise<void> {
|
|
546
|
+
if (!(await fire(this, 'deleting'))) return
|
|
547
|
+
const modelClass = ctorOf(this)
|
|
548
|
+
const id = read(this, keyOf(modelClass))
|
|
549
|
+
await modelClass.query(driver).where(keyOf(modelClass), id).delete()
|
|
550
|
+
this.exists = false
|
|
551
|
+
await fire(this, 'deleted')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Restores a soft-deleted instance. */
|
|
555
|
+
async restore(driver?: DatabaseDriver | DatabaseTransaction): Promise<this> {
|
|
556
|
+
const modelClass = ctorOf(this)
|
|
557
|
+
write(this, 'deleted_at', null)
|
|
558
|
+
const id = read(this, keyOf(modelClass))
|
|
559
|
+
await modelClass.withTrashed().where(keyOf(modelClass), id).update({ deleted_at: null })
|
|
560
|
+
this.exists = true
|
|
561
|
+
await fire(this, 'restored')
|
|
562
|
+
return this
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Declares a belongsTo relation for eager loading. */
|
|
566
|
+
belongsTo(
|
|
567
|
+
related: ModelClass,
|
|
568
|
+
foreignKey: string,
|
|
569
|
+
ownerKey = 'id',
|
|
570
|
+
relation = related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
|
|
571
|
+
): RelationLoad {
|
|
572
|
+
return {
|
|
573
|
+
relation,
|
|
574
|
+
kind: 'belongsTo',
|
|
575
|
+
table: related.table,
|
|
576
|
+
localKey: foreignKey,
|
|
577
|
+
foreignKey: ownerKey,
|
|
578
|
+
select: '*',
|
|
579
|
+
where: null,
|
|
580
|
+
order: [],
|
|
581
|
+
relations: [],
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Declares a hasOne relation for eager loading. */
|
|
586
|
+
hasOne(related: ModelClass, foreignKey: string, localKey = 'id'): RelationLoad {
|
|
587
|
+
return {
|
|
588
|
+
relation: related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
|
|
589
|
+
kind: 'hasOne',
|
|
590
|
+
table: related.table,
|
|
591
|
+
localKey,
|
|
592
|
+
foreignKey,
|
|
593
|
+
select: '*',
|
|
594
|
+
where: null,
|
|
595
|
+
order: [],
|
|
596
|
+
relations: [],
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Declares a hasMany relation for eager loading. */
|
|
601
|
+
hasMany(related: ModelClass, foreignKey: string, localKey = 'id'): RelationLoad {
|
|
602
|
+
return {
|
|
603
|
+
relation: related.name.replace(/^[A-Z]/, (letter) => letter.toLowerCase()),
|
|
604
|
+
kind: 'hasMany',
|
|
605
|
+
table: related.table,
|
|
606
|
+
localKey,
|
|
607
|
+
foreignKey,
|
|
608
|
+
select: '*',
|
|
609
|
+
where: null,
|
|
610
|
+
order: [],
|
|
611
|
+
relations: [],
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function persistKeys(model: Model): string[] {
|
|
617
|
+
const modelClass = ctorOf(model)
|
|
618
|
+
const keys = new Set<string>([...modelClass.fillable])
|
|
619
|
+
keys.add(keyOf(modelClass))
|
|
620
|
+
if (modelClass.timestamps) {
|
|
621
|
+
keys.add('created_at')
|
|
622
|
+
keys.add('updated_at')
|
|
623
|
+
}
|
|
624
|
+
if (modelClass.softDeletes) keys.add('deleted_at')
|
|
625
|
+
for (const key of Object.keys(asRecord(model))) {
|
|
626
|
+
if (typeof asRecord(model)[key] !== 'function' && key !== 'exists') keys.add(key)
|
|
627
|
+
}
|
|
628
|
+
return [...keys].filter((key) => read(model, key) !== undefined)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Starts a query against a table using an anonymous model class. */
|
|
632
|
+
export function query<TRow extends Row = Row>(
|
|
633
|
+
table: string,
|
|
634
|
+
driver?: DatabaseDriver | DatabaseTransaction,
|
|
635
|
+
): QueryBuilder {
|
|
636
|
+
class Anonymous extends Model {
|
|
637
|
+
static override table = table
|
|
638
|
+
static override fillable: readonly string[] = []
|
|
639
|
+
}
|
|
640
|
+
Object.defineProperty(Anonymous, 'name', { value: table })
|
|
641
|
+
return new QueryBuilder(Anonymous, driver ?? DB.driver) as QueryBuilder<Model & TRow>
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Scrivener entry for operations that Bailiff later restricts by directory. */
|
|
645
|
+
export const Scrivener = {
|
|
646
|
+
/** Returns an unwarded query for a model. Allowed only in errands and seeds. */
|
|
647
|
+
unwarded<T extends typeof Model>(model: T): { query: () => QueryBuilder<InstanceType<T>> } {
|
|
648
|
+
return {
|
|
649
|
+
query: () =>
|
|
650
|
+
new QueryBuilder(model as ModelClass, DB.driver, { unwarded: true }) as QueryBuilder<
|
|
651
|
+
InstanceType<T>
|
|
652
|
+
>,
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
}
|