@metaobjectsdev/runtime-ts 1.0.9 → 1.0.10
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/README.md +1 -1
- package/dist/constraint-errors.d.ts.map +1 -1
- package/dist/constraint-errors.js +3 -1
- package/dist/constraint-errors.js.map +1 -1
- package/dist/drivers/drizzle-driver.d.ts +2 -1
- package/dist/drivers/drizzle-driver.d.ts.map +1 -1
- package/dist/drivers/drizzle-driver.js +44 -0
- package/dist/drivers/drizzle-driver.js.map +1 -1
- package/dist/drivers/kysely-driver.d.ts +3 -1
- package/dist/drivers/kysely-driver.d.ts.map +1 -1
- package/dist/drivers/kysely-driver.js +37 -4
- package/dist/drivers/kysely-driver.js.map +1 -1
- package/dist/drivers/mysql-support.d.ts +19 -0
- package/dist/drivers/mysql-support.d.ts.map +1 -0
- package/dist/drivers/mysql-support.js +64 -0
- package/dist/drivers/mysql-support.js.map +1 -0
- package/dist/drizzle-fastify/filter-parser.d.ts +2 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +11 -4
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +2 -1
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +23 -30
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts +2 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +9 -3
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/util.d.ts +6 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +28 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/extract-object.d.ts.map +1 -1
- package/dist/extract-object.js +13 -2
- package/dist/extract-object.js.map +1 -1
- package/dist/hono/index.d.ts +2 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +21 -30
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts +2 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +9 -3
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +2 -1
- package/dist/object-manager.js.map +1 -1
- package/dist/persistence-driver.d.ts +8 -1
- package/dist/persistence-driver.d.ts.map +1 -1
- package/dist/query-builder.d.ts +2 -0
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +5 -0
- package/dist/query-builder.js.map +1 -1
- package/dist/sql-dialect.d.ts +11 -0
- package/dist/sql-dialect.d.ts.map +1 -0
- package/dist/sql-dialect.js +16 -0
- package/dist/sql-dialect.js.map +1 -0
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +63 -4
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +102 -73
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/constraint-errors.ts +3 -1
- package/src/drivers/drizzle-driver.ts +47 -4
- package/src/drivers/kysely-driver.ts +43 -7
- package/src/drivers/mysql-support.ts +77 -0
- package/src/drizzle-fastify/filter-parser.ts +16 -7
- package/src/drizzle-fastify/index.ts +23 -26
- package/src/drizzle-fastify/mount-read-only.ts +10 -4
- package/src/drizzle-fastify/util.ts +23 -0
- package/src/extract-object.ts +18 -0
- package/src/hono/index.ts +21 -26
- package/src/hono/mount-read-only.ts +10 -4
- package/src/object-manager.ts +2 -1
- package/src/persistence-driver.ts +8 -1
- package/src/query-builder.ts +9 -0
- package/src/sql-dialect.ts +21 -0
- package/src/type-coercer.ts +55 -3
- package/src/validator-runner.ts +100 -70
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
UpdateSpec, UpdateManySpec, DeleteSpec, DeleteManySpec, WhereClause, Row,
|
|
5
5
|
} from "../persistence-driver.js";
|
|
6
6
|
import { ConstraintViolationError } from "../errors.js";
|
|
7
|
+
import { arraysAsJsonText, insertedKey, mysqlConstraintKind } from "./mysql-support.js";
|
|
7
8
|
|
|
8
9
|
// Kysely's fluent builder is heavily generic on the database schema, but this driver
|
|
9
10
|
// is metadata-driven and accepts any table name at runtime. Confine the schema-agnostic
|
|
@@ -17,7 +18,9 @@ type AnyQuery = SelectQueryBuilder<any, any, any>;
|
|
|
17
18
|
|
|
18
19
|
export interface KyselyDriverOptions {
|
|
19
20
|
db: Kysely<Record<string, Row>>;
|
|
20
|
-
|
|
21
|
+
/** `mysql`: build the Kysely instance with `MysqlDialect` over a mysql2 pool created with
|
|
22
|
+
* `timezone: "Z"` (DATETIME then holds the UTC wall clock). */
|
|
23
|
+
dialect: "sqlite" | "postgres" | "mysql";
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface KyselyDriverPublic extends PersistenceDriver {
|
|
@@ -31,8 +34,24 @@ export function kyselyDriver(opts: KyselyDriverOptions): KyselyDriverPublic {
|
|
|
31
34
|
|
|
32
35
|
function makeKyselyDriver(
|
|
33
36
|
db: Kysely<Record<string, Row>>,
|
|
34
|
-
dialect: "sqlite" | "postgres",
|
|
37
|
+
dialect: "sqlite" | "postgres" | "mysql",
|
|
35
38
|
): KyselyDriverPublic {
|
|
39
|
+
// Neither SQLite nor MySQL has an array type: a scalar array is bound as JSON text.
|
|
40
|
+
const bindable = (values: Row): Row => (dialect === "postgres" ? values : arraysAsJsonText(values));
|
|
41
|
+
|
|
42
|
+
// MySQL has no RETURNING: insert, then read the row back by its key.
|
|
43
|
+
async function insertAndReadBack(table: string, values: Row, returning: string[], key: string[] | undefined): Promise<Row> {
|
|
44
|
+
const result = await (db as RawKysely).insertInto(table).values(bindable(values)).executeTakeFirst() as
|
|
45
|
+
{ insertId?: bigint | number } | undefined;
|
|
46
|
+
const keyValues = key !== undefined && key.length > 0 ? insertedKey(key, values, result?.insertId) : undefined;
|
|
47
|
+
if (keyValues === undefined) {
|
|
48
|
+
throw new Error(`kyselyDriver.insert: cannot read back the row inserted into '${table}' (no primary-key value)`);
|
|
49
|
+
}
|
|
50
|
+
let q = (db as RawKysely).selectFrom(table).select(returning);
|
|
51
|
+
for (const [col, v] of Object.entries(keyValues)) q = q.where(col, "=", v);
|
|
52
|
+
return (await q.executeTakeFirstOrThrow()) as Row;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
return {
|
|
37
56
|
db,
|
|
38
57
|
dialect,
|
|
@@ -62,9 +81,10 @@ function makeKyselyDriver(
|
|
|
62
81
|
|
|
63
82
|
async insert(spec: InsertSpec): Promise<Row> {
|
|
64
83
|
try {
|
|
84
|
+
if (dialect === "mysql") return await insertAndReadBack(spec.table, spec.values, spec.returning, spec.key);
|
|
65
85
|
const result = await (db as RawKysely)
|
|
66
86
|
.insertInto(spec.table)
|
|
67
|
-
.values(spec.values)
|
|
87
|
+
.values(bindable(spec.values))
|
|
68
88
|
.returning(spec.returning)
|
|
69
89
|
.executeTakeFirstOrThrow();
|
|
70
90
|
return result as Row;
|
|
@@ -75,9 +95,14 @@ function makeKyselyDriver(
|
|
|
75
95
|
|
|
76
96
|
async insertMany(spec: InsertManySpec): Promise<Row[]> {
|
|
77
97
|
try {
|
|
98
|
+
if (dialect === "mysql") {
|
|
99
|
+
const out: Row[] = [];
|
|
100
|
+
for (const row of spec.rows) out.push(await insertAndReadBack(spec.table, row, spec.returning, spec.key));
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
78
103
|
const rows = await (db as RawKysely)
|
|
79
104
|
.insertInto(spec.table)
|
|
80
|
-
.values(spec.rows)
|
|
105
|
+
.values(spec.rows.map(bindable))
|
|
81
106
|
.returning(spec.returning)
|
|
82
107
|
.execute();
|
|
83
108
|
return rows as Row[];
|
|
@@ -88,8 +113,14 @@ function makeKyselyDriver(
|
|
|
88
113
|
|
|
89
114
|
async update(spec: UpdateSpec): Promise<Row | null> {
|
|
90
115
|
try {
|
|
91
|
-
let q = (db as RawKysely).updateTable(spec.table).set(spec.values);
|
|
116
|
+
let q = (db as RawKysely).updateTable(spec.table).set(bindable(spec.values));
|
|
92
117
|
q = applyWhere(q, spec.where);
|
|
118
|
+
if (dialect === "mysql") {
|
|
119
|
+
await q.execute();
|
|
120
|
+
let s = (db as RawKysely).selectFrom(spec.table).select(spec.returning);
|
|
121
|
+
s = applyWhere(s, spec.where);
|
|
122
|
+
return ((await s.executeTakeFirst()) as Row | undefined) ?? null;
|
|
123
|
+
}
|
|
93
124
|
const rows = await q.returning(spec.returning).execute();
|
|
94
125
|
return ((rows as Row[])[0]) ?? null;
|
|
95
126
|
} catch (err) {
|
|
@@ -99,7 +130,7 @@ function makeKyselyDriver(
|
|
|
99
130
|
|
|
100
131
|
async updateMany(spec: UpdateManySpec): Promise<number> {
|
|
101
132
|
try {
|
|
102
|
-
let q = (db as RawKysely).updateTable(spec.table).set(spec.values);
|
|
133
|
+
let q = (db as RawKysely).updateTable(spec.table).set(bindable(spec.values));
|
|
103
134
|
q = applyWhere(q, spec.where);
|
|
104
135
|
const result = await q.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined;
|
|
105
136
|
return Number(result?.numUpdatedRows ?? 0);
|
|
@@ -185,11 +216,16 @@ function applyOrderLimit(
|
|
|
185
216
|
return out;
|
|
186
217
|
}
|
|
187
218
|
|
|
188
|
-
function mapDriverError(err: unknown, table: string, dialect: "sqlite" | "postgres"): unknown {
|
|
219
|
+
function mapDriverError(err: unknown, table: string, dialect: "sqlite" | "postgres" | "mysql"): unknown {
|
|
189
220
|
if (!(err instanceof Error)) return err;
|
|
190
221
|
const msg = err.message;
|
|
191
222
|
const code = (err as { code?: string }).code;
|
|
192
223
|
|
|
224
|
+
if (dialect === "mysql") {
|
|
225
|
+
const kind = mysqlConstraintKind(err);
|
|
226
|
+
return kind === null ? err : new ConstraintViolationError(msg, { kind, table, cause: err });
|
|
227
|
+
}
|
|
228
|
+
|
|
193
229
|
if (dialect === "sqlite") {
|
|
194
230
|
const kind = sqliteConstraintKind(code, msg);
|
|
195
231
|
if (kind !== null) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// MySQL support shared by the Kysely and Drizzle ObjectManager drivers.
|
|
2
|
+
//
|
|
3
|
+
// MySQL has no RETURNING, so a driver writes and then reads the row back:
|
|
4
|
+
// - insert: the key is the written key columns, plus the AUTO_INCREMENT id the server
|
|
5
|
+
// reports when the table has one and the caller did not supply it;
|
|
6
|
+
// - update: the row is re-selected with the same WHERE (absent → null).
|
|
7
|
+
// Constraint errors are recognised by mysql2's error `code`, walked through any wrapper's
|
|
8
|
+
// `cause` chain (Drizzle wraps driver errors).
|
|
9
|
+
import type { Row } from "../persistence-driver.js";
|
|
10
|
+
|
|
11
|
+
export type ConstraintKindName = "unique" | "foreign_key" | "not_null" | "check";
|
|
12
|
+
|
|
13
|
+
const MYSQL_CODES: Record<string, ConstraintKindName> = {
|
|
14
|
+
ER_DUP_ENTRY: "unique",
|
|
15
|
+
ER_NO_REFERENCED_ROW_2: "foreign_key",
|
|
16
|
+
ER_ROW_IS_REFERENCED_2: "foreign_key",
|
|
17
|
+
ER_NO_REFERENCED_ROW: "foreign_key",
|
|
18
|
+
ER_ROW_IS_REFERENCED: "foreign_key",
|
|
19
|
+
ER_BAD_NULL_ERROR: "not_null",
|
|
20
|
+
ER_CHECK_CONSTRAINT_VIOLATED: "check",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The constraint kind a mysql2 error (or a wrapper around one) reports, else null. */
|
|
24
|
+
export function mysqlConstraintKind(err: unknown): ConstraintKindName | null {
|
|
25
|
+
let cur: unknown = err;
|
|
26
|
+
for (let depth = 0; depth < 8 && typeof cur === "object" && cur !== null; depth++) {
|
|
27
|
+
const code = (cur as { code?: unknown }).code;
|
|
28
|
+
if (typeof code === "string" && code in MYSQL_CODES) return MYSQL_CODES[code]!;
|
|
29
|
+
const next = (cur as { cause?: unknown }).cause;
|
|
30
|
+
if (next === cur) break;
|
|
31
|
+
cur = next;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The key-column values of a row just inserted: every key column the caller wrote, and the
|
|
38
|
+
* server-generated AUTO_INCREMENT id for the one it did not. `undefined` when a key column
|
|
39
|
+
* has neither (the insert cannot be read back).
|
|
40
|
+
*/
|
|
41
|
+
export function insertedKey(
|
|
42
|
+
key: readonly string[],
|
|
43
|
+
values: Row,
|
|
44
|
+
insertId: number | bigint | undefined,
|
|
45
|
+
): Row | undefined {
|
|
46
|
+
const out: Row = {};
|
|
47
|
+
let usedInsertId = false;
|
|
48
|
+
for (const col of key) {
|
|
49
|
+
const v = values[col];
|
|
50
|
+
if (v !== undefined && v !== null) {
|
|
51
|
+
out[col] = v;
|
|
52
|
+
} else if (!usedInsertId && insertId !== undefined && Number(insertId) !== 0) {
|
|
53
|
+
out[col] = typeof insertId === "bigint" ? Number(insertId) : insertId;
|
|
54
|
+
usedInsertId = true;
|
|
55
|
+
} else {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A row a SQL driver without an array type can bind (SQLite, MySQL). mysql2 expands a JS
|
|
64
|
+
* array into a value LIST (`'x', 'y'`), breaking the column count, and libsql cannot bind one
|
|
65
|
+
* at all; a scalar array lives in a JSON (MySQL) or JSON-in-TEXT (SQLite) column, so it is sent
|
|
66
|
+
* as JSON text. Objects already arrive as JSON text from the type coercer. Drizzle's JSON
|
|
67
|
+
* column modes encode for themselves, so only the Kysely driver calls this.
|
|
68
|
+
*/
|
|
69
|
+
export function arraysAsJsonText(values: Row): Row {
|
|
70
|
+
let out: Row | null = null;
|
|
71
|
+
for (const [k, v] of Object.entries(values)) {
|
|
72
|
+
if (!Array.isArray(v)) continue;
|
|
73
|
+
out ??= { ...values };
|
|
74
|
+
out[k] = JSON.stringify(v);
|
|
75
|
+
}
|
|
76
|
+
return out ?? values;
|
|
77
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
eq, ne, gt, gte, lt, lte, inArray, like, ilike, isNull, not, and, or, asc, desc, sql,
|
|
4
4
|
type SQL, type SQLWrapper,
|
|
5
5
|
} from "drizzle-orm";
|
|
6
|
+
import type { SqlDialect } from "../sql-dialect.js";
|
|
6
7
|
import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
|
|
7
8
|
import { sortOrderSpec } from "./filter-allowlist.js";
|
|
8
9
|
import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.js";
|
|
@@ -24,7 +25,7 @@ export interface ParseFilterOpts {
|
|
|
24
25
|
table: AnyTable;
|
|
25
26
|
allowlist: FilterAllowlist;
|
|
26
27
|
sortAllowlist: SortAllowlist;
|
|
27
|
-
dialect:
|
|
28
|
+
dialect: SqlDialect;
|
|
28
29
|
maxNesting?: number;
|
|
29
30
|
maxInListSize?: number;
|
|
30
31
|
}
|
|
@@ -132,9 +133,13 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
|
|
|
132
133
|
// deliberately case-INSENSITIVE — it is a human search box, not the
|
|
133
134
|
// contract's `like` operator (which is case-sensitive SQL LIKE, ADR-0049).
|
|
134
135
|
// Postgres uses ILIKE; SQLite's native LIKE folds ASCII case by default,
|
|
135
|
-
// which is the intended behavior here.
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
// which is the intended behavior here. MySQL's LIKE follows the column's
|
|
137
|
+
// collation, which is case-insensitive by default and case-sensitive on a
|
|
138
|
+
// `_bin` column, so it folds both sides explicitly.
|
|
139
|
+
const parts = stringCols.map((col) =>
|
|
140
|
+
opts.dialect === "postgres" ? ilike(col, term)
|
|
141
|
+
: opts.dialect === "mysql" ? sql`LOWER(${col}) LIKE LOWER(${term})`
|
|
142
|
+
: like(col, term));
|
|
138
143
|
// or() is defined as returning SQL | undefined but will always return
|
|
139
144
|
// SQL when given a non-empty array. parts[0] is always defined here
|
|
140
145
|
// because stringCols.length > 0 guarantees at least one element.
|
|
@@ -150,7 +155,7 @@ function parseNode(
|
|
|
150
155
|
node: Record<string, unknown>,
|
|
151
156
|
table: AnyTable,
|
|
152
157
|
allowlist: FilterAllowlist,
|
|
153
|
-
dialect:
|
|
158
|
+
dialect: SqlDialect,
|
|
154
159
|
maxNesting: number,
|
|
155
160
|
maxInList: number,
|
|
156
161
|
depth: number,
|
|
@@ -203,7 +208,7 @@ function compileOp(
|
|
|
203
208
|
field: string,
|
|
204
209
|
op: string,
|
|
205
210
|
value: unknown,
|
|
206
|
-
dialect:
|
|
211
|
+
dialect: SqlDialect,
|
|
207
212
|
maxInList: number,
|
|
208
213
|
): SQL | undefined {
|
|
209
214
|
if (!rule.ops.includes(op as FilterOp)) {
|
|
@@ -234,9 +239,13 @@ function compileOp(
|
|
|
234
239
|
// default (and `PRAGMA case_sensitive_like` is connection-global on a
|
|
235
240
|
// consumer-owned connection), so the sqlite branch lowers to GLOB with
|
|
236
241
|
// an exactly-translated pattern instead.
|
|
242
|
+
// MySQL's LIKE follows the column's collation, which is case-INSENSITIVE by
|
|
243
|
+
// default, so the pattern carries an explicit binary collation.
|
|
237
244
|
return dialect === "postgres"
|
|
238
245
|
? like(col as any, s)
|
|
239
|
-
:
|
|
246
|
+
: dialect === "mysql"
|
|
247
|
+
? sql`${col} LIKE ${s} COLLATE utf8mb4_bin`
|
|
248
|
+
: sql`${col} GLOB ${likePatternToGlob(s)}`;
|
|
240
249
|
}
|
|
241
250
|
case "isNull": {
|
|
242
251
|
// isNull's value is always coerced as boolean (true/false), regardless of the
|
|
@@ -27,8 +27,9 @@ import qs from "qs";
|
|
|
27
27
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
28
28
|
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
29
29
|
import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
|
|
30
|
-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
|
|
30
|
+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js";
|
|
31
31
|
import { timestampWire } from "../timestamp-wire.js";
|
|
32
|
+
import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
|
|
32
33
|
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
33
34
|
import { validationErrorBody } from "../route-errors.js";
|
|
34
35
|
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
|
|
@@ -84,7 +85,7 @@ export interface CrudRoutesOptions {
|
|
|
84
85
|
filterAllowlist?: FilterAllowlist;
|
|
85
86
|
sortAllowlist?: SortAllowlist;
|
|
86
87
|
/** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
|
|
87
|
-
dialect?:
|
|
88
|
+
dialect?: SqlDialect;
|
|
88
89
|
/**
|
|
89
90
|
* FR-017 TPH — scope this route set to a single subtype of a single-table-
|
|
90
91
|
* inheritance base. When set:
|
|
@@ -280,15 +281,23 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
280
281
|
: parsed.data;
|
|
281
282
|
// A constraint the DB enforces (a foreign key from identity.reference, a unique
|
|
282
283
|
// index) must not surface as a 500 echoing the SQL and its bound parameters.
|
|
283
|
-
let
|
|
284
|
+
let row: unknown;
|
|
284
285
|
try {
|
|
285
|
-
|
|
286
|
+
if (supportsReturning(opts.dialect)) {
|
|
287
|
+
row = ((await opts.db.insert(opts.table).values(values).returning()) as unknown[])[0];
|
|
288
|
+
} else {
|
|
289
|
+
// MySQL has no RETURNING: take the key from the body (an assigned key) or from
|
|
290
|
+
// `$returningId()` (AUTO_INCREMENT / `$defaultFn`), then read the row back.
|
|
291
|
+
const [inserted] = (await opts.db.insert(opts.table).values(values).$returningId()) as
|
|
292
|
+
Array<Record<string, unknown> | undefined>;
|
|
293
|
+
const key = (values as Record<string, unknown>).id ?? inserted?.id;
|
|
294
|
+
row = key === undefined ? undefined : await firstRow(opts.db, opts.table, eq(opts.table.id, key));
|
|
295
|
+
}
|
|
286
296
|
} catch (e) {
|
|
287
297
|
const f = classifyConstraintError(e);
|
|
288
298
|
if (f === undefined) { logAndRedact(e); throw new RedactedDatabaseError(); }
|
|
289
299
|
return reply.code(f.status).send(f.body);
|
|
290
300
|
}
|
|
291
|
-
const row = (result as unknown[])[0];
|
|
292
301
|
// #214 — read-your-writes: re-read through the replica view so the response
|
|
293
302
|
// carries derived (origin.passthrough) columns the base table excludes.
|
|
294
303
|
return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
|
|
@@ -341,19 +350,21 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
341
350
|
return reply.code(400).send({ error: "invalid_id" });
|
|
342
351
|
}
|
|
343
352
|
const idCond = eq(opts.table.id, idValue);
|
|
344
|
-
|
|
353
|
+
const cond = discCond ? and(idCond, discCond) : idCond;
|
|
354
|
+
let row: unknown;
|
|
345
355
|
try {
|
|
346
|
-
|
|
347
|
-
.update(opts.table)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
356
|
+
if (supportsReturning(opts.dialect)) {
|
|
357
|
+
row = ((await opts.db.update(opts.table).set(data).where(cond).returning()) as unknown[])[0];
|
|
358
|
+
} else {
|
|
359
|
+
// MySQL has no RETURNING: update, then read the row back (absent → 404).
|
|
360
|
+
await opts.db.update(opts.table).set(data).where(cond);
|
|
361
|
+
row = await firstRow(opts.db, opts.table, cond);
|
|
362
|
+
}
|
|
351
363
|
} catch (e) {
|
|
352
364
|
const f = classifyConstraintError(e);
|
|
353
365
|
if (f === undefined) { logAndRedact(e); throw new RedactedDatabaseError(); }
|
|
354
366
|
return reply.code(f.status).send(f.body);
|
|
355
367
|
}
|
|
356
|
-
const row = (result as unknown[])[0];
|
|
357
368
|
if (row == null) return reply.code(404).send({ error: "not_found" });
|
|
358
369
|
// #214 — re-read through the replica view so the response carries derived columns.
|
|
359
370
|
return toWire(await reReadThroughView(opts, row));
|
|
@@ -408,19 +419,5 @@ export function mountDeleteRoute(opts: VerbOptions): void {
|
|
|
408
419
|
});
|
|
409
420
|
}
|
|
410
421
|
|
|
411
|
-
function extractRowCount(result: unknown): number {
|
|
412
|
-
if (typeof result === "number") return result;
|
|
413
|
-
if (Array.isArray(result)) return result.length;
|
|
414
|
-
if (result && typeof result === "object") {
|
|
415
|
-
const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
|
|
416
|
-
if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
|
|
417
|
-
if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
|
|
418
|
-
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
419
|
-
// bun:sqlite / better-sqlite3 run() result shape.
|
|
420
|
-
if (typeof obj.changes === "number") return obj.changes;
|
|
421
|
-
}
|
|
422
|
-
return 0;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
422
|
export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
|
|
426
423
|
export { mountM2mRoute, type M2mRouteOptions } from "./mount-m2m.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
2
|
+
import { quoteIdent, type SqlDialect } from "../sql-dialect.js";
|
|
2
3
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
3
4
|
import qs from "qs";
|
|
4
5
|
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js";
|
|
@@ -18,7 +19,7 @@ export interface MountReadOnlyOptions {
|
|
|
18
19
|
readonly view: AnyView;
|
|
19
20
|
readonly filterAllowlist: FilterAllowlist;
|
|
20
21
|
readonly sortAllowlist: SortAllowlist;
|
|
21
|
-
readonly dialect:
|
|
22
|
+
readonly dialect: SqlDialect;
|
|
22
23
|
/** Override default ID column name (defaults to "id"). */
|
|
23
24
|
readonly idColumn?: string;
|
|
24
25
|
/**
|
|
@@ -113,6 +114,11 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
|
|
|
113
114
|
if (Array.isArray(res)) return res as Record<string, unknown>[];
|
|
114
115
|
return (res as { rows?: Record<string, unknown>[] } | null)?.rows ?? [];
|
|
115
116
|
}
|
|
117
|
+
if (dialect === "mysql") {
|
|
118
|
+
// mysql2 answers `[rows, fields]`.
|
|
119
|
+
const [rows] = (await db.execute(query)) as [Record<string, unknown>[], unknown];
|
|
120
|
+
return rows;
|
|
121
|
+
}
|
|
116
122
|
return (await db.all(query)) as Record<string, unknown>[];
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -145,11 +151,11 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
145
151
|
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
146
152
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
147
153
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
148
|
-
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM
|
|
154
|
+
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
|
|
149
155
|
const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
|
|
150
156
|
if (!withCount) return camelRows;
|
|
151
157
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
152
|
-
const countRows = await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM
|
|
158
|
+
const countRows = await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM ${quoteIdent(dialect, viewName)}`)) as any[];
|
|
153
159
|
const total: number = Number(countRows[0]?.c ?? 0);
|
|
154
160
|
return { rows: camelRows, total };
|
|
155
161
|
}
|
|
@@ -204,7 +210,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
204
210
|
const { id } = req.params as { id: string };
|
|
205
211
|
if (useRawSql) {
|
|
206
212
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
207
|
-
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM
|
|
213
|
+
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
|
|
208
214
|
const row = rows[0] ? camelizeRow(rows[0]) : undefined;
|
|
209
215
|
return row ?? reply.code(404).send({ error: "not_found" });
|
|
210
216
|
}
|
|
@@ -114,3 +114,26 @@ export async function firstRow(db: unknown, src: unknown, cond: unknown): Promis
|
|
|
114
114
|
const rows: unknown = await (db as any).select().from(src).where(cond).limit(1);
|
|
115
115
|
return (rows as unknown[])[0];
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rows a DELETE/UPDATE affected, across the driver result shapes: libsql `rowsAffected`,
|
|
120
|
+
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, and mysql2's
|
|
121
|
+
* `[ResultSetHeader, fields]` tuple.
|
|
122
|
+
*/
|
|
123
|
+
export function extractRowCount(result: unknown): number {
|
|
124
|
+
if (typeof result === "number") return result;
|
|
125
|
+
// mysql2: `[ResultSetHeader, fields]`, the count on the header.
|
|
126
|
+
if (Array.isArray(result) && typeof (result[0] as { affectedRows?: unknown } | undefined)?.affectedRows === "number") {
|
|
127
|
+
return (result[0] as { affectedRows: number }).affectedRows;
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(result)) return result.length;
|
|
130
|
+
if (result && typeof result === "object") {
|
|
131
|
+
const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
|
|
132
|
+
if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
|
|
133
|
+
if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
|
|
134
|
+
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
135
|
+
// bun:sqlite / better-sqlite3 run() result shape.
|
|
136
|
+
if (typeof obj.changes === "number") return obj.changes;
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
package/src/extract-object.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
FIELD_ATTR_OBJECT_REF,
|
|
55
55
|
FIELD_ATTR_XML_TEXT,
|
|
56
56
|
VALIDATOR_SUBTYPE_NUMERIC,
|
|
57
|
+
VALIDATOR_SUBTYPE_ARRAY,
|
|
57
58
|
NORMALIZE_DEFAULT,
|
|
58
59
|
type NormalizeMode,
|
|
59
60
|
} from "@metaobjectsdev/metadata";
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
scalar,
|
|
65
66
|
range,
|
|
66
67
|
textContentField,
|
|
68
|
+
withItemBounds,
|
|
67
69
|
enumField,
|
|
68
70
|
enumArray,
|
|
69
71
|
object,
|
|
@@ -143,6 +145,22 @@ function fieldSpecFor(
|
|
|
143
145
|
format: Format,
|
|
144
146
|
visited: Set<MetaObject>,
|
|
145
147
|
depth: number,
|
|
148
|
+
): FieldSpec {
|
|
149
|
+
const spec = fieldShapeFor(field, owner, format, visited, depth);
|
|
150
|
+
if (!field.resolvedIsArray()) return spec;
|
|
151
|
+
// Element-count bounds: the field's array validator (@min/@max) is the single source of
|
|
152
|
+
// truth, as the numeric validator is for a scalar's range.
|
|
153
|
+
const bounds = field.validators().find((v) => v.subType === VALIDATOR_SUBTYPE_ARRAY);
|
|
154
|
+
if (bounds === undefined) return spec;
|
|
155
|
+
return withItemBounds(spec, bounds.min ?? null, bounds.max ?? null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fieldShapeFor(
|
|
159
|
+
field: MetaField,
|
|
160
|
+
owner: MetaObject,
|
|
161
|
+
format: Format,
|
|
162
|
+
visited: Set<MetaObject>,
|
|
163
|
+
depth: number,
|
|
146
164
|
): FieldSpec {
|
|
147
165
|
const name = field.name;
|
|
148
166
|
const required = isRequired(field);
|
package/src/hono/index.ts
CHANGED
|
@@ -42,7 +42,8 @@ export type {
|
|
|
42
42
|
FilterAllowlist,
|
|
43
43
|
SortAllowlist,
|
|
44
44
|
} from "../drizzle-fastify/filter-allowlist.js";
|
|
45
|
-
import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
|
|
45
|
+
import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js";
|
|
46
|
+
import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
|
|
46
47
|
import { timestampWire } from "../timestamp-wire.js";
|
|
47
48
|
// Every handler below is wrapped in guardRoute (an unexpected error answers
|
|
48
49
|
// `500 { error: "internal" }`, logged server-side) and reads its body through
|
|
@@ -104,7 +105,7 @@ export interface CrudRoutesOptions {
|
|
|
104
105
|
filterAllowlist?: FilterAllowlist;
|
|
105
106
|
sortAllowlist?: SortAllowlist;
|
|
106
107
|
/** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
|
|
107
|
-
dialect?:
|
|
108
|
+
dialect?: SqlDialect;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
|
|
@@ -266,15 +267,23 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
266
267
|
// Same redaction contract as the Fastify mount: a constraint the DB enforces must not
|
|
267
268
|
// come back as a 500 carrying the SQL and its bound parameters. Both mounts are
|
|
268
269
|
// required to emit byte-identical responses (docs/features/api-contract.md).
|
|
269
|
-
let
|
|
270
|
+
let row: unknown;
|
|
270
271
|
try {
|
|
271
|
-
|
|
272
|
+
if (supportsReturning(opts.dialect)) {
|
|
273
|
+
row = ((await opts.db.insert(opts.table).values(parsed.data).returning()) as unknown[])[0];
|
|
274
|
+
} else {
|
|
275
|
+
// MySQL has no RETURNING — the Fastify mount's rule; see drizzle-fastify/index.ts.
|
|
276
|
+
const values = parsed.data as Record<string, unknown>;
|
|
277
|
+
const [inserted] = (await opts.db.insert(opts.table).values(values).$returningId()) as
|
|
278
|
+
Array<Record<string, unknown> | undefined>;
|
|
279
|
+
const key = values.id ?? inserted?.id;
|
|
280
|
+
row = key === undefined ? undefined : await firstRow(opts.db, opts.table, eq(opts.table.id, key));
|
|
281
|
+
}
|
|
272
282
|
} catch (err) {
|
|
273
283
|
const f = classifyConstraintError(err);
|
|
274
284
|
if (f === undefined) { logAndRedact(err); throw new RedactedDatabaseError(); }
|
|
275
285
|
return c.json(f.body, f.status as 400 | 409);
|
|
276
286
|
}
|
|
277
|
-
const row = (result as unknown[])[0];
|
|
278
287
|
// Echo the row through the replica view so derived columns are present (#214).
|
|
279
288
|
return c.json(toWire(await reReadThroughView(opts, row)), 201);
|
|
280
289
|
}));
|
|
@@ -303,19 +312,19 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
303
312
|
// LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
|
|
304
313
|
const idValue = coerceIdForColumn(opts.table.id, id);
|
|
305
314
|
if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
|
|
306
|
-
let
|
|
315
|
+
let row: unknown;
|
|
307
316
|
try {
|
|
308
|
-
|
|
309
|
-
.update(opts.table)
|
|
310
|
-
|
|
311
|
-
.where(eq(opts.table.id, idValue))
|
|
312
|
-
.
|
|
317
|
+
if (supportsReturning(opts.dialect)) {
|
|
318
|
+
row = ((await opts.db.update(opts.table).set(parsed.data).where(eq(opts.table.id, idValue)).returning()) as unknown[])[0];
|
|
319
|
+
} else {
|
|
320
|
+
await opts.db.update(opts.table).set(parsed.data).where(eq(opts.table.id, idValue));
|
|
321
|
+
row = await firstRow(opts.db, opts.table, eq(opts.table.id, idValue));
|
|
322
|
+
}
|
|
313
323
|
} catch (err) {
|
|
314
324
|
const f = classifyConstraintError(err);
|
|
315
325
|
if (f === undefined) { logAndRedact(err); throw new RedactedDatabaseError(); }
|
|
316
326
|
return c.json(f.body, f.status as 400 | 409);
|
|
317
327
|
}
|
|
318
|
-
const row = (result as unknown[])[0];
|
|
319
328
|
return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
|
|
320
329
|
});
|
|
321
330
|
const path = `${opts.path}/:id`;
|
|
@@ -358,20 +367,6 @@ export function mountDeleteRoute(opts: VerbOptions): void {
|
|
|
358
367
|
}));
|
|
359
368
|
}
|
|
360
369
|
|
|
361
|
-
function extractRowCount(result: unknown): number {
|
|
362
|
-
if (typeof result === "number") return result;
|
|
363
|
-
if (Array.isArray(result)) return result.length;
|
|
364
|
-
if (result && typeof result === "object") {
|
|
365
|
-
const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
|
|
366
|
-
if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
|
|
367
|
-
if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
|
|
368
|
-
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
369
|
-
// bun:sqlite / better-sqlite3 run() result shape.
|
|
370
|
-
if (typeof obj.changes === "number") return obj.changes;
|
|
371
|
-
}
|
|
372
|
-
return 0;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
370
|
export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
|
|
376
371
|
|
|
377
372
|
// ---------------------------------------------------------------------------
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// GET :id only. POST/PATCH/PUT/DELETE return 405. Mirrors the drizzle-fastify
|
|
3
3
|
// equivalent so the cross-port API contract holds for projection endpoints.
|
|
4
4
|
|
|
5
|
+
import { quoteIdent, type SqlDialect } from "../sql-dialect.js";
|
|
5
6
|
import type { Hono } from "hono";
|
|
6
7
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
7
8
|
import qs from "qs";
|
|
@@ -28,7 +29,7 @@ export interface MountReadOnlyOptions {
|
|
|
28
29
|
readonly view: AnyView;
|
|
29
30
|
readonly filterAllowlist: FilterAllowlist;
|
|
30
31
|
readonly sortAllowlist: SortAllowlist;
|
|
31
|
-
readonly dialect:
|
|
32
|
+
readonly dialect: SqlDialect;
|
|
32
33
|
/** Override default ID column name (defaults to "id"). */
|
|
33
34
|
readonly idColumn?: string;
|
|
34
35
|
}
|
|
@@ -96,6 +97,11 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
|
|
|
96
97
|
if (Array.isArray(res)) return res as Record<string, unknown>[];
|
|
97
98
|
return (res as { rows?: Record<string, unknown>[] } | null)?.rows ?? [];
|
|
98
99
|
}
|
|
100
|
+
if (dialect === "mysql") {
|
|
101
|
+
// mysql2 answers `[rows, fields]`.
|
|
102
|
+
const [rows] = (await db.execute(query)) as [Record<string, unknown>[], unknown];
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
99
105
|
return (await db.all(query)) as Record<string, unknown>[];
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -121,11 +127,11 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
121
127
|
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
122
128
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
123
129
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
124
|
-
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM
|
|
130
|
+
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
|
|
125
131
|
const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
|
|
126
132
|
if (!withCount) return c.json(camelRows);
|
|
127
133
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
128
|
-
const countRows = (await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM
|
|
134
|
+
const countRows = (await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM ${quoteIdent(dialect, viewName)}`))) as any[];
|
|
129
135
|
const total: number = Number(countRows[0]?.c ?? 0);
|
|
130
136
|
return c.json({ rows: camelRows, total });
|
|
131
137
|
}
|
|
@@ -179,7 +185,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
179
185
|
const id = c.req.param("id") ?? "";
|
|
180
186
|
if (useRawSql) {
|
|
181
187
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
182
|
-
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM
|
|
188
|
+
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
|
|
183
189
|
const row = rows[0] ? camelizeRow(rows[0]) : undefined;
|
|
184
190
|
return row ? c.json(row) : c.json({ error: "not_found" }, 404);
|
|
185
191
|
}
|
package/src/object-manager.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from "./persistence-driver.js";
|
|
11
11
|
import {
|
|
12
12
|
buildSelectSpec, buildCountSpec, buildInsertSpec, buildUpdateSpec, buildDeleteSpec,
|
|
13
|
-
resolvePkFields, compileFilter,
|
|
13
|
+
resolvePkFields, compileFilter, primaryKeyColumns,
|
|
14
14
|
type Filter, type QueryOpts, type DiscriminatorScope,
|
|
15
15
|
} from "./query-builder.js";
|
|
16
16
|
import { tphSubtypeOf, type TphSubtype } from "./tph.js";
|
|
@@ -263,6 +263,7 @@ export class ObjectManager {
|
|
|
263
263
|
table: resolveTableName(entity),
|
|
264
264
|
rows: validatedRows.map((r) => this.toDbRow(entity, coerceRowOnWrite(entity, r, driver.dialect))),
|
|
265
265
|
returning: this.allDbColumns(entity),
|
|
266
|
+
key: primaryKeyColumns(entity, this.columnNamingStrategy),
|
|
266
267
|
};
|
|
267
268
|
// Wrap the batch in a transaction so a driver-level constraint failure on row N
|
|
268
269
|
// rolls back rows 1..N-1. Caller-supplied opts.tx already provides this.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The driver IS the unit of transaction — transaction(fn) yields a tx-scoped sub-driver.
|
|
3
3
|
// Java analog: ObjectConnection.
|
|
4
4
|
|
|
5
|
-
export type Dialect = "sqlite" | "postgres" | "memory";
|
|
5
|
+
export type Dialect = "sqlite" | "postgres" | "mysql" | "memory";
|
|
6
6
|
|
|
7
7
|
export type PrimitiveValue = string | number | boolean;
|
|
8
8
|
|
|
@@ -42,12 +42,19 @@ export interface InsertSpec {
|
|
|
42
42
|
values: Row;
|
|
43
43
|
/** Columns to return — driver handles RETURNING / lastInsertId per dialect. */
|
|
44
44
|
returning: string[];
|
|
45
|
+
/**
|
|
46
|
+
* The primary-key columns. A dialect without RETURNING (MySQL) reads the inserted row
|
|
47
|
+
* back by them; the others ignore it.
|
|
48
|
+
*/
|
|
49
|
+
key?: string[];
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface InsertManySpec {
|
|
48
53
|
table: string;
|
|
49
54
|
rows: Row[];
|
|
50
55
|
returning: string[];
|
|
56
|
+
/** As {@link InsertSpec.key}. */
|
|
57
|
+
key?: string[];
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
export interface UpdateSpec {
|