@bakery-framework/orm 2.0.0-alpha.5 → 2.0.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/adapters/base.ts +119 -9
- package/src/adapters/pgsql.ts +56 -44
- package/src/adapters/sqlite.ts +44 -23
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.7",
|
|
4
4
|
"description": "Bakery database layer: adapters, query builder, schema sync and backup.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"bun": ">=1.3.14"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@bakery-framework/core": "^2.0.0-alpha.
|
|
57
|
+
"@bakery-framework/core": "^2.0.0-alpha.7"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -849,20 +849,69 @@ export abstract class SQLAdapter {
|
|
|
849
849
|
return new DatabaseStatement(this, sqlText)
|
|
850
850
|
}
|
|
851
851
|
|
|
852
|
+
/**
|
|
853
|
+
* The `LIKE` escape character — `!`, and deliberately **not** a backslash.
|
|
854
|
+
*
|
|
855
|
+
* A backslash is the obvious choice and is unusable here. MySQL processes
|
|
856
|
+
* backslash escapes inside string literals where SQLite and Postgres do not,
|
|
857
|
+
* so the clause would need to differ per dialect; and worse, Postgres never
|
|
858
|
+
* receives what is written. `normalizePostgresSQL` treats `\'` as an escaped
|
|
859
|
+
* quote — MySQL's rule, applied to every dialect — so `ESCAPE '\'` leaves the
|
|
860
|
+
* string literal *open*, swallows the rest of the statement, and the driver
|
|
861
|
+
* reports `syntax error at or near "OFFSET"` from a clause several tokens
|
|
862
|
+
* later.
|
|
863
|
+
*
|
|
864
|
+
* That normalizer behaviour is a real defect and is not fixed here: a test
|
|
865
|
+
* pins it (`a backslash escape keeps the character it escapes`), and changing
|
|
866
|
+
* it would change every literal the framework emits. Choosing a character
|
|
867
|
+
* with no meaning to any of the three parsers sidesteps it entirely, and one
|
|
868
|
+
* spelling for all dialects beats a capability getter per dialect.
|
|
869
|
+
*/
|
|
870
|
+
protected readonly likeEscape = '!'
|
|
871
|
+
|
|
872
|
+
get likeEscapeClause(): string {
|
|
873
|
+
return ` ESCAPE '${this.likeEscape}'`
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Make a value match literally under `LIKE`.
|
|
878
|
+
*
|
|
879
|
+
* Without this a search for `50%` matches every row and one for `a_b` matches
|
|
880
|
+
* `axb` — `%` and `_` are the wildcards, and the filter passed user input
|
|
881
|
+
* through untouched. The escape character itself goes first, or escaping it
|
|
882
|
+
* afterwards would double the ones this method just added.
|
|
883
|
+
*/
|
|
884
|
+
protected escapeLike(value: string): string {
|
|
885
|
+
const e = this.likeEscape
|
|
886
|
+
return value
|
|
887
|
+
.split(e)
|
|
888
|
+
.join(e + e)
|
|
889
|
+
.replace(/[%_]/g, m => `${e}${m}`)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* `WHERE` and `ORDER BY` for a browsable table listing.
|
|
894
|
+
*
|
|
895
|
+
* A filter is either a **bare scalar**, which means `contains` and is what
|
|
896
|
+
* every caller sent before operators existed, or **`{op, value}`**. Keeping
|
|
897
|
+
* the scalar form meaningful is not politeness: `getData` is public and the
|
|
898
|
+
* dashboard still calls it that way.
|
|
899
|
+
*
|
|
900
|
+
* Columns are intersected with the real column set by the caller, so an
|
|
901
|
+
* unknown column disappears rather than reaching SQL. Values always bind.
|
|
902
|
+
*/
|
|
852
903
|
protected buildFilterSort(
|
|
853
904
|
options: SQLAdapter.FilterSortOptions,
|
|
854
905
|
validCols: Set<string>,
|
|
855
906
|
) {
|
|
856
907
|
const whereParams: unknown[] = []
|
|
857
|
-
const whereClauses =
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
)
|
|
862
|
-
.
|
|
863
|
-
|
|
864
|
-
return `${this.quote(col)} LIKE ?`
|
|
865
|
-
})
|
|
908
|
+
const whereClauses: string[] = []
|
|
909
|
+
|
|
910
|
+
for (const [col, raw] of Object.entries(options.filters || {})) {
|
|
911
|
+
if (!validCols.has(col)) continue
|
|
912
|
+
const clause = this.filterClause(col, raw, whereParams)
|
|
913
|
+
if (clause) whereClauses.push(clause)
|
|
914
|
+
}
|
|
866
915
|
|
|
867
916
|
const whereSql = whereClauses.length
|
|
868
917
|
? ` WHERE ${whereClauses.join(' AND ')}`
|
|
@@ -880,6 +929,67 @@ export abstract class SQLAdapter {
|
|
|
880
929
|
return { whereSql, orderSql, whereParams }
|
|
881
930
|
}
|
|
882
931
|
|
|
932
|
+
/**
|
|
933
|
+
* One filter, as SQL. Returns `null` when the filter says nothing.
|
|
934
|
+
*
|
|
935
|
+
* `params` is appended to rather than returned, because an operator may bind
|
|
936
|
+
* one value, two, or none at all — `IS NULL` has nothing to bind, and
|
|
937
|
+
* pretending otherwise is how a placeholder count drifts from its arguments.
|
|
938
|
+
*/
|
|
939
|
+
private filterClause(
|
|
940
|
+
col: string,
|
|
941
|
+
raw: unknown,
|
|
942
|
+
params: unknown[],
|
|
943
|
+
): string | null {
|
|
944
|
+
const quoted = this.quote(col)
|
|
945
|
+
|
|
946
|
+
// The pre-operator form. An empty string means "no filter" here, which is
|
|
947
|
+
// what a cleared text box sends and what every caller has relied on.
|
|
948
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') {
|
|
949
|
+
if (raw === undefined || raw === null || raw === '') return null
|
|
950
|
+
params.push(`%${this.escapeLike(String(raw))}%`)
|
|
951
|
+
return `${quoted} LIKE ?${this.likeEscapeClause}`
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const { op, value } = raw as { op?: string; value?: unknown }
|
|
955
|
+
|
|
956
|
+
// No value to bind, and none expected — these two are the whole reason a
|
|
957
|
+
// filter cannot be modelled as a plain column/value pair.
|
|
958
|
+
if (op === 'null') return `${quoted} IS NULL`
|
|
959
|
+
if (op === 'notnull') return `${quoted} IS NOT NULL`
|
|
960
|
+
|
|
961
|
+
if (value === undefined || value === null) return null
|
|
962
|
+
|
|
963
|
+
const comparison: Record<string, string> = {
|
|
964
|
+
eq: '=',
|
|
965
|
+
ne: '<>',
|
|
966
|
+
gt: '>',
|
|
967
|
+
gte: '>=',
|
|
968
|
+
lt: '<',
|
|
969
|
+
lte: '<=',
|
|
970
|
+
}
|
|
971
|
+
if (op && comparison[op]) {
|
|
972
|
+
params.push(value)
|
|
973
|
+
return `${quoted} ${comparison[op]} ?`
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const pattern: Record<string, (v: string) => string> = {
|
|
977
|
+
contains: v => `%${v}%`,
|
|
978
|
+
starts: v => `${v}%`,
|
|
979
|
+
ends: v => `%${v}`,
|
|
980
|
+
}
|
|
981
|
+
const shape = op ? pattern[op] : undefined
|
|
982
|
+
if (shape) {
|
|
983
|
+
params.push(shape(this.escapeLike(String(value))))
|
|
984
|
+
return `${quoted} LIKE ?${this.likeEscapeClause}`
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// An operator this dialect does not know is dropped rather than guessed
|
|
988
|
+
// at. Guessing would mean answering a question nobody asked, and the
|
|
989
|
+
// caller validates the vocabulary before it gets here.
|
|
990
|
+
return null
|
|
991
|
+
}
|
|
992
|
+
|
|
883
993
|
protected formatDefault(
|
|
884
994
|
def: unknown,
|
|
885
995
|
boolTrue: string,
|
package/src/adapters/pgsql.ts
CHANGED
|
@@ -253,50 +253,62 @@ export class PGAdapter extends SQLAdapter {
|
|
|
253
253
|
" WHERE table_schema NOT IN ('pg_catalog', 'information_schema')" +
|
|
254
254
|
' ORDER BY table_name',
|
|
255
255
|
).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
|
-
|
|
256
|
+
// One wave rather than N. The four queries per table were already
|
|
257
|
+
// concurrent; the loop around them awaited each table in turn, so a
|
|
258
|
+
// 20-table schema cost 20 sequential round-trip waves. See the note in
|
|
259
|
+
// sqlite.ts about the COUNT(*), which is a full scan here too.
|
|
260
|
+
return await Promise.all(
|
|
261
|
+
res.map(async t => {
|
|
262
|
+
const qName = this.quote(t.name)
|
|
263
|
+
const [countRes, cols, pkCols, idxs] = (await Promise.all([
|
|
264
|
+
this.query(`SELECT COUNT(*)::int as count FROM ${qName}`).get(),
|
|
265
|
+
this.query(
|
|
266
|
+
'SELECT column_name AS name, data_type AS type,' +
|
|
267
|
+
' is_nullable AS is_nullable' +
|
|
268
|
+
' FROM information_schema.columns' +
|
|
269
|
+
' WHERE table_name = ?' +
|
|
270
|
+
" AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
|
|
271
|
+
' ORDER BY ordinal_position',
|
|
272
|
+
).all(t.name),
|
|
273
|
+
// `::regclass` casts a **string**, so the table name binds as a
|
|
274
|
+
// parameter. It used to interpolate `qName` — a double-quoted
|
|
275
|
+
// *identifier* — which Postgres reads as a column reference, so this
|
|
276
|
+
// threw `column "<table>" does not exist` for every table and took the
|
|
277
|
+
// whole of `getSchema()` down with it on this dialect.
|
|
278
|
+
//
|
|
279
|
+
// The quiet case was worse than the loud one: a table with a column of
|
|
280
|
+
// the same name resolved, casting that column's *value* to a regclass
|
|
281
|
+
// and reporting some other table's primary key as this one's.
|
|
282
|
+
this.query(
|
|
283
|
+
'SELECT a.attname AS name' +
|
|
284
|
+
' FROM pg_index i' +
|
|
285
|
+
' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
|
|
286
|
+
' AND a.attnum = ANY(i.indkey)' +
|
|
287
|
+
' WHERE i.indisprimary AND i.indrelid = ?::regclass',
|
|
288
|
+
).all(t.name),
|
|
289
|
+
this.query(
|
|
290
|
+
'SELECT indexname AS name, indexdef AS def' +
|
|
291
|
+
' FROM pg_indexes' +
|
|
292
|
+
" WHERE schemaname NOT IN ('pg_catalog', 'information_schema')" +
|
|
293
|
+
' AND tablename = ?',
|
|
294
|
+
).all(t.name),
|
|
295
|
+
])) as [SQLAdapter.CountRow, any[], any[], any[]]
|
|
296
|
+
return {
|
|
297
|
+
name: t.name,
|
|
298
|
+
rowCount: countRes?.count || 0,
|
|
299
|
+
columns: cols.map(c => ({
|
|
300
|
+
name: c.name,
|
|
301
|
+
type: c.type,
|
|
302
|
+
notnull: c.is_nullable === 'NO',
|
|
303
|
+
pk: pkCols.some(pk => pk.name === c.name),
|
|
304
|
+
})),
|
|
305
|
+
indexes: idxs.map(i => ({
|
|
306
|
+
name: i.name,
|
|
307
|
+
unique: /UNIQUE/i.test(i.def),
|
|
308
|
+
})),
|
|
309
|
+
}
|
|
310
|
+
}),
|
|
311
|
+
)
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
async getData(
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -287,29 +287,50 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
287
287
|
'SELECT name FROM sqlite_master' +
|
|
288
288
|
" WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
289
289
|
).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
|
-
|
|
290
|
+
// Per table rather than per wave: the three queries below were already
|
|
291
|
+
// concurrent, but the loop around them awaited each table in turn, so a
|
|
292
|
+
// 20-table schema cost 20 sequential round-trip waves. They share nothing,
|
|
293
|
+
// so the whole fan-out is one wave now.
|
|
294
|
+
//
|
|
295
|
+
// Worth knowing before this grows: the `COUNT(*)` is a full scan on this
|
|
296
|
+
// dialect and on Postgres, and it is paid on every caller of `getSchema()`
|
|
297
|
+
// even though only a schema *listing* displays the number. Making it
|
|
298
|
+
// optional means `rowCount` has to admit it can be absent, which is a type
|
|
299
|
+
// change across every consumer — recorded rather than done here.
|
|
300
|
+
return await Promise.all(
|
|
301
|
+
res.map(async t => {
|
|
302
|
+
const tableName = this.quote(t.name)
|
|
303
|
+
|
|
304
|
+
const [countRes, cols, idxs] = (await Promise.all([
|
|
305
|
+
this.query(`SELECT COUNT(*) as count FROM ${tableName}`).get(),
|
|
306
|
+
this.query(`PRAGMA table_info(${tableName})`).all(),
|
|
307
|
+
this.query(`PRAGMA index_list(${tableName})`).all(),
|
|
308
|
+
])) as [SQLAdapter.CountRow, any[], any[]]
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
name: t.name,
|
|
312
|
+
rowCount: countRes?.count || 0,
|
|
313
|
+
columns: cols.map(c => ({
|
|
314
|
+
name: c.name,
|
|
315
|
+
type: c.type,
|
|
316
|
+
notnull: c.notnull === 1,
|
|
317
|
+
// `pk` is the column's **1-based position within the primary key**,
|
|
318
|
+
// not a boolean — `PRAGMA table_info` reports 0 for "not part of the
|
|
319
|
+
// key", 1 for the first key column, 2 for the second. So `=== 1`
|
|
320
|
+
// reported a composite `PRIMARY KEY (a, b)` as a single-column key on
|
|
321
|
+
// `a`, silently, and only on SQLite: MySQL reads `column_key = 'PRI'`
|
|
322
|
+
// and Postgres reads `pg_index.indisprimary`, both of which are set on
|
|
323
|
+
// every member.
|
|
324
|
+
//
|
|
325
|
+
// `parseConstraints` a few hundred lines down already had this right
|
|
326
|
+
// (`col.pk > 0`), which is why `getConstraints()` disagreed with
|
|
327
|
+
// `getSchema()` about the same table.
|
|
328
|
+
pk: c.pk > 0,
|
|
329
|
+
})),
|
|
330
|
+
indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
|
|
331
|
+
}
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
313
334
|
}
|
|
314
335
|
|
|
315
336
|
async getData(
|