@bakery-framework/orm 2.0.0-alpha.1 → 2.0.0-alpha.12
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 +133 -11
- package/src/adapters/mysql.ts +11 -4
- package/src/adapters/pgsql.ts +76 -57
- package/src/adapters/sqlite.ts +64 -36
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +13 -6
- package/src/sync/execute.ts +522 -0
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +62 -0
- 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": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.12",
|
|
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.12"
|
|
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
|
|
@@ -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 =
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
)
|
|
862
|
-
.
|
|
863
|
-
|
|
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,
|
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
|
}
|