@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.
@@ -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<Record<string, unknown>[]>;
29
- static query(): QueryBuilder<Record<string, unknown>>;
30
- static paginate(options?: PaginateOptions): Promise<PaginateResult<Record<string, unknown>>>;
31
- static find(id: number | string): Promise<Record<string, unknown> | null>;
32
- static create(data: Record<string, unknown>): Promise<Record<string, unknown> | null>;
33
- static update(id: number | string, data: Record<string, unknown>): Promise<Record<string, unknown> | null>;
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: Record<string, unknown> | null,
78
+ row: Row | null,
38
79
  foreignKey: string,
39
80
  RelatedModel: typeof Model,
40
- ): Promise<Record<string, unknown> | null>;
81
+ ): Promise<Row | null>;
41
82
  static hasMany(
42
83
  row: { id: number },
43
84
  RelatedModel: typeof Model,
44
85
  foreignKey: string,
45
- ): Promise<Record<string, unknown>[]>;
86
+ ): Promise<Row[]>;
46
87
 
47
- static first(): Promise<Record<string, unknown> | null>;
88
+ static first(): Promise<Row | null>;
48
89
  static count(): Promise<number>;
49
90
  }
@@ -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
- static async create(data) {
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
- return this.find(result.lastInsertRowid)
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, data) {
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
- return this.find(id)
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
+ }