@tylix/orm 0.5.1 → 0.6.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/package.json +2 -2
- package/src/adapters/DatabaseAdapter.d.ts +3 -0
- package/src/adapters/DatabaseAdapter.js +13 -2
- package/src/adapters/MongoAdapter.js +84 -16
- package/src/model/Model.d.ts +30 -10
- package/src/model/Model.js +142 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tylix/orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "node --test src/**/*.test.js"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tylix/shared": "^0.
|
|
19
|
+
"@tylix/shared": "^0.6.0",
|
|
20
20
|
"mongodb": "^6.5.0",
|
|
21
21
|
"mysql2": "^3.23.1",
|
|
22
22
|
"pg": "^8.11.0"
|
|
@@ -5,6 +5,9 @@ export interface RunResult {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export declare class DatabaseAdapter {
|
|
8
|
+
/** SQL keyword used for LIKE conditions: "LIKE" by default, "ILIKE" on Postgres. */
|
|
9
|
+
readonly likeOperator: 'LIKE' | 'ILIKE';
|
|
10
|
+
|
|
8
11
|
connect(): Promise<this>;
|
|
9
12
|
close(): Promise<void>;
|
|
10
13
|
run(sql: string, params?: unknown[]): Promise<RunResult>;
|
|
@@ -44,6 +44,15 @@ export class DatabaseAdapter {
|
|
|
44
44
|
throw new Error('listTables() not implemented')
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The SQL keyword used for LIKE conditions. Case-insensitive on MySQL and
|
|
49
|
+
* SQLite already; PostgresAdapter overrides this with "ILIKE" so all SQL
|
|
50
|
+
* drivers match the same rows.
|
|
51
|
+
*/
|
|
52
|
+
get likeOperator() {
|
|
53
|
+
return 'LIKE'
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Runs a structured query descriptor ({ wheres, orders, limitValue,
|
|
49
58
|
* offsetValue }) against `table` and returns matching rows.
|
|
@@ -55,7 +64,9 @@ export class DatabaseAdapter {
|
|
|
55
64
|
* overrides this entirely since it has no SQL layer to delegate to.
|
|
56
65
|
*/
|
|
57
66
|
async query(table, descriptor = {}) {
|
|
58
|
-
const { sql, params } = buildSelectSql(table, descriptor
|
|
67
|
+
const { sql, params } = buildSelectSql(table, descriptor, {
|
|
68
|
+
likeOperator: this.likeOperator,
|
|
69
|
+
})
|
|
59
70
|
return this.all(sql, params)
|
|
60
71
|
}
|
|
61
|
-
}
|
|
72
|
+
}
|
|
@@ -2,6 +2,50 @@ import { MongoClient } from 'mongodb'
|
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
4
|
|
|
5
|
+
// Same rule the SQL builder uses: plain identifiers only, with optional
|
|
6
|
+
// dots for nested fields. Rejects "$where", "$or", spaces, quotes, etc.
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/
|
|
8
|
+
|
|
9
|
+
// Same cap as the SQL builder, so a query that works on one driver
|
|
10
|
+
// doesn't fail on another.
|
|
11
|
+
const MAX_LIST_VALUES = 1000
|
|
12
|
+
|
|
13
|
+
function assertField(name) {
|
|
14
|
+
if (typeof name !== 'string' || !IDENTIFIER.test(name)) {
|
|
15
|
+
throw new Error(`Invalid column name "${String(name)}".`)
|
|
16
|
+
}
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Validates the array given to IN / NOT IN, with the same rules as SQL.
|
|
21
|
+
function assertList(values, operator) {
|
|
22
|
+
if (!Array.isArray(values)) {
|
|
23
|
+
throw new Error(`Operator "${operator}" needs an array of values.`)
|
|
24
|
+
}
|
|
25
|
+
if (values.length > MAX_LIST_VALUES) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Operator "${operator}" accepts at most ${MAX_LIST_VALUES} values, got ${values.length}.`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
if (values.some((v) => v === null || v === undefined)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`"${operator}" lists cannot contain null or undefined. Use whereNull() / whereNotNull() for null checks.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return values
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// SQL LIKE -> anchored, case-insensitive regex ("%" = any run, "_" = any
|
|
39
|
+
// single character). Case-insensitive to match MySQL and SQLite.
|
|
40
|
+
function likeToRegex(pattern) {
|
|
41
|
+
if (typeof pattern !== 'string') {
|
|
42
|
+
throw new Error('LIKE needs a string pattern.')
|
|
43
|
+
}
|
|
44
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
const source = escaped.replace(/%/g, '.*').replace(/_/g, '.')
|
|
46
|
+
return new RegExp(`^${source}$`, 'is')
|
|
47
|
+
}
|
|
48
|
+
|
|
5
49
|
export function parseQuery(sql) {
|
|
6
50
|
const trimmed = sql.trim().replace(/\s+/g, ' ')
|
|
7
51
|
|
|
@@ -277,29 +321,52 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
277
321
|
async query(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
|
|
278
322
|
this.ensureConnected()
|
|
279
323
|
|
|
280
|
-
|
|
324
|
+
let filter = {}
|
|
281
325
|
const sort = {}
|
|
282
326
|
const description = () => {
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
const show = (value) =>
|
|
328
|
+
JSON.stringify(value, (key, v) => (v instanceof RegExp ? String(v) : v))
|
|
329
|
+
let text = `db.${table}.find(${show(filter)})`
|
|
330
|
+
if (Object.keys(sort).length) text += `.sort(${show(sort)})`
|
|
285
331
|
if (offsetValue !== undefined) text += `.skip(${offsetValue})`
|
|
286
332
|
if (limitValue !== undefined) text += `.limit(${limitValue})`
|
|
287
333
|
return text
|
|
288
334
|
}
|
|
289
335
|
|
|
290
336
|
return this.guard({ get sql() { return description() } }, async () => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
337
|
+
// Every condition goes into $and, so several conditions on the same
|
|
338
|
+
// field (age > 18 AND age < 65) all apply, like SQL. Values are
|
|
339
|
+
// always wrapped in an operator, so an object from the client can
|
|
340
|
+
// never be interpreted as a query operator itself.
|
|
341
|
+
const conditions = wheres.map((w) => {
|
|
342
|
+
const field = assertField(w.field)
|
|
343
|
+
const operator = String(w.operator).trim().replace(/\s+/g, ' ').toUpperCase()
|
|
344
|
+
|
|
345
|
+
if (operator === '=') return { [field]: { $eq: w.value } }
|
|
346
|
+
if (operator === '!=' || operator === '<>')
|
|
347
|
+
return { [field]: { $ne: w.value } }
|
|
348
|
+
if (operator === '>') return { [field]: { $gt: w.value } }
|
|
349
|
+
if (operator === '<') return { [field]: { $lt: w.value } }
|
|
350
|
+
if (operator === '>=') return { [field]: { $gte: w.value } }
|
|
351
|
+
if (operator === '<=') return { [field]: { $lte: w.value } }
|
|
352
|
+
if (operator === 'LIKE')
|
|
353
|
+
return { [field]: { $regex: likeToRegex(w.value) } }
|
|
354
|
+
|
|
355
|
+
// Mongo treats a missing field as null, so IS NULL also matches
|
|
356
|
+
// documents that don't have the field at all.
|
|
357
|
+
if (operator === 'IS NULL') return { [field]: { $eq: null } }
|
|
358
|
+
if (operator === 'IS NOT NULL') return { [field]: { $ne: null } }
|
|
359
|
+
if (operator === 'IN')
|
|
360
|
+
return { [field]: { $in: assertList(w.value, operator) } }
|
|
361
|
+
if (operator === 'NOT IN')
|
|
362
|
+
return { [field]: { $nin: assertList(w.value, operator) } }
|
|
363
|
+
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
366
|
+
)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
if (conditions.length > 0) filter = { $and: conditions }
|
|
303
370
|
|
|
304
371
|
let cursor = this.db
|
|
305
372
|
.collection(table)
|
|
@@ -307,7 +374,8 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
307
374
|
|
|
308
375
|
if (orders.length > 0) {
|
|
309
376
|
orders.forEach((o) => {
|
|
310
|
-
sort[o.field] =
|
|
377
|
+
sort[assertField(o.field)] =
|
|
378
|
+
o.direction.toUpperCase() === 'DESC' ? -1 : 1
|
|
311
379
|
})
|
|
312
380
|
cursor = cursor.sort(sort)
|
|
313
381
|
}
|
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,6 +18,8 @@ interface PaginateResult<T> {
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
type Row = Record<string, unknown>;
|
|
22
|
+
|
|
20
23
|
export declare class Model {
|
|
21
24
|
static table: string | null;
|
|
22
25
|
static fillable: string[];
|
|
@@ -25,25 +28,42 @@ export declare class Model {
|
|
|
25
28
|
static getTable(): string;
|
|
26
29
|
static getAdapter(): unknown;
|
|
27
30
|
|
|
28
|
-
static all(): Promise<
|
|
29
|
-
static query(): QueryBuilder<
|
|
30
|
-
static
|
|
31
|
-
static
|
|
32
|
-
static
|
|
33
|
-
static
|
|
31
|
+
static all(): Promise<Row[]>;
|
|
32
|
+
static query(): QueryBuilder<Row>;
|
|
33
|
+
static where(field: string, value: unknown): QueryBuilder<Row>;
|
|
34
|
+
static where(field: string, operator: WhereClause['operator'], value: unknown): QueryBuilder<Row>;
|
|
35
|
+
static whereNull(field: string): QueryBuilder<Row>;
|
|
36
|
+
static whereNotNull(field: string): QueryBuilder<Row>;
|
|
37
|
+
static whereIn(field: string, values: readonly unknown[]): QueryBuilder<Row>;
|
|
38
|
+
static whereNotIn(field: string, values: readonly unknown[]): QueryBuilder<Row>;
|
|
39
|
+
static latest(column?: string): QueryBuilder<Row>;
|
|
40
|
+
static oldest(column?: string): QueryBuilder<Row>;
|
|
41
|
+
static paginate(options?: PaginateOptions): Promise<PaginateResult<Row>>;
|
|
42
|
+
static find(id: number | string): Promise<Row | null>;
|
|
43
|
+
static findOrFail(id: number | string): Promise<Row>;
|
|
44
|
+
static create(data: Row): Promise<Row | null>;
|
|
45
|
+
static update(id: number | string, data: Row): Promise<Row | null>;
|
|
34
46
|
static delete(id: number | string): Promise<boolean>;
|
|
47
|
+
static destroy(...ids: Array<number | string | Array<number | string>>): Promise<number>;
|
|
48
|
+
|
|
49
|
+
static firstOrCreate(attributes: Row, values?: Row): Promise<Row | null>;
|
|
50
|
+
static updateOrCreate(attributes: Row, values?: Row): Promise<Row | null>;
|
|
51
|
+
|
|
52
|
+
static pluck(column: string): Promise<unknown[]>;
|
|
53
|
+
static pluck(column: string, keyColumn: string): Promise<Record<string, unknown>>;
|
|
54
|
+
static exists(): Promise<boolean>;
|
|
35
55
|
|
|
36
56
|
static belongsTo(
|
|
37
|
-
row:
|
|
57
|
+
row: Row | null,
|
|
38
58
|
foreignKey: string,
|
|
39
59
|
RelatedModel: typeof Model,
|
|
40
|
-
): Promise<
|
|
60
|
+
): Promise<Row | null>;
|
|
41
61
|
static hasMany(
|
|
42
62
|
row: { id: number },
|
|
43
63
|
RelatedModel: typeof Model,
|
|
44
64
|
foreignKey: string,
|
|
45
|
-
): Promise<
|
|
65
|
+
): Promise<Row[]>;
|
|
46
66
|
|
|
47
|
-
static first(): Promise<
|
|
67
|
+
static first(): Promise<Row | null>;
|
|
48
68
|
static count(): Promise<number>;
|
|
49
69
|
}
|
package/src/model/Model.js
CHANGED
|
@@ -30,6 +30,46 @@ export class Model {
|
|
|
30
30
|
return new QueryBuilder(this)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Shortcut for query().where(...):
|
|
35
|
+
* await Post.where("status", "published").get()
|
|
36
|
+
* await Post.where("views", ">", 100).first()
|
|
37
|
+
* await Post.where("deleted_at", null).get() // IS NULL
|
|
38
|
+
*/
|
|
39
|
+
static where(...args) {
|
|
40
|
+
return this.query().where(...args)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** deleted_at IS NULL. Returns a QueryBuilder: await Post.whereNull("deleted_at").get() */
|
|
44
|
+
static whereNull(field) {
|
|
45
|
+
return this.query().whereNull(field)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** verified_at IS NOT NULL. Returns a QueryBuilder. */
|
|
49
|
+
static whereNotNull(field) {
|
|
50
|
+
return this.query().whereNotNull(field)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** id IN (1, 2, 3). Returns a QueryBuilder: await Post.whereIn("id", [1, 2]).get() */
|
|
54
|
+
static whereIn(field, values) {
|
|
55
|
+
return this.query().whereIn(field, values)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** status NOT IN ("banned", "spam"). Returns a QueryBuilder. */
|
|
59
|
+
static whereNotIn(field, values) {
|
|
60
|
+
return this.query().whereNotIn(field, values)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Newest first. Returns a QueryBuilder: await Post.latest().get() */
|
|
64
|
+
static latest(column = 'created_at') {
|
|
65
|
+
return this.query().latest(column)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Oldest first. Returns a QueryBuilder: await Post.oldest().first() */
|
|
69
|
+
static oldest(column = 'created_at') {
|
|
70
|
+
return this.query().oldest(column)
|
|
71
|
+
}
|
|
72
|
+
|
|
33
73
|
static async paginate({ page = 1, limit = 20, where = {} } = {}) {
|
|
34
74
|
const safePage = Math.max(1, Math.floor(page))
|
|
35
75
|
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)))
|
|
@@ -53,11 +93,23 @@ export class Model {
|
|
|
53
93
|
},
|
|
54
94
|
}
|
|
55
95
|
}
|
|
96
|
+
|
|
56
97
|
static async find(id) {
|
|
57
98
|
const adapter = ConnectionManager.getAdapter()
|
|
58
99
|
return adapter.get(`SELECT * FROM ${this.getTable()} WHERE id = ?`, [id])
|
|
59
100
|
}
|
|
60
101
|
|
|
102
|
+
/** Like find(), but throws (with err.status = 404) when the row is missing. */
|
|
103
|
+
static async findOrFail(id) {
|
|
104
|
+
const row = await this.find(id)
|
|
105
|
+
if (!row) {
|
|
106
|
+
const error = new Error(`${this.name} with id ${id} not found.`)
|
|
107
|
+
error.status = 404
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
return row
|
|
111
|
+
}
|
|
112
|
+
|
|
61
113
|
static async create(data) {
|
|
62
114
|
const adapter = ConnectionManager.getAdapter()
|
|
63
115
|
const fields = this.fillable.filter((f) => f in data)
|
|
@@ -119,6 +171,95 @@ export class Model {
|
|
|
119
171
|
return true
|
|
120
172
|
}
|
|
121
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Delete one or more rows by id and return how many actually existed:
|
|
176
|
+
* await Post.destroy(1)
|
|
177
|
+
* await Post.destroy(1, 2, 3)
|
|
178
|
+
* await Post.destroy([1, 2, 3])
|
|
179
|
+
*/
|
|
180
|
+
static async destroy(...ids) {
|
|
181
|
+
const flat = ids.flat().filter((id) => id !== undefined && id !== null)
|
|
182
|
+
if (flat.length === 0) {
|
|
183
|
+
throw new Error(`${this.name}.destroy() needs at least one id.`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let deleted = 0
|
|
187
|
+
for (const id of flat) {
|
|
188
|
+
if (await this.find(id)) {
|
|
189
|
+
await this.delete(id)
|
|
190
|
+
deleted++
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return deleted
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Find the first row matching `attributes`, or create it with
|
|
198
|
+
* `attributes` + `values`:
|
|
199
|
+
* await User.firstOrCreate({ email: "a@b.com" }, { name: "Ann" })
|
|
200
|
+
*/
|
|
201
|
+
static async firstOrCreate(attributes, values = {}) {
|
|
202
|
+
const keys = Object.keys(attributes ?? {})
|
|
203
|
+
if (keys.length === 0) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${this.name}.firstOrCreate() needs at least one attribute to match on.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let query = this.query()
|
|
210
|
+
for (const key of keys) {
|
|
211
|
+
query = query.where(key, attributes[key])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const existing = await query.first()
|
|
215
|
+
if (existing) return existing
|
|
216
|
+
|
|
217
|
+
return this.create({ ...attributes, ...values })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Find the first row matching `attributes` and update it with `values`,
|
|
222
|
+
* or create it with `attributes` + `values`:
|
|
223
|
+
* await Setting.updateOrCreate({ key: "theme" }, { value: "dark" })
|
|
224
|
+
*/
|
|
225
|
+
static async updateOrCreate(attributes, values = {}) {
|
|
226
|
+
const keys = Object.keys(attributes ?? {})
|
|
227
|
+
if (keys.length === 0) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`${this.name}.updateOrCreate() needs at least one attribute to match on.`,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let query = this.query()
|
|
234
|
+
for (const key of keys) {
|
|
235
|
+
query = query.where(key, attributes[key])
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const existing = await query.first()
|
|
239
|
+
if (!existing) {
|
|
240
|
+
return this.create({ ...attributes, ...values })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const hasFillableChange = this.fillable.some((f) => f in values)
|
|
244
|
+
if (!hasFillableChange) return existing
|
|
245
|
+
|
|
246
|
+
return this.update(existing.id, values)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One column as a plain array (or an object when a key column is given):
|
|
251
|
+
* await User.pluck("email") -> ["a@b.com", "c@d.com"]
|
|
252
|
+
* await User.pluck("email", "id") -> { 1: "a@b.com", 2: "c@d.com" }
|
|
253
|
+
*/
|
|
254
|
+
static async pluck(column, keyColumn) {
|
|
255
|
+
return this.query().pluck(column, keyColumn)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** True when the table has at least one row. */
|
|
259
|
+
static async exists() {
|
|
260
|
+
return this.query().exists()
|
|
261
|
+
}
|
|
262
|
+
|
|
122
263
|
static async belongsTo(row, foreignKey, RelatedModel) {
|
|
123
264
|
if (!row || row[foreignKey] === undefined || row[foreignKey] === null) {
|
|
124
265
|
return null
|
|
@@ -141,4 +282,4 @@ export class Model {
|
|
|
141
282
|
static async count() {
|
|
142
283
|
return this.query().count()
|
|
143
284
|
}
|
|
144
|
-
}
|
|
285
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,91 @@
|
|
|
1
|
-
const
|
|
1
|
+
const COMPARISON_OPERATORS = ["=", "!=", "<>", ">", "<", ">=", "<="];
|
|
2
|
+
const NULL_OPERATORS = ["IS NULL", "IS NOT NULL"];
|
|
3
|
+
const LIST_OPERATORS = ["IN", "NOT IN"];
|
|
4
|
+
|
|
5
|
+
// "like" and other lowercase spellings are accepted and normalized first.
|
|
6
|
+
const SUPPORTED_OPERATORS = [
|
|
7
|
+
...COMPARISON_OPERATORS,
|
|
8
|
+
"LIKE",
|
|
9
|
+
...NULL_OPERATORS,
|
|
10
|
+
...LIST_OPERATORS,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// What a LIKE condition is written as in SQL. MySQL and SQLite are already
|
|
14
|
+
// case-insensitive for LIKE, so they keep "LIKE"; PostgreSQL is
|
|
15
|
+
// case-sensitive, so its adapter asks for "ILIKE" to behave the same way.
|
|
16
|
+
const LIKE_OPERATORS = ["LIKE", "ILIKE"];
|
|
17
|
+
|
|
18
|
+
// Each value in an IN list is its own "?" placeholder, and every driver caps
|
|
19
|
+
// the number of placeholders per statement.
|
|
20
|
+
const MAX_LIST_VALUES = 1000;
|
|
21
|
+
|
|
22
|
+
// Letters, digits and underscores only, with an optional "table.column" form.
|
|
23
|
+
// Rejects spaces, quotes, semicolons, comments and anything else that could
|
|
24
|
+
// change the shape of the query.
|
|
25
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
26
|
+
|
|
27
|
+
function assertIdentifier(name, kind) {
|
|
28
|
+
if (typeof name !== "string" || !IDENTIFIER.test(name)) {
|
|
29
|
+
throw new Error(`Invalid ${kind} name "${String(name)}".`);
|
|
30
|
+
}
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeOperator(operator) {
|
|
35
|
+
return String(operator).trim().replace(/\s+/g, " ").toUpperCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Builds one WHERE condition and pushes its values onto `params`.
|
|
39
|
+
function buildCondition(w, likeOperator, params) {
|
|
40
|
+
const operator = normalizeOperator(w.operator);
|
|
41
|
+
if (!SUPPORTED_OPERATORS.includes(operator)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Unsupported where operator "${w.operator}". Supported: ${SUPPORTED_OPERATORS.join(", ")}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const column = assertIdentifier(w.field, "column");
|
|
48
|
+
|
|
49
|
+
if (NULL_OPERATORS.includes(operator)) {
|
|
50
|
+
return `${column} ${operator}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (LIST_OPERATORS.includes(operator)) {
|
|
54
|
+
const values = w.value;
|
|
55
|
+
if (!Array.isArray(values)) {
|
|
56
|
+
throw new Error(`Operator "${operator}" needs an array of values.`);
|
|
57
|
+
}
|
|
58
|
+
if (values.length > MAX_LIST_VALUES) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Operator "${operator}" accepts at most ${MAX_LIST_VALUES} values, got ${values.length}.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
// NULL never matches inside IN, and a single NULL makes NOT IN match
|
|
64
|
+
// nothing at all, so reject it instead of returning surprising rows.
|
|
65
|
+
if (values.some((v) => v === null || v === undefined)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`"${operator}" lists cannot contain null or undefined. Use whereNull() / whereNotNull() for null checks.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
// "IN ()" is invalid SQL. An empty IN matches nothing; an empty NOT IN
|
|
71
|
+
// excludes nothing.
|
|
72
|
+
if (values.length === 0) {
|
|
73
|
+
return operator === "IN" ? "1 = 0" : "1 = 1";
|
|
74
|
+
}
|
|
75
|
+
params.push(...values);
|
|
76
|
+
return `${column} ${operator} (${values.map(() => "?").join(", ")})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// "= NULL" and "!= NULL" are never true in SQL, so translate them.
|
|
80
|
+
if (w.value === null) {
|
|
81
|
+
if (operator === "=") return `${column} IS NULL`;
|
|
82
|
+
if (operator === "!=" || operator === "<>") return `${column} IS NOT NULL`;
|
|
83
|
+
throw new Error(`Operator "${operator}" cannot be used with null.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
params.push(w.value);
|
|
87
|
+
return `${column} ${operator === "LIKE" ? likeOperator : operator} ?`;
|
|
88
|
+
}
|
|
2
89
|
|
|
3
90
|
/**
|
|
4
91
|
* Translates a structured query descriptor into a "?"-placeholder SQL
|
|
@@ -8,29 +95,38 @@ const SUPPORTED_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
|
|
|
8
95
|
* translation and datetime conversion correctly per driver, so this
|
|
9
96
|
* only needs to speak the common "?" dialect.
|
|
10
97
|
*
|
|
98
|
+
* Values are always passed as parameters. Table and column names cannot be
|
|
99
|
+
* parameters, so they are validated as plain identifiers instead.
|
|
100
|
+
*
|
|
11
101
|
* limit/offset are inlined as validated integers rather than passed as
|
|
12
102
|
* placeholders, since mysql2 does not reliably support placeholders in
|
|
13
103
|
* LIMIT/OFFSET position (same reasoning as Model.paginate()).
|
|
104
|
+
*
|
|
105
|
+
* options.likeOperator: "LIKE" (default) or "ILIKE", used for LIKE conditions.
|
|
14
106
|
*/
|
|
15
|
-
export function buildSelectSql(
|
|
107
|
+
export function buildSelectSql(
|
|
108
|
+
table,
|
|
109
|
+
{ wheres = [], orders = [], limitValue, offsetValue } = {},
|
|
110
|
+
{ likeOperator = "LIKE" } = {}
|
|
111
|
+
) {
|
|
112
|
+
if (!LIKE_OPERATORS.includes(likeOperator)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Unsupported likeOperator "${likeOperator}". Use: ${LIKE_OPERATORS.join(", ")}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
16
118
|
const params = [];
|
|
17
|
-
let sql = `SELECT * FROM ${table}`;
|
|
119
|
+
let sql = `SELECT * FROM ${assertIdentifier(table, "table")}`;
|
|
18
120
|
|
|
19
121
|
if (wheres.length > 0) {
|
|
20
|
-
const clauses = wheres.map((w) =>
|
|
21
|
-
if (!SUPPORTED_OPERATORS.includes(w.operator)) {
|
|
22
|
-
throw new Error(
|
|
23
|
-
`Unsupported where operator "${w.operator}". Supported: ${SUPPORTED_OPERATORS.join(", ")}`
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
params.push(w.value);
|
|
27
|
-
return `${w.field} ${w.operator} ?`;
|
|
28
|
-
});
|
|
122
|
+
const clauses = wheres.map((w) => buildCondition(w, likeOperator, params));
|
|
29
123
|
sql += ` WHERE ${clauses.join(" AND ")}`;
|
|
30
124
|
}
|
|
31
125
|
|
|
32
126
|
if (orders.length > 0) {
|
|
33
|
-
const clauses = orders.map(
|
|
127
|
+
const clauses = orders.map(
|
|
128
|
+
(o) => `${assertIdentifier(o.field, "column")} ${o.direction}`
|
|
129
|
+
);
|
|
34
130
|
sql += ` ORDER BY ${clauses.join(", ")}`;
|
|
35
131
|
}
|
|
36
132
|
|
|
@@ -47,4 +143,4 @@ export function buildSelectSql(table, { wheres = [], orders = [], limitValue, of
|
|
|
47
143
|
}
|
|
48
144
|
|
|
49
145
|
return { sql, params };
|
|
50
|
-
}
|
|
146
|
+
}
|
package/src/query/types.d.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
export type ComparisonOperator =
|
|
2
|
+
| '='
|
|
3
|
+
| '!='
|
|
4
|
+
| '<>'
|
|
5
|
+
| '>'
|
|
6
|
+
| '<'
|
|
7
|
+
| '>='
|
|
8
|
+
| '<='
|
|
9
|
+
| 'LIKE'
|
|
10
|
+
| 'like'
|
|
11
|
+
|
|
12
|
+
export type ListOperator = 'IN' | 'NOT IN'
|
|
13
|
+
|
|
14
|
+
export type NullOperator = 'IS NULL' | 'IS NOT NULL'
|
|
15
|
+
|
|
2
16
|
export interface WhereClause {
|
|
3
17
|
field: string
|
|
4
|
-
operator:
|
|
18
|
+
operator: ComparisonOperator | ListOperator | NullOperator
|
|
5
19
|
value: unknown
|
|
6
20
|
}
|
|
7
21
|
|
|
@@ -15,4 +29,28 @@ export interface QueryDescriptor {
|
|
|
15
29
|
orders: OrderClause[]
|
|
16
30
|
limitValue?: number
|
|
17
31
|
offsetValue?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ColumnBuilder {
|
|
35
|
+
unique(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TableBuilder {
|
|
39
|
+
increments(name: string): ColumnBuilder;
|
|
40
|
+
string(name: string): ColumnBuilder;
|
|
41
|
+
text(name: string): ColumnBuilder;
|
|
42
|
+
boolean(name: string): ColumnBuilder;
|
|
43
|
+
integer(name: string): ColumnBuilder;
|
|
44
|
+
date(name: string): ColumnBuilder;
|
|
45
|
+
datetime(name: string): ColumnBuilder;
|
|
46
|
+
json(name: string): ColumnBuilder;
|
|
47
|
+
timestamps(): void;
|
|
48
|
+
// present only in alterTable's callback, absent from createTable's
|
|
49
|
+
dropColumn?(name: string): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Schema {
|
|
53
|
+
createTable(tableName: string, callback: (table: TableBuilder) => void | Promise<void>): Promise<void>;
|
|
54
|
+
dropTable(tableName: string): Promise<void>;
|
|
55
|
+
alterTable(tableName: string, callback: (table: TableBuilder) => void | Promise<void>): Promise<void>;
|
|
18
56
|
}
|