@bakery-framework/orm 2.0.0-alpha.1 → 2.0.0-alpha.11

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": "@bakery-framework/orm",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -51,9 +51,9 @@
51
51
  "!src/tests"
52
52
  ],
53
53
  "engines": {
54
- "bun": ">=1.3.14"
54
+ "bun": ">=1.4.0"
55
55
  },
56
56
  "dependencies": {
57
- "@bakery-framework/core": "^1.2.3"
57
+ "@bakery-framework/core": "^2.0.0-alpha.11"
58
58
  }
59
59
  }
@@ -35,10 +35,21 @@ export namespace SQLAdapter {
35
35
  }
36
36
  export interface TableDetails {
37
37
  name: string
38
- rowCount: number
38
+ /**
39
+ * `null` unless the caller asked for counts. A `COUNT(*)` is a full scan
40
+ * on SQLite and Postgres, and `getSchema()` is on the write path — every
41
+ * explorer write introspects to resolve row identity — so the scan is paid
42
+ * only where a number is actually displayed. `null`, not `0`: a count that
43
+ * was never taken must not read as an empty table.
44
+ */
45
+ rowCount: number | null
39
46
  columns: TableColumnInfo[]
40
47
  indexes: TableIndexInfo[]
41
48
  }
49
+ export interface SchemaOptions {
50
+ /** Take the per-table `COUNT(*)`. Off by default — see `rowCount`. */
51
+ rowCounts?: boolean
52
+ }
42
53
  export interface TableDataResult {
43
54
  rows: any[]
44
55
  totalRows: number
@@ -534,7 +545,9 @@ export abstract class SQLAdapter {
534
545
  ).run()
535
546
  }
536
547
 
537
- abstract getSchema(): Promise<SQLAdapter.TableDetails[]>
548
+ abstract getSchema(
549
+ options?: SQLAdapter.SchemaOptions,
550
+ ): Promise<SQLAdapter.TableDetails[]>
538
551
 
539
552
  /**
540
553
  * Foreign keys as the database has them, keyed by the tuple that identifies
@@ -849,20 +862,68 @@ export abstract class SQLAdapter {
849
862
  return new DatabaseStatement(this, sqlText)
850
863
  }
851
864
 
865
+ /**
866
+ * The `LIKE` escape character — `!`, and deliberately **not** a backslash.
867
+ *
868
+ * A backslash is the obvious choice and is still the wrong one. MySQL
869
+ * processes backslash escapes inside string literals where SQLite and
870
+ * Postgres do not, so `ESCAPE '\'` would need a per-dialect spelling — and
871
+ * the whole point of one escape character is one clause for all three.
872
+ *
873
+ * The Postgres normalizer used to make this worse: it applied MySQL's rule
874
+ * to every dialect, so `'\'` swallowed its own closing quote and the driver
875
+ * reported a syntax error several tokens later. That defect is fixed — the
876
+ * scanner now has no opinion about backslashes, matching the server — and
877
+ * `a backslash in a literal is a character, not an escape` pins it. `!` stays
878
+ * regardless, because MySQL's literal-level escaping is the server's real
879
+ * behaviour, not a scanner bug, and a character with no meaning to any of the
880
+ * three parsers needs no capability getter.
881
+ */
882
+ protected readonly likeEscape = '!'
883
+
884
+ get likeEscapeClause(): string {
885
+ return ` ESCAPE '${this.likeEscape}'`
886
+ }
887
+
888
+ /**
889
+ * Make a value match literally under `LIKE`.
890
+ *
891
+ * Without this a search for `50%` matches every row and one for `a_b` matches
892
+ * `axb` — `%` and `_` are the wildcards, and the filter passed user input
893
+ * through untouched. The escape character itself goes first, or escaping it
894
+ * afterwards would double the ones this method just added.
895
+ */
896
+ protected escapeLike(value: string): string {
897
+ const e = this.likeEscape
898
+ return value
899
+ .split(e)
900
+ .join(e + e)
901
+ .replace(/[%_]/g, m => `${e}${m}`)
902
+ }
903
+
904
+ /**
905
+ * `WHERE` and `ORDER BY` for a browsable table listing.
906
+ *
907
+ * A filter is either a **bare scalar**, which means `contains` and is what
908
+ * every caller sent before operators existed, or **`{op, value}`**. Keeping
909
+ * the scalar form meaningful is not politeness: `getData` is public and the
910
+ * dashboard still calls it that way.
911
+ *
912
+ * Columns are intersected with the real column set by the caller, so an
913
+ * unknown column disappears rather than reaching SQL. Values always bind.
914
+ */
852
915
  protected buildFilterSort(
853
916
  options: SQLAdapter.FilterSortOptions,
854
917
  validCols: Set<string>,
855
918
  ) {
856
919
  const whereParams: unknown[] = []
857
- const whereClauses = Object.entries(options.filters || {})
858
- .filter(
859
- ([col, val]) =>
860
- validCols.has(col) && val !== undefined && val !== null && val !== '',
861
- )
862
- .map(([col, val]) => {
863
- whereParams.push(`%${val}%`)
864
- return `${this.quote(col)} LIKE ?`
865
- })
920
+ const whereClauses: string[] = []
921
+
922
+ for (const [col, raw] of Object.entries(options.filters || {})) {
923
+ if (!validCols.has(col)) continue
924
+ const clause = this.filterClause(col, raw, whereParams)
925
+ if (clause) whereClauses.push(clause)
926
+ }
866
927
 
867
928
  const whereSql = whereClauses.length
868
929
  ? ` WHERE ${whereClauses.join(' AND ')}`
@@ -880,6 +941,67 @@ export abstract class SQLAdapter {
880
941
  return { whereSql, orderSql, whereParams }
881
942
  }
882
943
 
944
+ /**
945
+ * One filter, as SQL. Returns `null` when the filter says nothing.
946
+ *
947
+ * `params` is appended to rather than returned, because an operator may bind
948
+ * one value, two, or none at all — `IS NULL` has nothing to bind, and
949
+ * pretending otherwise is how a placeholder count drifts from its arguments.
950
+ */
951
+ private filterClause(
952
+ col: string,
953
+ raw: unknown,
954
+ params: unknown[],
955
+ ): string | null {
956
+ const quoted = this.quote(col)
957
+
958
+ // The pre-operator form. An empty string means "no filter" here, which is
959
+ // what a cleared text box sends and what every caller has relied on.
960
+ if (raw === null || raw === undefined || typeof raw !== 'object') {
961
+ if (raw === undefined || raw === null || raw === '') return null
962
+ params.push(`%${this.escapeLike(String(raw))}%`)
963
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
964
+ }
965
+
966
+ const { op, value } = raw as { op?: string; value?: unknown }
967
+
968
+ // No value to bind, and none expected — these two are the whole reason a
969
+ // filter cannot be modelled as a plain column/value pair.
970
+ if (op === 'null') return `${quoted} IS NULL`
971
+ if (op === 'notnull') return `${quoted} IS NOT NULL`
972
+
973
+ if (value === undefined || value === null) return null
974
+
975
+ const comparison: Record<string, string> = {
976
+ eq: '=',
977
+ ne: '<>',
978
+ gt: '>',
979
+ gte: '>=',
980
+ lt: '<',
981
+ lte: '<=',
982
+ }
983
+ if (op && comparison[op]) {
984
+ params.push(value)
985
+ return `${quoted} ${comparison[op]} ?`
986
+ }
987
+
988
+ const pattern: Record<string, (v: string) => string> = {
989
+ contains: v => `%${v}%`,
990
+ starts: v => `${v}%`,
991
+ ends: v => `%${v}`,
992
+ }
993
+ const shape = op ? pattern[op] : undefined
994
+ if (shape) {
995
+ params.push(shape(this.escapeLike(String(value))))
996
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
997
+ }
998
+
999
+ // An operator this dialect does not know is dropped rather than guessed
1000
+ // at. Guessing would mean answering a question nobody asked, and the
1001
+ // caller validates the vocabulary before it gets here.
1002
+ return null
1003
+ }
1004
+
883
1005
  protected formatDefault(
884
1006
  def: unknown,
885
1007
  boolTrue: string,
@@ -270,7 +270,9 @@ export class MySQLAdapter extends SQLAdapter {
270
270
  return new MySQLAdapter(sql as SQL)
271
271
  }
272
272
 
273
- async getSchema(): Promise<SQLAdapter.TableDetails[]> {
273
+ async getSchema(
274
+ options?: SQLAdapter.SchemaOptions,
275
+ ): Promise<SQLAdapter.TableDetails[]> {
274
276
  const res = (await this.query(
275
277
  'SELECT table_name AS name' +
276
278
  ' FROM information_schema.tables' +
@@ -280,7 +282,11 @@ export class MySQLAdapter extends SQLAdapter {
280
282
  const tablesWithDetails: SQLAdapter.TableDetails[] = []
281
283
  for (const t of res) {
282
284
  const [countRes, cols, idxs] = (await Promise.all([
283
- this.query(`SELECT COUNT(*) as count FROM ${this.quote(t.name)}`).get(),
285
+ options?.rowCounts
286
+ ? this.query(
287
+ `SELECT COUNT(*) as count FROM ${this.quote(t.name)}`,
288
+ ).get()
289
+ : null,
284
290
  this.query(
285
291
  'SELECT column_name AS name, data_type AS type,' +
286
292
  ' is_nullable AS is_nullable, column_key AS column_key' +
@@ -293,13 +299,13 @@ export class MySQLAdapter extends SQLAdapter {
293
299
  ' FROM information_schema.statistics' +
294
300
  ' WHERE table_name = ? AND table_schema = DATABASE()',
295
301
  ).all(t.name),
296
- ])) as [SQLAdapter.CountRow, any[], any[]]
302
+ ])) as [SQLAdapter.CountRow | null, any[], any[]]
297
303
  const uniqueIdxs = Array.from(
298
304
  new Map(idxs.map(i => [i.name, i.non_unique === 0])).entries(),
299
305
  ).map(([name, unique]) => ({ name, unique }))
300
306
  tablesWithDetails.push({
301
307
  name: t.name,
302
- rowCount: countRes?.count || 0,
308
+ rowCount: options?.rowCounts ? countRes?.count || 0 : null,
303
309
  columns: cols.map(c => ({
304
310
  name: c.name,
305
311
  type: c.type,
@@ -505,6 +511,7 @@ export class MySQLAdapter extends SQLAdapter {
505
511
  type: info.non_unique === 0 ? 'unique' : 'index',
506
512
  table: Case.camel(info.table),
507
513
  cols: info.cols.map(Case.camel),
514
+ rawCols: info.cols,
508
515
  },
509
516
  ]),
510
517
  )
@@ -60,13 +60,16 @@ export class PGAdapter extends SQLAdapter {
60
60
  nextChar: string | undefined,
61
61
  state: PGSQLParserState,
62
62
  ): string | null {
63
- if (char === '\\') {
64
- if ((state.inSingleQuote || state.inDoubleQuote) && nextChar) {
65
- state.skipNext = true
66
- return `\\${nextChar}`
67
- }
68
- return char
69
- }
63
+ // No backslash branch, deliberately. Postgres with
64
+ // `standard_conforming_strings` the server default since 9.1, and what
65
+ // every connection here runs under — treats a backslash inside a `'…'`
66
+ // literal as an ordinary character. This scanner used to apply MySQL's
67
+ // rule instead: `\` consumed the next character, so the `'` closing a
68
+ // `'\'` literal was eaten, the scanner stayed "inside" a literal the
69
+ // server had already closed, and every `?` after it was left unrewritten —
70
+ // which is how `ESCAPE '\'` produced a syntax error several tokens
71
+ // downstream. The server's parse is the only one that matters; the scanner
72
+ // now agrees with it by having no opinion about backslashes at all.
70
73
  if (char === '`') {
71
74
  return !state.inSingleQuote && !state.inDoubleQuote ? '"' : char
72
75
  }
@@ -246,57 +249,73 @@ export class PGAdapter extends SQLAdapter {
246
249
  return new PGAdapter(sql as SQL)
247
250
  }
248
251
 
249
- async getSchema(): Promise<SQLAdapter.TableDetails[]> {
252
+ async getSchema(
253
+ options?: SQLAdapter.SchemaOptions,
254
+ ): Promise<SQLAdapter.TableDetails[]> {
250
255
  const res = (await this.query(
251
256
  'SELECT table_name AS name, table_type AS type' +
252
257
  ' FROM information_schema.tables' +
253
258
  " WHERE table_schema NOT IN ('pg_catalog', 'information_schema')" +
254
259
  ' ORDER BY table_name',
255
260
  ).all()) as any[]
256
- const tablesWithDetails: SQLAdapter.TableDetails[] = []
257
-
258
- for (const t of res) {
259
- const qName = this.quote(t.name)
260
- const [countRes, cols, pkCols, idxs] = (await Promise.all([
261
- this.query(`SELECT COUNT(*)::int as count FROM ${qName}`).get(),
262
- this.query(
263
- 'SELECT column_name AS name, data_type AS type,' +
264
- ' is_nullable AS is_nullable' +
265
- ' FROM information_schema.columns' +
266
- ' WHERE table_name = ?' +
267
- " AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
268
- ' ORDER BY ordinal_position',
269
- ).all(t.name),
270
- this.query(
271
- 'SELECT a.attname AS name' +
272
- ' FROM pg_index i' +
273
- ' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
274
- ' AND a.attnum = ANY(i.indkey)' +
275
- ` WHERE i.indisprimary AND i.indrelid = ${qName}::regclass`,
276
- ).all(),
277
- this.query(
278
- 'SELECT indexname AS name, indexdef AS def' +
279
- ' FROM pg_indexes' +
280
- " WHERE schemaname NOT IN ('pg_catalog', 'information_schema')" +
281
- ' AND tablename = ?',
282
- ).all(t.name),
283
- ])) as [SQLAdapter.CountRow, any[], any[], any[]]
284
- tablesWithDetails.push({
285
- name: t.name,
286
- rowCount: countRes?.count || 0,
287
- columns: cols.map(c => ({
288
- name: c.name,
289
- type: c.type,
290
- notnull: c.is_nullable === 'NO',
291
- pk: pkCols.some(pk => pk.name === c.name),
292
- })),
293
- indexes: idxs.map(i => ({
294
- name: i.name,
295
- unique: /UNIQUE/i.test(i.def),
296
- })),
297
- })
298
- }
299
- return tablesWithDetails
261
+ // One wave rather than N. The four queries per table were already
262
+ // concurrent; the loop around them awaited each table in turn, so a
263
+ // 20-table schema cost 20 sequential round-trip waves. See the note in
264
+ // sqlite.ts about the COUNT(*), which is a full scan here too.
265
+ return await Promise.all(
266
+ res.map(async t => {
267
+ const qName = this.quote(t.name)
268
+ const [countRes, cols, pkCols, idxs] = (await Promise.all([
269
+ options?.rowCounts
270
+ ? this.query(`SELECT COUNT(*)::int as count FROM ${qName}`).get()
271
+ : null,
272
+ this.query(
273
+ 'SELECT column_name AS name, data_type AS type,' +
274
+ ' is_nullable AS is_nullable' +
275
+ ' FROM information_schema.columns' +
276
+ ' WHERE table_name = ?' +
277
+ " AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
278
+ ' ORDER BY ordinal_position',
279
+ ).all(t.name),
280
+ // `::regclass` casts a **string**, so the table name binds as a
281
+ // parameter. It used to interpolate `qName` — a double-quoted
282
+ // *identifier* — which Postgres reads as a column reference, so this
283
+ // threw `column "<table>" does not exist` for every table and took the
284
+ // whole of `getSchema()` down with it on this dialect.
285
+ //
286
+ // The quiet case was worse than the loud one: a table with a column of
287
+ // the same name resolved, casting that column's *value* to a regclass
288
+ // and reporting some other table's primary key as this one's.
289
+ this.query(
290
+ 'SELECT a.attname AS name' +
291
+ ' FROM pg_index i' +
292
+ ' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
293
+ ' AND a.attnum = ANY(i.indkey)' +
294
+ ' WHERE i.indisprimary AND i.indrelid = ?::regclass',
295
+ ).all(t.name),
296
+ this.query(
297
+ 'SELECT indexname AS name, indexdef AS def' +
298
+ ' FROM pg_indexes' +
299
+ " WHERE schemaname NOT IN ('pg_catalog', 'information_schema')" +
300
+ ' AND tablename = ?',
301
+ ).all(t.name),
302
+ ])) as [SQLAdapter.CountRow | null, any[], any[], any[]]
303
+ return {
304
+ name: t.name,
305
+ rowCount: options?.rowCounts ? countRes?.count || 0 : null,
306
+ columns: cols.map(c => ({
307
+ name: c.name,
308
+ type: c.type,
309
+ notnull: c.is_nullable === 'NO',
310
+ pk: pkCols.some(pk => pk.name === c.name),
311
+ })),
312
+ indexes: idxs.map(i => ({
313
+ name: i.name,
314
+ unique: /UNIQUE/i.test(i.def),
315
+ })),
316
+ }
317
+ }),
318
+ )
300
319
  }
301
320
 
302
321
  async getData(
@@ -461,14 +480,14 @@ export class PGAdapter extends SQLAdapter {
461
480
  )
462
481
  continue
463
482
  const m = r.indexdef.match(/\(([^)]+)\)/)
483
+ const raw: string[] = m
484
+ ? m[1].split(',').map((c: string) => c.trim().replace(/"/g, ''))
485
+ : []
464
486
  dbIndexes[Case.camel(r.indexname)] = {
465
487
  type: /UNIQUE/i.test(r.indexdef) ? 'unique' : 'index',
466
488
  table: Case.camel(r.tablename),
467
- cols: m
468
- ? m[1]
469
- .split(',')
470
- .map((c: string) => Case.camel(c.trim().replace(/"/g, '')))
471
- : [],
489
+ cols: raw.map(Case.camel),
490
+ rawCols: raw,
472
491
  }
473
492
  }
474
493
  return dbIndexes
@@ -282,34 +282,58 @@ export class SQLiteAdapter extends SQLAdapter {
282
282
  return new SQLiteAdapter(this.filename, sql as SQL)
283
283
  }
284
284
 
285
- async getSchema(): Promise<SQLAdapter.TableDetails[]> {
285
+ async getSchema(
286
+ options?: SQLAdapter.SchemaOptions,
287
+ ): Promise<SQLAdapter.TableDetails[]> {
286
288
  const res = (await this.query(
287
289
  'SELECT name FROM sqlite_master' +
288
290
  " WHERE type='table' AND name NOT LIKE 'sqlite_%'",
289
291
  ).all()) as SQLAdapter.NameRow[]
290
- const tablesWithDetails: SQLAdapter.TableDetails[] = []
291
- for (const t of res) {
292
- const tableName = this.quote(t.name)
293
-
294
- const [countRes, cols, idxs] = (await Promise.all([
295
- this.query(`SELECT COUNT(*) as count FROM ${tableName}`).get(),
296
- this.query(`PRAGMA table_info(${tableName})`).all(),
297
- this.query(`PRAGMA index_list(${tableName})`).all(),
298
- ])) as [SQLAdapter.CountRow, any[], any[]]
299
-
300
- tablesWithDetails.push({
301
- name: t.name,
302
- rowCount: countRes?.count || 0,
303
- columns: cols.map(c => ({
304
- name: c.name,
305
- type: c.type,
306
- notnull: c.notnull === 1,
307
- pk: c.pk === 1,
308
- })),
309
- indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
310
- })
311
- }
312
- return tablesWithDetails
292
+ // Per table rather than per wave: the three queries below were already
293
+ // concurrent, but the loop around them awaited each table in turn, so a
294
+ // 20-table schema cost 20 sequential round-trip waves. They share nothing,
295
+ // so the whole fan-out is one wave now.
296
+ //
297
+ // The `COUNT(*)` is a full scan on this dialect and on Postgres, so it is
298
+ // opt-in: only a schema *listing* displays the number, and `getSchema()`
299
+ // also sits on the explorer's write path, where a count nobody shows cost
300
+ // a scan of every table per write.
301
+ return await Promise.all(
302
+ res.map(async t => {
303
+ const tableName = this.quote(t.name)
304
+
305
+ const [countRes, cols, idxs] = (await Promise.all([
306
+ options?.rowCounts
307
+ ? this.query(`SELECT COUNT(*) as count FROM ${tableName}`).get()
308
+ : null,
309
+ this.query(`PRAGMA table_info(${tableName})`).all(),
310
+ this.query(`PRAGMA index_list(${tableName})`).all(),
311
+ ])) as [SQLAdapter.CountRow | null, any[], any[]]
312
+
313
+ return {
314
+ name: t.name,
315
+ rowCount: options?.rowCounts ? countRes?.count || 0 : null,
316
+ columns: cols.map(c => ({
317
+ name: c.name,
318
+ type: c.type,
319
+ notnull: c.notnull === 1,
320
+ // `pk` is the column's **1-based position within the primary key**,
321
+ // not a boolean — `PRAGMA table_info` reports 0 for "not part of the
322
+ // key", 1 for the first key column, 2 for the second. So `=== 1`
323
+ // reported a composite `PRIMARY KEY (a, b)` as a single-column key on
324
+ // `a`, silently, and only on SQLite: MySQL reads `column_key = 'PRI'`
325
+ // and Postgres reads `pg_index.indisprimary`, both of which are set on
326
+ // every member.
327
+ //
328
+ // `parseConstraints` a few hundred lines down already had this right
329
+ // (`col.pk > 0`), which is why `getConstraints()` disagreed with
330
+ // `getSchema()` about the same table.
331
+ pk: c.pk > 0,
332
+ })),
333
+ indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
334
+ }
335
+ }),
336
+ )
313
337
  }
314
338
 
315
339
  async getData(
@@ -484,18 +508,22 @@ export class SQLiteAdapter extends SQLAdapter {
484
508
  ).all()) as any[]
485
509
  return Object.fromEntries(
486
510
  await Promise.all(
487
- indexes.map(async idx => [
488
- Case.camel(idx.name),
489
- {
490
- type: idx.sql.toUpperCase().includes('UNIQUE') ? 'unique' : 'index',
491
- table: Case.camel(idx.tbl_name),
492
- cols: (
493
- (await this.query(
494
- `PRAGMA index_info('${idx.name}')`,
495
- ).all()) as any[]
496
- ).map(c => Case.camel(c.name)),
497
- },
498
- ]),
511
+ indexes.map(async idx => {
512
+ const raw = (
513
+ (await this.query(`PRAGMA index_info('${idx.name}')`).all()) as any[]
514
+ ).map(c => String(c.name))
515
+ return [
516
+ Case.camel(idx.name),
517
+ {
518
+ type: idx.sql.toUpperCase().includes('UNIQUE')
519
+ ? 'unique'
520
+ : 'index',
521
+ table: Case.camel(idx.tbl_name),
522
+ cols: raw.map(Case.camel),
523
+ rawCols: raw,
524
+ },
525
+ ]
526
+ }),
499
527
  ),
500
528
  )
501
529
  }