@tylix/orm 0.5.0 → 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/DatabaseError.d.ts +52 -0
- package/src/adapters/DatabaseError.js +336 -0
- package/src/adapters/MongoAdapter.js +192 -77
- package/src/adapters/MysqlAdapter.js +41 -31
- package/src/adapters/PostgresAdapter.js +38 -18
- package/src/adapters/SqliteAdapter.js +33 -23
- 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/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
|
}
|