@bakery-framework/orm 1.2.3 → 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 +3 -3
- package/src/adapters/base.ts +140 -19
- package/src/adapters/mysql.ts +11 -4
- package/src/adapters/pgsql.ts +76 -57
- package/src/adapters/sqlite.ts +64 -36
- package/src/orm/index.ts +2 -3
- package/src/orm/query.ts +10 -0
- package/src/schema-registry.ts +4 -7
- package/src/sync/builder.ts +10 -15
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +21 -22
- package/src/sync/execute.ts +522 -0
- package/src/sync/load.ts +6 -5
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +69 -8
- package/src/sync/helpers.ts +0 -1145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "
|
|
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.
|
|
54
|
+
"bun": ">=1.4.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@bakery-framework/core": "^
|
|
57
|
+
"@bakery-framework/core": "^2.0.0-alpha.11"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -35,10 +35,21 @@ export namespace SQLAdapter {
|
|
|
35
35
|
}
|
|
36
36
|
export interface TableDetails {
|
|
37
37
|
name: string
|
|
38
|
-
|
|
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(
|
|
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
|
|
@@ -768,14 +781,6 @@ export abstract class SQLAdapter {
|
|
|
768
781
|
})
|
|
769
782
|
}
|
|
770
783
|
|
|
771
|
-
/**
|
|
772
|
-
* A `CHECK (col IN (…))` clause restricting a column to a set of values.
|
|
773
|
-
*
|
|
774
|
-
* Takes the column name because a CHECK has to name it, which is why
|
|
775
|
-
* `colDef` grew an optional `column` argument. Values bind nowhere — this is
|
|
776
|
-
* DDL — so they are quoted the same way `formatDefault` quotes a string
|
|
777
|
-
* default, by doubling the single quote.
|
|
778
|
-
*/
|
|
779
784
|
/**
|
|
780
785
|
* The declared width of a **sized** text column, or `undefined`.
|
|
781
786
|
*
|
|
@@ -804,6 +809,13 @@ export abstract class SQLAdapter {
|
|
|
804
809
|
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
805
810
|
}
|
|
806
811
|
|
|
812
|
+
/**
|
|
813
|
+
* A `CHECK (col IN (…))` clause restricting a column to a set of values.
|
|
814
|
+
*
|
|
815
|
+
* Takes the column name because a CHECK has to name it. Values bind nowhere —
|
|
816
|
+
* this is DDL — so they are quoted the same way `formatDefault` quotes a
|
|
817
|
+
* string default, by doubling the single quote.
|
|
818
|
+
*/
|
|
807
819
|
protected enumClause(column: string, values: string[]): string {
|
|
808
820
|
const list = values
|
|
809
821
|
.map(v => `'${String(v).replaceAll("'", "''")}'`)
|
|
@@ -850,20 +862,68 @@ export abstract class SQLAdapter {
|
|
|
850
862
|
return new DatabaseStatement(this, sqlText)
|
|
851
863
|
}
|
|
852
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
|
+
*/
|
|
853
915
|
protected buildFilterSort(
|
|
854
916
|
options: SQLAdapter.FilterSortOptions,
|
|
855
917
|
validCols: Set<string>,
|
|
856
918
|
) {
|
|
857
919
|
const whereParams: unknown[] = []
|
|
858
|
-
const whereClauses =
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
)
|
|
863
|
-
.
|
|
864
|
-
|
|
865
|
-
return `${this.quote(col)} LIKE ?`
|
|
866
|
-
})
|
|
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
|
+
}
|
|
867
927
|
|
|
868
928
|
const whereSql = whereClauses.length
|
|
869
929
|
? ` WHERE ${whereClauses.join(' AND ')}`
|
|
@@ -881,6 +941,67 @@ export abstract class SQLAdapter {
|
|
|
881
941
|
return { whereSql, orderSql, whereParams }
|
|
882
942
|
}
|
|
883
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
|
+
|
|
884
1005
|
protected formatDefault(
|
|
885
1006
|
def: unknown,
|
|
886
1007
|
boolTrue: string,
|
package/src/adapters/mysql.ts
CHANGED
|
@@ -270,7 +270,9 @@ export class MySQLAdapter extends SQLAdapter {
|
|
|
270
270
|
return new MySQLAdapter(sql as SQL)
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
async getSchema(
|
|
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
|
-
|
|
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
|
)
|
package/src/adapters/pgsql.ts
CHANGED
|
@@ -60,13 +60,16 @@ export class PGAdapter extends SQLAdapter {
|
|
|
60
60
|
nextChar: string | undefined,
|
|
61
61
|
state: PGSQLParserState,
|
|
62
62
|
): string | null {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
this.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
'
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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:
|
|
468
|
-
|
|
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
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
}
|
package/src/orm/index.ts
CHANGED
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* `TypeError: undefined is not an object (evaluating 'Mutation.Insert')`.
|
|
13
13
|
*
|
|
14
14
|
* Biome's `organizeImports` sorts these alphabetically and puts `./mutation`
|
|
15
|
-
* first.
|
|
16
|
-
*
|
|
17
|
-
* suppression.
|
|
15
|
+
* first. The compiler cannot see the breakage, because the types are fine
|
|
16
|
+
* either way. Hence the suppression.
|
|
18
17
|
*/
|
|
19
18
|
// biome-ignore-all assist/source/organizeImports: cycle — see above
|
|
20
19
|
import { DB } from './query'
|
package/src/orm/query.ts
CHANGED
|
@@ -1072,6 +1072,16 @@ export namespace DB {
|
|
|
1072
1072
|
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
1073
1073
|
return `${left} ${op}`
|
|
1074
1074
|
}
|
|
1075
|
+
// A bound NULL can never satisfy `=` or `<>` — three-valued logic makes
|
|
1076
|
+
// both comparisons UNKNOWN for every row, so `where(col, null)` silently
|
|
1077
|
+
// matched nothing while cheerfully reporting success. The caller who
|
|
1078
|
+
// passes an explicit null means the SQL spelling of it. Only literal
|
|
1079
|
+
// values: a column reference on the right is a different comparison and
|
|
1080
|
+
// is left alone.
|
|
1081
|
+
if (rightArg === null && !isRightColumn) {
|
|
1082
|
+
if (op === '=') return `${left} IS NULL`
|
|
1083
|
+
if (op === '!=' || op === '<>') return `${left} IS NOT NULL`
|
|
1084
|
+
}
|
|
1075
1085
|
if (op === 'BETWEEN' && Array.isArray(rightArg)) {
|
|
1076
1086
|
const min = evalOperands(rightArg[0], params, false)
|
|
1077
1087
|
const max = evalOperands(rightArg[1], params, false)
|
package/src/schema-registry.ts
CHANGED
|
@@ -36,18 +36,15 @@ import type { MapOf } from '@bakery-framework/core/types'
|
|
|
36
36
|
|
|
37
37
|
// Augmented by the app; empty until then.
|
|
38
38
|
//
|
|
39
|
-
// **It has to be an `interface`, and the empty body is the point.** Biome's
|
|
40
39
|
// **`interface`, not `type`, and the empty body is the point.** A type alias
|
|
41
40
|
// cannot be declaration-merged, so rewriting this to `type X = {}` turns every
|
|
42
41
|
// app's `declare module '@bakery-framework/orm/schema-registry'` into
|
|
43
42
|
// `TS2300: Duplicate identifier` and the whole schema registry stops working.
|
|
44
43
|
//
|
|
45
|
-
// Biome's `noBannedTypes`
|
|
46
|
-
// fix
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// offers the same fix, so the suppression stays. The compiler is the real
|
|
50
|
-
// guard: change this line and `apps/starter` fails to typecheck.
|
|
44
|
+
// Biome's `noBannedTypes` proposes exactly that rewrite and calls it a safe
|
|
45
|
+
// fix. That rule is off repo-wide now, but `noEmptyInterface` reaches the same
|
|
46
|
+
// construct and offers the same fix, so the suppression stays. The compiler is
|
|
47
|
+
// the real guard: change this line and `apps/starter` fails to typecheck.
|
|
51
48
|
// biome-ignore lint/suspicious/noEmptyInterface: must stay mergeable — above
|
|
52
49
|
export interface SchemaRegistry {}
|
|
53
50
|
|
package/src/sync/builder.ts
CHANGED
|
@@ -503,21 +503,6 @@ ${body}`
|
|
|
503
503
|
return `${result} } as const;\n`
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
/**
|
|
507
|
-
* The `orm/` folder layout's `schema.ts`: `table()` values and nothing else.
|
|
508
|
-
*
|
|
509
|
-
* The generator only ever emitted the `DBInfo` namespace, and for a folder
|
|
510
|
-
* project the write target is `orm/schema.ts` — which `orm/index.ts`
|
|
511
|
-
* re-exports. So a regeneration replaced every `table()` with a namespace
|
|
512
|
-
* *and* added a second `declare module '@bakery-framework/orm/schema-registry'` block
|
|
513
|
-
* colliding with the one in `index.ts`. It fired on `--choose=db` and, less
|
|
514
|
-
* visibly, after any sync involving `old()` wrappers.
|
|
515
|
-
*
|
|
516
|
-
* Tables only, deliberately: `index.ts` owns the re-exports and the schema
|
|
517
|
-
* registration, `indexes.ts` owns the index and unique declarations, and
|
|
518
|
-
* neither is the generator's to rewrite. That separation is the reason the
|
|
519
|
-
* folder layout exists (see `load.ts`).
|
|
520
|
-
*/
|
|
521
506
|
/**
|
|
522
507
|
* The constraints key a `_references.table` names, or `undefined`.
|
|
523
508
|
*
|
|
@@ -604,6 +589,16 @@ ${body}`
|
|
|
604
589
|
: `Field.Foreign(${target})`
|
|
605
590
|
}
|
|
606
591
|
|
|
592
|
+
/**
|
|
593
|
+
* The `orm/` folder layout's `schema.ts`: `table()` values and nothing else.
|
|
594
|
+
*
|
|
595
|
+
* Tables only, deliberately: `index.ts` owns the re-exports and the schema
|
|
596
|
+
* registration, `indexes.ts` owns the index and unique declarations, and
|
|
597
|
+
* neither is the generator's to rewrite. Emitting the single-file `DBInfo`
|
|
598
|
+
* namespace here instead would clobber both and leave two colliding
|
|
599
|
+
* `declare module '@bakery-framework/orm/schema-registry'` blocks. That
|
|
600
|
+
* separation is the reason the folder layout exists (see `load.ts`).
|
|
601
|
+
*/
|
|
607
602
|
private static buildTableModule(
|
|
608
603
|
constraints: Record<string, any>,
|
|
609
604
|
adapter: SQLAdapter,
|