@tylix/orm 0.5.1 → 0.7.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 +40 -15
- package/package.json +33 -6
- package/src/adapters/DatabaseAdapter.d.ts +9 -0
- package/src/adapters/DatabaseAdapter.js +33 -2
- package/src/adapters/MongoAdapter.js +95 -16
- package/src/adapters/MysqlAdapter.js +6 -0
- package/src/adapters/PostgresAdapter.js +8 -1
- package/src/adapters/SqliteAdapter.js +54 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -0
- package/src/instrumentation.d.ts +10 -0
- package/src/instrumentation.js +26 -0
- package/src/model/Model.d.ts +51 -10
- package/src/model/Model.js +181 -5
- package/src/model/observers.js +83 -0
- package/src/model/traits.d.ts +30 -0
- package/src/model/traits.js +61 -0
- package/src/query/QueryBuilder.d.ts +9 -0
- package/src/query/QueryBuilder.js +61 -2
- package/src/query/buildSelectSql.js +110 -14
- package/src/query/types.d.ts +40 -2
- package/src/adapters/AdapterFactory.test.js +0 -35
- package/src/adapters/MongoAdapter.test.js +0 -52
- package/src/adapters/MysqlAdapter.test.js +0 -36
- package/src/adapters/PostgresAdapter.test.js +0 -47
- package/src/adapters/SqliteAdapter.test.js +0 -39
- package/src/adapters/query.test.js +0 -60
- package/src/model/Model.paginate.test.js +0 -74
- package/src/model/Model.relations.test.js +0 -86
- package/src/model/Model.test.js +0 -91
- package/src/query/QueryBuilder.test.js +0 -82
- package/src/query/buildSelectSql.test.js +0 -58
- package/tsconfig.json +0 -14
package/src/model/Model.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// orm/src/model/Model.d.ts
|
|
2
2
|
import { QueryBuilder } from '../query/QueryBuilder.js';
|
|
3
|
+
import type { WhereClause } from '../query/types.js';
|
|
3
4
|
|
|
4
5
|
interface PaginateOptions {
|
|
5
6
|
page?: number;
|
|
@@ -17,33 +18,73 @@ interface PaginateResult<T> {
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
type Row = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** Hooks an observer may define; see observers.js. All optional. */
|
|
24
|
+
export interface Observer {
|
|
25
|
+
creating?(data: Row, model: typeof Model): Row | void | Promise<Row | void>;
|
|
26
|
+
created?(row: Row, model: typeof Model): unknown;
|
|
27
|
+
updating?(data: Row, before: Row | null, model: typeof Model): Row | void | Promise<Row | void>;
|
|
28
|
+
updated?(row: Row, before: Row | null, model: typeof Model): unknown;
|
|
29
|
+
deleting?(row: Row, model: typeof Model): unknown;
|
|
30
|
+
deleted?(row: Row, model: typeof Model): unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An observer object, or a class whose instances are observers. */
|
|
34
|
+
export type ObserverLike = Observer | (new () => Observer);
|
|
35
|
+
|
|
20
36
|
export declare class Model {
|
|
37
|
+
/** Lets traits extend models as TypeScript mixins (see traits.d.ts). */
|
|
38
|
+
constructor(...args: any[]);
|
|
21
39
|
static table: string | null;
|
|
22
40
|
static fillable: string[];
|
|
23
41
|
static timestamps: boolean;
|
|
42
|
+
/** Observers whose hooks run on create/update/delete. */
|
|
43
|
+
static observers?: ObserverLike[];
|
|
44
|
+
/** Adds observers at runtime. */
|
|
45
|
+
static observe<T extends typeof Model>(this: T, ...observers: ObserverLike[]): T;
|
|
24
46
|
|
|
25
47
|
static getTable(): string;
|
|
26
48
|
static getAdapter(): unknown;
|
|
27
49
|
|
|
28
|
-
static all(): Promise<
|
|
29
|
-
static query(): QueryBuilder<
|
|
30
|
-
static
|
|
31
|
-
static
|
|
32
|
-
static
|
|
33
|
-
static
|
|
50
|
+
static all(): Promise<Row[]>;
|
|
51
|
+
static query(): QueryBuilder<Row>;
|
|
52
|
+
static where(field: string, value: unknown): QueryBuilder<Row>;
|
|
53
|
+
static where(field: string, operator: WhereClause['operator'], value: unknown): QueryBuilder<Row>;
|
|
54
|
+
static whereNull(field: string): QueryBuilder<Row>;
|
|
55
|
+
static whereNotNull(field: string): QueryBuilder<Row>;
|
|
56
|
+
static whereIn(field: string, values: readonly unknown[]): QueryBuilder<Row>;
|
|
57
|
+
static whereNotIn(field: string, values: readonly unknown[]): QueryBuilder<Row>;
|
|
58
|
+
static latest(column?: string): QueryBuilder<Row>;
|
|
59
|
+
static oldest(column?: string): QueryBuilder<Row>;
|
|
60
|
+
static paginate(options?: PaginateOptions): Promise<PaginateResult<Row>>;
|
|
61
|
+
static find(id: number | string): Promise<Row | null>;
|
|
62
|
+
static findOrFail(id: number | string): Promise<Row>;
|
|
63
|
+
static create(data: Row): Promise<Row | null>;
|
|
64
|
+
static update(id: number | string, data: Row): Promise<Row | null>;
|
|
65
|
+
/** Updates every row matching `where` in one statement; returns how many changed. Observers don't run. */
|
|
66
|
+
static updateWhere(where: Record<string, unknown>, values: Record<string, unknown>): Promise<number>;
|
|
34
67
|
static delete(id: number | string): Promise<boolean>;
|
|
68
|
+
static destroy(...ids: Array<number | string | Array<number | string>>): Promise<number>;
|
|
69
|
+
|
|
70
|
+
static firstOrCreate(attributes: Row, values?: Row): Promise<Row | null>;
|
|
71
|
+
static updateOrCreate(attributes: Row, values?: Row): Promise<Row | null>;
|
|
72
|
+
|
|
73
|
+
static pluck(column: string): Promise<unknown[]>;
|
|
74
|
+
static pluck(column: string, keyColumn: string): Promise<Record<string, unknown>>;
|
|
75
|
+
static exists(): Promise<boolean>;
|
|
35
76
|
|
|
36
77
|
static belongsTo(
|
|
37
|
-
row:
|
|
78
|
+
row: Row | null,
|
|
38
79
|
foreignKey: string,
|
|
39
80
|
RelatedModel: typeof Model,
|
|
40
|
-
): Promise<
|
|
81
|
+
): Promise<Row | null>;
|
|
41
82
|
static hasMany(
|
|
42
83
|
row: { id: number },
|
|
43
84
|
RelatedModel: typeof Model,
|
|
44
85
|
foreignKey: string,
|
|
45
|
-
): Promise<
|
|
86
|
+
): Promise<Row[]>;
|
|
46
87
|
|
|
47
|
-
static first(): Promise<
|
|
88
|
+
static first(): Promise<Row | null>;
|
|
48
89
|
static count(): Promise<number>;
|
|
49
90
|
}
|
package/src/model/Model.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ConnectionManager } from './ConnectionManager.js'
|
|
2
2
|
import { QueryBuilder } from '../query/QueryBuilder.js'
|
|
3
|
+
import { addObservers, fire, hasHook, observersOf } from './observers.js'
|
|
3
4
|
|
|
4
5
|
export class Model {
|
|
5
6
|
static table = null
|
|
@@ -17,6 +18,16 @@ export class Model {
|
|
|
17
18
|
return ConnectionManager.getAdapter()
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Adds observers at runtime (see observers.js for the hooks):
|
|
23
|
+
* User.observe(UserObserver)
|
|
24
|
+
* Usually they're declared on the model instead: static observers = [...]
|
|
25
|
+
*/
|
|
26
|
+
static observe(...observers) {
|
|
27
|
+
addObservers(this, observers)
|
|
28
|
+
return this
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
static async all() {
|
|
21
32
|
const adapter = ConnectionManager.getAdapter()
|
|
22
33
|
return adapter.all(`SELECT * FROM ${this.getTable()}`)
|
|
@@ -30,6 +41,46 @@ export class Model {
|
|
|
30
41
|
return new QueryBuilder(this)
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Shortcut for query().where(...):
|
|
46
|
+
* await Post.where("status", "published").get()
|
|
47
|
+
* await Post.where("views", ">", 100).first()
|
|
48
|
+
* await Post.where("deleted_at", null).get() // IS NULL
|
|
49
|
+
*/
|
|
50
|
+
static where(...args) {
|
|
51
|
+
return this.query().where(...args)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** deleted_at IS NULL. Returns a QueryBuilder: await Post.whereNull("deleted_at").get() */
|
|
55
|
+
static whereNull(field) {
|
|
56
|
+
return this.query().whereNull(field)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** verified_at IS NOT NULL. Returns a QueryBuilder. */
|
|
60
|
+
static whereNotNull(field) {
|
|
61
|
+
return this.query().whereNotNull(field)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** id IN (1, 2, 3). Returns a QueryBuilder: await Post.whereIn("id", [1, 2]).get() */
|
|
65
|
+
static whereIn(field, values) {
|
|
66
|
+
return this.query().whereIn(field, values)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** status NOT IN ("banned", "spam"). Returns a QueryBuilder. */
|
|
70
|
+
static whereNotIn(field, values) {
|
|
71
|
+
return this.query().whereNotIn(field, values)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Newest first. Returns a QueryBuilder: await Post.latest().get() */
|
|
75
|
+
static latest(column = 'created_at') {
|
|
76
|
+
return this.query().latest(column)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Oldest first. Returns a QueryBuilder: await Post.oldest().first() */
|
|
80
|
+
static oldest(column = 'created_at') {
|
|
81
|
+
return this.query().oldest(column)
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
static async paginate({ page = 1, limit = 20, where = {} } = {}) {
|
|
34
85
|
const safePage = Math.max(1, Math.floor(page))
|
|
35
86
|
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)))
|
|
@@ -53,13 +104,27 @@ export class Model {
|
|
|
53
104
|
},
|
|
54
105
|
}
|
|
55
106
|
}
|
|
107
|
+
|
|
56
108
|
static async find(id) {
|
|
57
109
|
const adapter = ConnectionManager.getAdapter()
|
|
58
110
|
return adapter.get(`SELECT * FROM ${this.getTable()} WHERE id = ?`, [id])
|
|
59
111
|
}
|
|
60
112
|
|
|
61
|
-
|
|
113
|
+
/** Like find(), but throws (with err.status = 404) when the row is missing. */
|
|
114
|
+
static async findOrFail(id) {
|
|
115
|
+
const row = await this.find(id)
|
|
116
|
+
if (!row) {
|
|
117
|
+
const error = new Error(`${this.name} with id ${id} not found.`)
|
|
118
|
+
error.status = 404
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
return row
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static async create(input) {
|
|
62
125
|
const adapter = ConnectionManager.getAdapter()
|
|
126
|
+
const observers = observersOf(this)
|
|
127
|
+
const data = await fire(this, observers, 'creating', { ...input })
|
|
63
128
|
const fields = this.fillable.filter((f) => f in data)
|
|
64
129
|
|
|
65
130
|
if (fields.length === 0) {
|
|
@@ -83,11 +148,17 @@ export class Model {
|
|
|
83
148
|
columnValues,
|
|
84
149
|
)
|
|
85
150
|
|
|
86
|
-
|
|
151
|
+
const row = await this.find(result.lastInsertRowid)
|
|
152
|
+
await fire(this, observers, 'created', row)
|
|
153
|
+
return row
|
|
87
154
|
}
|
|
88
155
|
|
|
89
|
-
static async update(id,
|
|
156
|
+
static async update(id, input) {
|
|
90
157
|
const adapter = ConnectionManager.getAdapter()
|
|
158
|
+
const observers = observersOf(this)
|
|
159
|
+
// The row as it was, for updating/updated hooks; only read when needed.
|
|
160
|
+
const before = hasHook(observers, ['updating', 'updated']) ? await this.find(id) : null
|
|
161
|
+
const data = await fire(this, observers, 'updating', { ...input }, before)
|
|
91
162
|
const fields = this.fillable.filter((f) => f in data)
|
|
92
163
|
|
|
93
164
|
if (fields.length === 0) {
|
|
@@ -110,15 +181,120 @@ export class Model {
|
|
|
110
181
|
[...columnValues, id],
|
|
111
182
|
)
|
|
112
183
|
|
|
113
|
-
|
|
184
|
+
const row = await this.find(id)
|
|
185
|
+
await fire(this, observers, 'updated', row, before)
|
|
186
|
+
return row
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Updates every row matching `where` in one statement and returns how
|
|
190
|
+
// many changed. Bulk: observers don't run. Useful for check-and-set:
|
|
191
|
+
// const claimed = (await Job.updateWhere({ id, status: 'pending' }, { status: 'processing' })) === 1
|
|
192
|
+
static async updateWhere(where, values) {
|
|
193
|
+
const data = { ...values }
|
|
194
|
+
if (this.timestamps) data.updated_at = new Date().toISOString()
|
|
195
|
+
return ConnectionManager.getAdapter().updateWhere(this.getTable(), data, where)
|
|
114
196
|
}
|
|
115
197
|
|
|
116
198
|
static async delete(id) {
|
|
117
199
|
const adapter = ConnectionManager.getAdapter()
|
|
200
|
+
const observers = observersOf(this)
|
|
201
|
+
// deleting/deleted hooks get the row; a missing row runs no hooks.
|
|
202
|
+
const row = hasHook(observers, ['deleting', 'deleted']) ? await this.find(id) : null
|
|
203
|
+
if (row) await fire(this, observers, 'deleting', row)
|
|
118
204
|
await adapter.run(`DELETE FROM ${this.getTable()} WHERE id = ?`, [id])
|
|
205
|
+
if (row) await fire(this, observers, 'deleted', row)
|
|
119
206
|
return true
|
|
120
207
|
}
|
|
121
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Delete one or more rows by id and return how many actually existed:
|
|
211
|
+
* await Post.destroy(1)
|
|
212
|
+
* await Post.destroy(1, 2, 3)
|
|
213
|
+
* await Post.destroy([1, 2, 3])
|
|
214
|
+
*/
|
|
215
|
+
static async destroy(...ids) {
|
|
216
|
+
const flat = ids.flat().filter((id) => id !== undefined && id !== null)
|
|
217
|
+
if (flat.length === 0) {
|
|
218
|
+
throw new Error(`${this.name}.destroy() needs at least one id.`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let deleted = 0
|
|
222
|
+
for (const id of flat) {
|
|
223
|
+
if (await this.find(id)) {
|
|
224
|
+
await this.delete(id)
|
|
225
|
+
deleted++
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return deleted
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Find the first row matching `attributes`, or create it with
|
|
233
|
+
* `attributes` + `values`:
|
|
234
|
+
* await User.firstOrCreate({ email: "a@b.com" }, { name: "Ann" })
|
|
235
|
+
*/
|
|
236
|
+
static async firstOrCreate(attributes, values = {}) {
|
|
237
|
+
const keys = Object.keys(attributes ?? {})
|
|
238
|
+
if (keys.length === 0) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${this.name}.firstOrCreate() needs at least one attribute to match on.`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let query = this.query()
|
|
245
|
+
for (const key of keys) {
|
|
246
|
+
query = query.where(key, attributes[key])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const existing = await query.first()
|
|
250
|
+
if (existing) return existing
|
|
251
|
+
|
|
252
|
+
return this.create({ ...attributes, ...values })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Find the first row matching `attributes` and update it with `values`,
|
|
257
|
+
* or create it with `attributes` + `values`:
|
|
258
|
+
* await Setting.updateOrCreate({ key: "theme" }, { value: "dark" })
|
|
259
|
+
*/
|
|
260
|
+
static async updateOrCreate(attributes, values = {}) {
|
|
261
|
+
const keys = Object.keys(attributes ?? {})
|
|
262
|
+
if (keys.length === 0) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${this.name}.updateOrCreate() needs at least one attribute to match on.`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let query = this.query()
|
|
269
|
+
for (const key of keys) {
|
|
270
|
+
query = query.where(key, attributes[key])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const existing = await query.first()
|
|
274
|
+
if (!existing) {
|
|
275
|
+
return this.create({ ...attributes, ...values })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const hasFillableChange = this.fillable.some((f) => f in values)
|
|
279
|
+
if (!hasFillableChange) return existing
|
|
280
|
+
|
|
281
|
+
return this.update(existing.id, values)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* One column as a plain array (or an object when a key column is given):
|
|
286
|
+
* await User.pluck("email") -> ["a@b.com", "c@d.com"]
|
|
287
|
+
* await User.pluck("email", "id") -> { 1: "a@b.com", 2: "c@d.com" }
|
|
288
|
+
*/
|
|
289
|
+
static async pluck(column, keyColumn) {
|
|
290
|
+
return this.query().pluck(column, keyColumn)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** True when the table has at least one row. */
|
|
294
|
+
static async exists() {
|
|
295
|
+
return this.query().exists()
|
|
296
|
+
}
|
|
297
|
+
|
|
122
298
|
static async belongsTo(row, foreignKey, RelatedModel) {
|
|
123
299
|
if (!row || row[foreignKey] === undefined || row[foreignKey] === null) {
|
|
124
300
|
return null
|
|
@@ -141,4 +317,4 @@ export class Model {
|
|
|
141
317
|
static async count() {
|
|
142
318
|
return this.query().count()
|
|
143
319
|
}
|
|
144
|
-
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Model observers: code that runs when rows are created, updated or deleted.
|
|
2
|
+
//
|
|
3
|
+
// class UserObserver {
|
|
4
|
+
// async created(user) { await dispatch('SendWelcomeEmail', { userId: user.id }) }
|
|
5
|
+
// }
|
|
6
|
+
// class User extends Model {
|
|
7
|
+
// static observers = [UserObserver]
|
|
8
|
+
// }
|
|
9
|
+
//
|
|
10
|
+
// Hooks (all optional, may be async):
|
|
11
|
+
// creating(data) before an insert; return an object to replace
|
|
12
|
+
// the data, or throw to cancel
|
|
13
|
+
// created(row) after the insert
|
|
14
|
+
// updating(data, before) before an update (before = current row);
|
|
15
|
+
// return an object to replace the data, or throw
|
|
16
|
+
// updated(row, before) after the update
|
|
17
|
+
// deleting(row) before a delete; throw to cancel
|
|
18
|
+
// deleted(row) after the delete
|
|
19
|
+
//
|
|
20
|
+
// Every hook also gets the model class as its last argument.
|
|
21
|
+
//
|
|
22
|
+
// Observers come from `static observers` on the model and on every class it
|
|
23
|
+
// extends (so traits can bring their own), plus Model.observe(...) calls.
|
|
24
|
+
// An observer is a class (created once and reused) or a plain object.
|
|
25
|
+
|
|
26
|
+
export const OBSERVER_EVENTS = ['creating', 'created', 'updating', 'updated', 'deleting', 'deleted']
|
|
27
|
+
|
|
28
|
+
// Model class -> observers added at runtime with Model.observe().
|
|
29
|
+
const registered = new WeakMap()
|
|
30
|
+
// Observer class -> its single instance.
|
|
31
|
+
const instances = new WeakMap()
|
|
32
|
+
|
|
33
|
+
function instantiate(observer) {
|
|
34
|
+
if (typeof observer === 'function') {
|
|
35
|
+
if (!instances.has(observer)) instances.set(observer, new observer())
|
|
36
|
+
return instances.get(observer)
|
|
37
|
+
}
|
|
38
|
+
if (observer && typeof observer === 'object') return observer
|
|
39
|
+
throw new TypeError('An observer must be a class or an object with hook methods.')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addObservers(Model, observers) {
|
|
43
|
+
for (const observer of observers) instantiate(observer) // validate early
|
|
44
|
+
registered.set(Model, [...(registered.get(Model) ?? []), ...observers])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Every observer for a model: its parents' first, then its own.
|
|
48
|
+
export function observersOf(Model) {
|
|
49
|
+
const chain = []
|
|
50
|
+
for (let current = Model; current && current !== Function.prototype; current = Object.getPrototypeOf(current)) {
|
|
51
|
+
chain.unshift(current)
|
|
52
|
+
}
|
|
53
|
+
const list = []
|
|
54
|
+
for (const cls of chain) {
|
|
55
|
+
if (Object.hasOwn(cls, 'observers')) {
|
|
56
|
+
if (!Array.isArray(cls.observers)) {
|
|
57
|
+
throw new TypeError(`${cls.name}.observers must be an array of observers.`)
|
|
58
|
+
}
|
|
59
|
+
list.push(...cls.observers)
|
|
60
|
+
}
|
|
61
|
+
list.push(...(registered.get(cls) ?? []))
|
|
62
|
+
}
|
|
63
|
+
return list.map(instantiate)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Runs one event's hooks in order. For "creating"/"updating", a hook may
|
|
67
|
+
// return a new data object, which the next hook (and the write) receives.
|
|
68
|
+
export async function fire(Model, observers, event, data, ...rest) {
|
|
69
|
+
let current = data
|
|
70
|
+
for (const observer of observers) {
|
|
71
|
+
const hook = observer[event]
|
|
72
|
+
if (typeof hook !== 'function') continue
|
|
73
|
+
const result = await hook.call(observer, current, ...rest, Model)
|
|
74
|
+
if ((event === 'creating' || event === 'updating') && result && typeof result === 'object') {
|
|
75
|
+
current = result
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return current
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasHook(observers, events) {
|
|
82
|
+
return observers.some((observer) => events.some((event) => typeof observer[event] === 'function'))
|
|
83
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Model } from './Model.js';
|
|
2
|
+
|
|
3
|
+
type Row = Record<string, unknown>;
|
|
4
|
+
type ModelClass = typeof Model;
|
|
5
|
+
|
|
6
|
+
/** A trait: takes a model class, returns a class extending it with more. */
|
|
7
|
+
export type Trait<Added = {}> = <T extends ModelClass>(Base: T) => T & Added;
|
|
8
|
+
|
|
9
|
+
// Any trait function: the class it returns is what use() adds.
|
|
10
|
+
type AnyTrait = (Base: any) => any;
|
|
11
|
+
|
|
12
|
+
/** Applies traits to a base model class, left to right. */
|
|
13
|
+
export function use<T extends ModelClass>(Base: T): T;
|
|
14
|
+
export function use<T extends ModelClass, A extends AnyTrait>(Base: T, a: A): T & ReturnType<A>;
|
|
15
|
+
export function use<T extends ModelClass, A extends AnyTrait, B extends AnyTrait>(Base: T, a: A, b: B): T & ReturnType<A> & ReturnType<B>;
|
|
16
|
+
export function use<T extends ModelClass, A extends AnyTrait, B extends AnyTrait, C extends AnyTrait>(
|
|
17
|
+
Base: T, a: A, b: B, c: C,
|
|
18
|
+
): T & ReturnType<A> & ReturnType<B> & ReturnType<C>;
|
|
19
|
+
export function use<T extends ModelClass>(Base: T, ...traits: AnyTrait[]): T;
|
|
20
|
+
|
|
21
|
+
export interface SearchableStatics {
|
|
22
|
+
/** Rows where any of the trait's fields contains `term` (case-insensitive). */
|
|
23
|
+
search(term: string, options?: { limit?: number }): Promise<Row[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Adds Model.search(term) over the given columns. */
|
|
27
|
+
export function Searchable(options: { fields: string[] }): Trait<SearchableStatics>;
|
|
28
|
+
|
|
29
|
+
// Only what is exported above is public (helper types stay private).
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Traits: reusable model behaviour, as JavaScript mixins. A trait is a
|
|
2
|
+
// function that takes a model class and returns a class extending it:
|
|
3
|
+
//
|
|
4
|
+
// export const SoftLabels = (Base) => class extends Base {
|
|
5
|
+
// static async labelled(label) { return this.where('label', label).get() }
|
|
6
|
+
// }
|
|
7
|
+
//
|
|
8
|
+
// class Post extends use(Model, Searchable({ fields: ['title', 'body'] }), SoftLabels) {
|
|
9
|
+
// static table = 'posts'
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// A trait can bring observer hooks too: `static observers = [...]` on the
|
|
13
|
+
// class it returns runs alongside the model's own observers.
|
|
14
|
+
|
|
15
|
+
export function use(Base, ...traits) {
|
|
16
|
+
return traits.reduce((Current, trait, i) => {
|
|
17
|
+
if (typeof trait !== 'function') {
|
|
18
|
+
throw new TypeError(`use(): trait ${i + 1} is not a function. Did you forget to call a trait factory, e.g. Searchable({ fields })?`)
|
|
19
|
+
}
|
|
20
|
+
const Next = trait(Current)
|
|
21
|
+
if (typeof Next !== 'function' || !(Next === Current || Object.prototype.isPrototypeOf.call(Current, Next))) {
|
|
22
|
+
throw new TypeError(`use(): trait ${i + 1} must return a class that extends the one it was given.`)
|
|
23
|
+
}
|
|
24
|
+
return Next
|
|
25
|
+
}, Base)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
29
|
+
|
|
30
|
+
// Adds Model.search(term) over the given columns:
|
|
31
|
+
// class Post extends use(Model, Searchable({ fields: ['title', 'body'] })) {}
|
|
32
|
+
// await Post.search('tylix') // rows whose title or body contains it
|
|
33
|
+
// await Post.search('tylix', { limit: 10 })
|
|
34
|
+
// Matching is case-insensitive (LIKE on every driver). It uses plain where()
|
|
35
|
+
// queries, one per field, so it works on SQLite, MySQL, PostgreSQL and
|
|
36
|
+
// MongoDB alike; for large tables use a full-text index instead.
|
|
37
|
+
export function Searchable(options = {}) {
|
|
38
|
+
if (typeof options === 'function') {
|
|
39
|
+
throw new TypeError('Searchable is a trait factory: call it with options, e.g. use(Model, Searchable({ fields: ["title"] })).')
|
|
40
|
+
}
|
|
41
|
+
const { fields } = options
|
|
42
|
+
if (!Array.isArray(fields) || fields.length === 0 || !fields.every((f) => IDENTIFIER.test(f))) {
|
|
43
|
+
throw new TypeError('Searchable({ fields }) needs a list of column names, e.g. ["title", "body"].')
|
|
44
|
+
}
|
|
45
|
+
return (Base) =>
|
|
46
|
+
class extends Base {
|
|
47
|
+
static async search(term, { limit = 50 } = {}) {
|
|
48
|
+
const text = String(term ?? '').trim()
|
|
49
|
+
if (text === '') return []
|
|
50
|
+
const pattern = `%${text}%`
|
|
51
|
+
const found = new Map()
|
|
52
|
+
for (const field of fields) {
|
|
53
|
+
for (const row of await this.where(field, 'LIKE', pattern).get()) {
|
|
54
|
+
if (!found.has(row.id)) found.set(row.id, row)
|
|
55
|
+
if (found.size >= limit) return [...found.values()]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...found.values()]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -5,7 +5,13 @@ export declare class QueryBuilder<TRow> {
|
|
|
5
5
|
|
|
6
6
|
where(field: string, value: unknown): this;
|
|
7
7
|
where(field: string, operator: WhereClause['operator'], value: unknown): this;
|
|
8
|
+
whereNull(field: string): this;
|
|
9
|
+
whereNotNull(field: string): this;
|
|
10
|
+
whereIn(field: string, values: readonly unknown[]): this;
|
|
11
|
+
whereNotIn(field: string, values: readonly unknown[]): this;
|
|
8
12
|
orderBy(field: string, direction?: 'ASC' | 'DESC'): this;
|
|
13
|
+
latest(column?: string): this;
|
|
14
|
+
oldest(column?: string): this;
|
|
9
15
|
limit(value: number): this;
|
|
10
16
|
offset(value: number): this;
|
|
11
17
|
toDescriptor(): QueryDescriptor;
|
|
@@ -13,4 +19,7 @@ export declare class QueryBuilder<TRow> {
|
|
|
13
19
|
get(): Promise<TRow[]>;
|
|
14
20
|
first(): Promise<TRow | null>;
|
|
15
21
|
count(): Promise<number>;
|
|
22
|
+
pluck(column: string): Promise<unknown[]>;
|
|
23
|
+
pluck(column: string, keyColumn: string): Promise<Record<string, unknown>>;
|
|
24
|
+
exists(): Promise<boolean>;
|
|
16
25
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fluent query builder returned by Model.query(). Accumulates a
|
|
3
3
|
* structured descriptor and only hits the database when a terminal
|
|
4
|
-
* method (get/first/count) is called.
|
|
4
|
+
* method (get/first/count/pluck/exists) is called.
|
|
5
5
|
*/
|
|
6
6
|
export class QueryBuilder {
|
|
7
7
|
constructor(ModelClass) {
|
|
@@ -12,6 +12,10 @@ export class QueryBuilder {
|
|
|
12
12
|
this.offsetValue = undefined;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// where("status", "active") status = 'active'
|
|
16
|
+
// where("age", ">", 18) age > 18
|
|
17
|
+
// where("deleted_at", null) deleted_at IS NULL
|
|
18
|
+
// where("id", "IN", [1, 2, 3]) same as whereIn("id", [1, 2, 3])
|
|
15
19
|
where(field, operatorOrValue, maybeValue) {
|
|
16
20
|
const hasExplicitOperator = maybeValue !== undefined;
|
|
17
21
|
const operator = hasExplicitOperator ? operatorOrValue : "=";
|
|
@@ -21,6 +25,36 @@ export class QueryBuilder {
|
|
|
21
25
|
return this;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
// deleted_at IS NULL
|
|
29
|
+
whereNull(field) {
|
|
30
|
+
this.wheres.push({ field, operator: "IS NULL", value: null });
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// deleted_at IS NOT NULL
|
|
35
|
+
whereNotNull(field) {
|
|
36
|
+
this.wheres.push({ field, operator: "IS NOT NULL", value: null });
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// id IN (1, 2, 3). An empty array matches nothing.
|
|
41
|
+
whereIn(field, values) {
|
|
42
|
+
if (!Array.isArray(values)) {
|
|
43
|
+
throw new Error("whereIn() needs an array of values.");
|
|
44
|
+
}
|
|
45
|
+
this.wheres.push({ field, operator: "IN", value: [...values] });
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// id NOT IN (1, 2, 3). An empty array excludes nothing.
|
|
50
|
+
whereNotIn(field, values) {
|
|
51
|
+
if (!Array.isArray(values)) {
|
|
52
|
+
throw new Error("whereNotIn() needs an array of values.");
|
|
53
|
+
}
|
|
54
|
+
this.wheres.push({ field, operator: "NOT IN", value: [...values] });
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
orderBy(field, direction = "ASC") {
|
|
25
59
|
const normalized = direction.toUpperCase();
|
|
26
60
|
if (normalized !== "ASC" && normalized !== "DESC") {
|
|
@@ -30,6 +64,16 @@ export class QueryBuilder {
|
|
|
30
64
|
return this;
|
|
31
65
|
}
|
|
32
66
|
|
|
67
|
+
// Newest first (by created_at unless another column is given).
|
|
68
|
+
latest(column = "created_at") {
|
|
69
|
+
return this.orderBy(column, "DESC");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Oldest first (by created_at unless another column is given).
|
|
73
|
+
oldest(column = "created_at") {
|
|
74
|
+
return this.orderBy(column, "ASC");
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
limit(value) {
|
|
34
78
|
this.limitValue = value;
|
|
35
79
|
return this;
|
|
@@ -63,4 +107,19 @@ export class QueryBuilder {
|
|
|
63
107
|
const rows = await this.get();
|
|
64
108
|
return rows.length;
|
|
65
109
|
}
|
|
66
|
-
|
|
110
|
+
|
|
111
|
+
// pluck("email") -> ["a@b.com", ...]
|
|
112
|
+
// pluck("email", "id") -> { 1: "a@b.com", ... }
|
|
113
|
+
async pluck(column, keyColumn) {
|
|
114
|
+
const rows = await this.get();
|
|
115
|
+
if (keyColumn === undefined) {
|
|
116
|
+
return rows.map((row) => row[column]);
|
|
117
|
+
}
|
|
118
|
+
return Object.fromEntries(rows.map((row) => [row[keyColumn], row[column]]));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async exists() {
|
|
122
|
+
const row = await this.first();
|
|
123
|
+
return row !== null;
|
|
124
|
+
}
|
|
125
|
+
}
|