@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tylix/orm",
3
- "version": "0.5.1",
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.5.1",
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
- const filter = {}
324
+ let filter = {}
281
325
  const sort = {}
282
326
  const description = () => {
283
- let text = `db.${table}.find(${JSON.stringify(filter)})`
284
- if (Object.keys(sort).length) text += `.sort(${JSON.stringify(sort)})`
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
- for (const w of wheres) {
292
- if (w.operator === '=') filter[w.field] = w.value
293
- else if (w.operator === '!=') filter[w.field] = { $ne: w.value }
294
- else if (w.operator === '>') filter[w.field] = { $gt: w.value }
295
- else if (w.operator === '<') filter[w.field] = { $lt: w.value }
296
- else if (w.operator === '>=') filter[w.field] = { $gte: w.value }
297
- else if (w.operator === '<=') filter[w.field] = { $lte: w.value }
298
- else
299
- throw new Error(
300
- `Unsupported where operator "${w.operator}" for MongoAdapter`,
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] = o.direction.toUpperCase() === 'DESC' ? -1 : 1
377
+ sort[assertField(o.field)] =
378
+ o.direction.toUpperCase() === 'DESC' ? -1 : 1
311
379
  })
312
380
  cursor = cursor.sort(sort)
313
381
  }
@@ -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<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>;
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: Record<string, unknown> | null,
57
+ row: Row | null,
38
58
  foreignKey: string,
39
59
  RelatedModel: typeof Model,
40
- ): Promise<Record<string, unknown> | null>;
60
+ ): Promise<Row | null>;
41
61
  static hasMany(
42
62
  row: { id: number },
43
63
  RelatedModel: typeof Model,
44
64
  foreignKey: string,
45
- ): Promise<Record<string, unknown>[]>;
65
+ ): Promise<Row[]>;
46
66
 
47
- static first(): Promise<Record<string, unknown> | null>;
67
+ static first(): Promise<Row | null>;
48
68
  static count(): Promise<number>;
49
69
  }
@@ -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 SUPPORTED_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
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(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
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((o) => `${o.field} ${o.direction}`);
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
+ }
@@ -1,7 +1,21 @@
1
- // src/query/types.d.ts
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
  }