diff --git a/.changeset/constraint-errors.md b/.changeset/constraint-errors.md new file mode 100644 index 0000000..2fa3420 --- /dev/null +++ b/.changeset/constraint-errors.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": minor +--- + +Constraint violations are typed errors. `refineConstraints(effect)` turns a `DatabaseError` or `TransactionCommitFailed` for a broken constraint into `UniqueViolation`, `ForeignKeyViolation`, `NotNullViolation` or `CheckViolation`, so a caller can `catchTag` a duplicate key. Each carries the `constraint`, `table` and `column` the server named. Postgres maps SQLSTATE 23505, 23503, 23502 and 23514; ClickHouse maps code 469 (`VIOLATED_CONSTRAINT`) to `CheckViolation`. SQLite and D1 map the `SQLITE_CONSTRAINT_UNIQUE`, `_PRIMARYKEY`, `_FOREIGNKEY`, `_NOTNULL` and `_CHECK` extended codes, with the table and column SQLite names. `constraintViolation(error)` reads one without failing. `DatabaseError` and `TransactionCommitFailed` now keep the server's numeric error code as `errorCode`, which ClickHouse reports and `sqlState` dropped. `ClickhouseHttpClient` classifies code 469 as `ConstraintError` instead of `StatementTimeoutError`. diff --git a/design/gap-review.md b/design/gap-review.md index 852c5eb..508c174 100644 --- a/design/gap-review.md +++ b/design/gap-review.md @@ -32,7 +32,7 @@ builder; **P1** commonly used; **P2** niche. | ~~`FOR UPDATE` / `FOR SHARE` / `SKIP LOCKED` / `NOWAIT`~~ (built) | 7 | S | | jsonb and array operators (`@>`, `->`, `?`, `&&`, `ANY`) | ~12 | M | | ~~`isNull` / `isNotNull` / `between`; variadic `and` / `or` that skip `undefined`~~ (built) | everywhere | S | -| Constraint error helpers (unique, foreign key, not null); keep ClickHouse's numeric error codes, which `sqlStateOf` drops today | all upserts | S | +| ~~Constraint error helpers (unique, foreign key, not null); keep ClickHouse's numeric error codes, which `sqlStateOf` drops today~~ (built: `refineConstraints`, `DatabaseError.errorCode`; Postgres, ClickHouse, SQLite and D1) | all upserts | S | | Tenant-scope enforcement in `Database`, opt in, with an explicit cross-tenant entry point | safety | S | | ~~Postgres `defineTable` (indexes, unique, FKs), Postgres migrations, a drizzle-kit importer~~ Done: `S.pg.table`, `dialect: "postgres"`, `--baseline --from-drizzle` (design/migrations.md section 8) | 68 tables, 90 indexes, 47 unique, 75 folders | L | diff --git a/docs/database.md b/docs/database.md index bf14d00..9b1339c 100644 --- a/docs/database.md +++ b/docs/database.md @@ -93,6 +93,8 @@ Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransacti | `retryContention(options?)` | Re-run on serialization failure or deadlock | | `Transaction` | The open transaction (`depth`, `isolationLevel`, `accessMode`) | | `isContention(error)` | Whether an error is a serialization failure or deadlock | +| `refineConstraints(effect)` | Fail with a typed `ConstraintViolation` where a statement broke a constraint ([Constraint violations](#constraint-violations)) | +| `constraintViolation(error)` | The `ConstraintViolation` an error reports, as an `Option` | `run`, `query`, `execute`, `transaction` and `retryContention` are also methods on `DatabaseApi`. @@ -256,12 +258,52 @@ again. `read uncommitted` is not offered because Postgres runs it as `read commi | Error | When | | --- | --- | -| `DatabaseError` | A statement failed. `reason` is the driver's classification (`UniqueViolation`, `SerializationError`, ...) and `sqlState` the server's code | -| `TransactionCommitFailed` | COMMIT failed: a deferred constraint, a serialization failure at commit, a dropped connection. Carries `reason` and `sqlState` | +| `DatabaseError` | A statement failed. `reason` is the driver's classification (`UniqueViolation`, `SerializationError`, ...), `sqlState` the server's five-character code (Postgres) and `errorCode` its numeric code (ClickHouse) | +| `TransactionCommitFailed` | COMMIT failed: a deferred constraint, a serialization failure at commit, a dropped connection. Carries `reason`, `sqlState` and `errorCode` | | `TransactionRollbackFailed` | ROLLBACK failed after the body failed. `bodyCause` is the body's failure, `cause` the rollback's | | `TransactionOptionsRejected` | A setting the dialect does not support, or settings on a nested transaction | | `TransactionUnsupported` | The dialect has no transactions (ClickHouse) | +### Constraint violations + +`refineConstraints(effect)` turns a `DatabaseError` or `TransactionCommitFailed` for a broken +constraint into one tagged error per kind, so you can `catchTag` a duplicate key instead of +reading SQLSTATEs. Every other failure passes through unchanged. + +| Error | Postgres SQLSTATE | ClickHouse code | SQLite and D1 | +| --- | --- | --- | --- | +| `UniqueViolation` | 23505 | — | `SQLITE_CONSTRAINT_UNIQUE`, `SQLITE_CONSTRAINT_PRIMARYKEY` | +| `ForeignKeyViolation` | 23503 | — | `SQLITE_CONSTRAINT_FOREIGNKEY` | +| `NotNullViolation` | 23502 | — | `SQLITE_CONSTRAINT_NOTNULL` | +| `CheckViolation` | 23514 | 469 (`VIOLATED_CONSTRAINT`) | `SQLITE_CONSTRAINT_CHECK` | + +Each carries `message`, `sql`, the server's `sqlState` or `errorCode`, and `cause` (the error it +came from). It also carries `constraint`, `table` and `column` when the server named them: +Postgres names the constraint and the table, and the column for NOT NULL. ClickHouse names the +CHECK constraint and the table (`db.table`) in its message. ClickHouse has no unique, foreign key +or NOT NULL constraints to violate. SQLite names no unique index: a unique or NOT NULL violation +carries the `table` and, when the key has one column, the `column`. A CHECK violation carries +the CHECK's name as `constraint`, or its expression when it has no name. A foreign key violation +names nothing. SQLite's extended code is read from `node:sqlite` and from D1's message; neither +sets `sqlState` or `errorCode`. + +```ts title="database-constraints.ts" +import { Effect } from "effect" +import * as Db from "@maple-dev/effect-orm/database" + +// An idempotent write: a duplicate email is "already exists", not a failure. +export const register = (email: string) => + Db.execute(Db.sql`INSERT INTO users (email) VALUES (${email})`).pipe( + Db.refineConstraints, + Effect.as("created" as const), + Effect.catchTag("@maple-dev/effect-orm/UniqueViolation", () => Effect.succeed("exists" as const)), + ) +// Effect<"created" | "exists", DatabaseError | ForeignKeyViolation | NotNullViolation | CheckViolation, Database> +``` + +A deferred constraint fails at COMMIT, so put `refineConstraints` outside the `transaction` +to see it. + Effect fails with a bare `SqlError` on a failed COMMIT, dies on a failed ROLLBACK and, when ROLLBACK fails, drops the body's own failure. `transaction` turns these into the typed errors above. Defects from `body` stay defects. diff --git a/docs/reference.md b/docs/reference.md index fe8f4eb..6535d6b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -514,6 +514,7 @@ placeholders, double-quoted identifiers. | `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` | | `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `afterCommit`, `retryContention`, `Transaction` | `/database` | | `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` | +| `refineConstraints`, `constraintViolation`, and the `ConstraintViolation` errors `UniqueViolation`, `ForeignKeyViolation`, `NotNullViolation`, `CheckViolation` | `/database` | | `withQuerySummary`, `QuerySummary` — the `db.query.summary` of the statements an effect runs, for spans | `/database` | | `ClickhouseHttpClient`, `layer`, `make`, `ClickhouseHttpClientConfig` — a ClickHouse `SqlClient` over `fetch`, for Workers | `/clickhouse-http` | | `defineConfig`, `generate`, `check`, `pull`, `loadSchema`, `readMigrations`, `analyze`, `KitError` | `/kit` | diff --git a/src/clickhouse-http/client.test.ts b/src/clickhouse-http/client.test.ts index e7fc8b9..945808c 100644 --- a/src/clickhouse-http/client.test.ts +++ b/src/clickhouse-http/client.test.ts @@ -106,6 +106,7 @@ describe("ClickhouseHttpClient", () => { [516, "AuthenticationError"], [497, "AuthorizationError"], [159, "StatementTimeoutError"], + [469, "ConstraintError"], [241, "UnknownError"], ] as const)("classifies ClickHouse code %s as %s", ([code, reason]) => { const { live } = fake(() => ({ @@ -161,6 +162,25 @@ describe("ClickhouseHttpClient", () => { ) }) + it.effect("keeps the numeric code on DatabaseError, and a violated CHECK is CheckViolation", () => { + const body = + "Code: 469. DB::Exception: Constraint `positive` for table default.rents (7fee639d-39fa-44e2-936d-107bc35ca49b) is violated at row 1. Expression: (cents > 0). Column values: cents = -5. (VIOLATED_CONSTRAINT) (version 26.10.2.103 (official build))" + const { live } = fake(() => ({ status: 500, body, headers: { "x-clickhouse-exception-code": "469" } })) + return Effect.gen(function* () { + const insert = Db.execute(Db.sql`INSERT INTO rents VALUES (${1}, ${-5})`) + const raw = yield* Effect.flip(insert) + expect(raw).toMatchObject({ errorCode: 469, reason: "ConstraintError" }) + expect(raw.sqlState).toBeUndefined() + const error = yield* Effect.flip(insert.pipe(Db.refineConstraints)) + expect(error).toBeInstanceOf(Db.CheckViolation) + expect(error).toMatchObject({ constraint: "positive", table: "default.rents", errorCode: 469 }) + }).pipe( + Effect.provide(Layer.effect(Db.Database, Effect.gen(function* () { + return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: CH.clickhouseDialect }) + })).pipe(Layer.provideMerge(live))), + ) + }) + it.effect("kills the query by id when its fiber is interrupted", () => { const sent: Array = [] const http = HttpClient.make((request, url) => { diff --git a/src/clickhouse-http/client.ts b/src/clickhouse-http/client.ts index 525be27..a72198f 100644 --- a/src/clickhouse-http/client.ts +++ b/src/clickhouse-http/client.ts @@ -20,6 +20,7 @@ import { AuthenticationError, AuthorizationError, ConnectionError, + ConstraintError, SqlError, SqlSyntaxError, StatementTimeoutError, @@ -82,7 +83,9 @@ const Settings = Context.Reference>>( ) // The codes Effect's Node driver classifies, so a failure means the same thing -// whichever client ran it. +// whichever client ran it. One exception: that driver reads 469 +// (VIOLATED_CONSTRAINT, a failed CHECK) as a statement timeout, which says +// retry; here it is a ConstraintError. const SYNTAX_ERROR_CODES = new Set([36, 60, 62, 242]) const classify = (code: number | undefined, message: string, cause: unknown, operation: string) => { @@ -90,7 +93,8 @@ const classify = (code: number | undefined, message: string, cause: unknown, ope if (code === 516) return new AuthenticationError(props) if (code === 497) return new AuthorizationError(props) if (code !== undefined && SYNTAX_ERROR_CODES.has(code)) return new SqlSyntaxError(props) - if (code === 159 || code === 469) return new StatementTimeoutError(props) + if (code === 159) return new StatementTimeoutError(props) + if (code === 469) return new ConstraintError(props) return new UnknownError(props) } diff --git a/src/database.ts b/src/database.ts index dc56aab..40ad0f5 100644 --- a/src/database.ts +++ b/src/database.ts @@ -29,14 +29,20 @@ export { type TransactionInfo, type TransactionOptions, } from "./database/database" +export { constraintViolation, refineConstraints } from "./database/constraint" export { + CheckViolation, DatabaseError, + ForeignKeyViolation, + NotNullViolation, + UniqueViolation, TenantScopeViolation, TransactionClosed, TransactionCommitFailed, TransactionOptionsRejected, TransactionRollbackFailed, TransactionUnsupported, + type ConstraintViolation, type TransactionError, } from "./database/errors" export { QuerySummary, withQuerySummary } from "./database/tracing" diff --git a/src/database/constraint.test.ts b/src/database/constraint.test.ts new file mode 100644 index 0000000..df95df6 --- /dev/null +++ b/src/database/constraint.test.ts @@ -0,0 +1,176 @@ +// Constraint violations as typed errors, against a real Postgres (PGlite) for +// the SQLSTATE side. ClickHouse's live CHECK test is in +// tests/database.clickhouse.test.ts; here its errors are built by hand. + +import * as D1Client from "@effect/sql-d1/D1Client" +import { PgliteClient } from "@effect/sql-pglite" +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient" +import { describe, expect, it, layer } from "@effect/vitest" +import { Effect, Layer } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { Miniflare } from "miniflare" +import { postgresDialect } from "../pg/dialect" +import * as Db from "../database" +import * as SQLite from "../sqlite" + +const Live = Layer.effect( + Db.Database, + Effect.gen(function* () { + return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: postgresDialect }) + }), +).pipe(Layer.provideMerge(PgliteClient.layer())) + +layer(Live, { excludeTestServices: true })("constraint violations on PGlite", (it) => { + it.effect("a duplicate key is UniqueViolation with the constraint name", () => + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE users_u (id int4 PRIMARY KEY, email text CONSTRAINT users_u_email_key UNIQUE)`) + yield* Db.execute(Db.sql`INSERT INTO users_u VALUES (${1}, ${"a@x"})`) + const error = yield* Effect.flip( + Db.execute(Db.sql`INSERT INTO users_u VALUES (${2}, ${"a@x"})`).pipe(Db.refineConstraints), + ) + expect(error._tag).toBe("@maple-dev/effect-orm/UniqueViolation") + if (error._tag !== "@maple-dev/effect-orm/UniqueViolation") return + expect(error.constraint).toBe("users_u_email_key") + expect(error.table).toBe("users_u") + expect(error.sqlState).toBe("23505") + expect(error.cause).toBeInstanceOf(Db.DatabaseError) + }), + ) + + it.effect("a missing referenced row is ForeignKeyViolation", () => + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE orgs_f (id int4 PRIMARY KEY)`) + yield* Db.execute( + Db.sql`CREATE TABLE units_f (id int4 PRIMARY KEY, org_id int4 CONSTRAINT units_f_org_fk REFERENCES orgs_f (id))`, + ) + const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO units_f VALUES (${1}, ${9})`).pipe(Db.refineConstraints)) + expect(error).toBeInstanceOf(Db.ForeignKeyViolation) + expect(error).toMatchObject({ constraint: "units_f_org_fk", table: "units_f", sqlState: "23503" }) + }), + ) + + it.effect("NULL in a NOT NULL column is NotNullViolation with the column", () => + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE tours_n (id int4 PRIMARY KEY, starts_at timestamptz NOT NULL)`) + const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO tours_n VALUES (${1}, ${null})`).pipe(Db.refineConstraints)) + expect(error).toBeInstanceOf(Db.NotNullViolation) + expect(error).toMatchObject({ table: "tours_n", column: "starts_at", sqlState: "23502" }) + }), + ) + + it.effect("a row that fails a CHECK is CheckViolation with the constraint name", () => + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE rents_c (id int4 PRIMARY KEY, cents int4 CONSTRAINT rents_c_positive CHECK (cents > 0))`) + const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO rents_c VALUES (${1}, ${-5})`).pipe(Db.refineConstraints)) + expect(error).toBeInstanceOf(Db.CheckViolation) + expect(error).toMatchObject({ constraint: "rents_c_positive", table: "rents_c", sqlState: "23514" }) + }), + ) + + it.effect("a deferred foreign key found at COMMIT is ForeignKeyViolation too", () => + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE orgs_d (id int4 PRIMARY KEY)`) + yield* Db.execute( + Db.sql`CREATE TABLE units_d (id int4, org_id int4 CONSTRAINT units_d_org_fk REFERENCES orgs_d (id) DEFERRABLE INITIALLY DEFERRED)`, + ) + const error = yield* Effect.flip( + Db.transaction(Db.execute(Db.sql`INSERT INTO units_d VALUES (${1}, ${9})`)).pipe(Db.refineConstraints), + ) + expect(error).toBeInstanceOf(Db.ForeignKeyViolation) + expect(error).toMatchObject({ constraint: "units_d_org_fk", sqlState: "23503" }) + expect((error as Db.ForeignKeyViolation).cause).toBeInstanceOf(Db.TransactionCommitFailed) + }), + ) + + it.effect("every other failure passes through unchanged", () => + Effect.gen(function* () { + const syntax = yield* Effect.flip(Db.execute({ sql: "SELEC 1" }).pipe(Db.refineConstraints)) + expect(syntax).toBeInstanceOf(Db.DatabaseError) + const domain = new Error("domain") + expect(yield* Effect.flip(Effect.fail(domain).pipe(Db.refineConstraints))).toBe(domain) + }), + ) +}) + +// SQLite reports each kind by its extended result code (node:sqlite keeps it +// as `errcode`; D1 only in its message). SQLite names no unique index, so a +// violation carries the table and, for one column, the column. +const SQLITE_SCHEMA = [ + "PRAGMA foreign_keys = ON", + "CREATE TABLE orgs (id INTEGER PRIMARY KEY)", + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, org_id INTEGER REFERENCES orgs (id), cents INTEGER CONSTRAINT positive CHECK (cents > 0), a TEXT, b TEXT, UNIQUE (a, b))", + "INSERT INTO users (id, email, cents, a, b) VALUES (1, 'a@x', 1, 'p', 'q')", +] + +const sqliteViolations = (db: Db.DatabaseApi) => + Effect.gen(function* () { + for (const sql of SQLITE_SCHEMA) yield* db.execute({ sql }) + const violation = (sql: string) => Effect.flip(db.execute({ sql }).pipe(Db.refineConstraints)) + return { + unique: yield* violation("INSERT INTO users (id, email, cents) VALUES (2, 'a@x', 1)"), + primaryKey: yield* violation("INSERT INTO users (id, email, cents) VALUES (1, 'b@x', 1)"), + composite: yield* violation("INSERT INTO users (id, email, cents, a, b) VALUES (3, 'c@x', 1, 'p', 'q')"), + notNull: yield* violation("INSERT INTO users (id, email, cents) VALUES (4, NULL, 1)"), + check: yield* violation("INSERT INTO users (id, email, cents) VALUES (5, 'd@x', -5)"), + foreignKey: yield* violation("INSERT INTO users (id, email, cents, org_id) VALUES (6, 'e@x', 1, 9)"), + other: yield* violation("SELEC 1"), + } + }) + +const expectSqliteViolations = (seen: Effect.Success>) => { + expect(seen.unique).toBeInstanceOf(Db.UniqueViolation) + expect(seen.unique).toMatchObject({ table: "users", column: "email" }) + expect(seen.unique).not.toHaveProperty("constraint") + expect(seen.primaryKey).toBeInstanceOf(Db.UniqueViolation) + expect(seen.primaryKey).toMatchObject({ table: "users", column: "id" }) + expect(seen.composite).toBeInstanceOf(Db.UniqueViolation) + expect(seen.composite).toMatchObject({ table: "users" }) + expect(seen.composite).not.toHaveProperty("column") + expect(seen.notNull).toBeInstanceOf(Db.NotNullViolation) + expect(seen.notNull).toMatchObject({ table: "users", column: "email" }) + expect(seen.check).toBeInstanceOf(Db.CheckViolation) + expect(seen.check).toMatchObject({ constraint: "positive" }) + expect(seen.foreignKey).toBeInstanceOf(Db.ForeignKeyViolation) + expect(seen.other).toBeInstanceOf(Db.DatabaseError) +} + +describe("constraint violations on SQLite", () => { + it.effect("node:sqlite: each kind is its tagged error", () => + Effect.gen(function* () { + const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect }) + expectSqliteViolations(yield* sqliteViolations(db)) + }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))), + ) + + it.effect("node:sqlite: a deferred foreign key found at COMMIT is ForeignKeyViolation", () => + Effect.gen(function* () { + const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect }) + yield* db.execute({ sql: "PRAGMA foreign_keys = ON" }) + yield* db.execute({ sql: "CREATE TABLE orgs (id INTEGER PRIMARY KEY)" }) + yield* db.execute({ sql: "CREATE TABLE units (id INTEGER, org_id INTEGER REFERENCES orgs (id) DEFERRABLE INITIALLY DEFERRED)" }) + const error = yield* Effect.flip(db.transaction(db.execute({ sql: "INSERT INTO units VALUES (1, 9)" })).pipe(Db.refineConstraints)) + expect(error).toBeInstanceOf(Db.ForeignKeyViolation) + expect((error as Db.ForeignKeyViolation).cause).toBeInstanceOf(Db.TransactionCommitFailed) + }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))), + ) + + it("D1: each kind is its tagged error", async () => { + const mf = new Miniflare({ + modules: true, + script: "export default { fetch() { return new Response('constraint test') } }", + compatibilityDate: "2026-07-01", + d1Databases: ["DB"], + }) + try { + const binding = await mf.getD1Database("DB") + const seen = await Effect.runPromise( + Effect.gen(function* () { + return yield* sqliteViolations(Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.d1Dialect })) + }).pipe(Effect.provide(D1Client.layer({ db: binding }))), + ) + expectSqliteViolations(seen) + } finally { + await mf.dispose() + } + }, 30_000) +}) diff --git a/src/database/constraint.ts b/src/database/constraint.ts new file mode 100644 index 0000000..588c02c --- /dev/null +++ b/src/database/constraint.ts @@ -0,0 +1,101 @@ +// Constraint violations as typed errors. +// +// A `DatabaseError` keeps the driver's classification and the server's code; a +// caller who wants "this email is taken" should not read either. These helpers +// read them once and give one tagged error per constraint kind, the same on +// every dialect that reports it. + +import { Effect, Option } from "effect" +import { CheckViolation, DatabaseError, ForeignKeyViolation, NotNullViolation, TransactionCommitFailed, UniqueViolation, type ConstraintViolation } from "./errors" +import { serverFieldOf, sqliteConstraintOf, type SqliteConstraint } from "./sql-error" + +type Kind = typeof UniqueViolation | typeof ForeignKeyViolation | typeof NotNullViolation | typeof CheckViolation + +const kindOf = (error: DatabaseError | TransactionCommitFailed): Kind | undefined => { + if (error.sqlState === "23505" || error.reason === "UniqueViolation") return UniqueViolation + if (error.sqlState === "23503") return ForeignKeyViolation + if (error.sqlState === "23502") return NotNullViolation + if (error.sqlState === "23514") return CheckViolation + // ClickHouse VIOLATED_CONSTRAINT: its only constraints are CHECKs. + if (error.sqlState === undefined && error.errorCode === 469) return CheckViolation + return undefined +} + +// ClickHouse names the constraint and table only in its message: +// "Constraint `positive` for table default.rents (uuid) is violated at row 1". +const fromClickHouseMessage = (message: string) => { + const match = /Constraint `([^`]+)` for table ([^\s(]+)/.exec(message) + return match === null ? {} : { constraint: match[1], table: match[2] } +} + +interface Named { + readonly constraint?: string | undefined + readonly table?: string | undefined + readonly column?: string | undefined +} + +const namedByServer = (error: DatabaseError | TransactionCommitFailed): Named => { + const named = error.errorCode === undefined ? {} : fromClickHouseMessage(error.message) + return { + constraint: serverFieldOf(error.cause, "constraint") ?? named.constraint, + table: serverFieldOf(error.cause, "table") ?? named.table, + column: serverFieldOf(error.cause, "column"), + } +} + +// SQLite names no unique index, only its columns: "UNIQUE constraint failed: +// t.a, t.b", "NOT NULL constraint failed: t.a", "CHECK constraint failed: name" +// (the expression when the CHECK has none), "FOREIGN KEY constraint failed". +// D1 appends ": SQLITE_CONSTRAINT (extended: ...)". Effect's SQLite +// `UniqueViolation` reason calls the column list its constraint, so this reads +// the message rather than the reason. +const fromSqliteMessage = (kind: SqliteConstraint, message: string): Named => { + const detail = /constraint failed: (.*?)(?:: SQLITE_CONSTRAINT\b.*)?$/.exec(message)?.[1]?.trim() + if (detail === undefined || detail.length === 0) return {} + if (kind === "CHECK") return { constraint: detail } + const columns = detail.split(", ").map((qualified) => /^(.+)\.([^.]+)$/.exec(qualified)) + const table = columns[0]?.[1] + if (table === undefined || columns.some((column) => column?.[1] !== table)) return {} + // A column only when the key has just one. + return columns.length === 1 ? { table, column: columns[0]?.[2] } : { table } +} + +const SQLITE_KINDS: Readonly> = { + UNIQUE: UniqueViolation, + PRIMARYKEY: UniqueViolation, + FOREIGNKEY: ForeignKeyViolation, + NOTNULL: NotNullViolation, + CHECK: CheckViolation, +} + +/** + * The constraint violation `error` reports, if it is a `DatabaseError` or + * `TransactionCommitFailed` for one. Anything else is `None`. + */ +export const constraintViolation = (error: unknown): Option.Option => { + if (!(error instanceof DatabaseError || error instanceof TransactionCommitFailed)) return Option.none() + const sqlite = sqliteConstraintOf(error.cause) + const Kind = sqlite === undefined ? kindOf(error) : SQLITE_KINDS[sqlite] + if (Kind === undefined) return Option.none() + const { constraint, table, column } = sqlite === undefined ? namedByServer(error) : fromSqliteMessage(sqlite, error.message) + return Option.some( + new Kind({ + message: error.message, + ...(error instanceof DatabaseError ? { sql: error.sql } : undefined), + ...(constraint === undefined ? undefined : { constraint }), + ...(table === undefined ? undefined : { table }), + ...(column === undefined ? undefined : { column }), + ...(error.sqlState === undefined ? undefined : { sqlState: error.sqlState }), + ...(error.errorCode === undefined ? undefined : { errorCode: error.errorCode }), + cause: error, + }), + ) +} + +/** + * Fail with a `ConstraintViolation` where `effect` fails with a + * `DatabaseError` or `TransactionCommitFailed` for one, so a caller can + * `catchTag` a duplicate key. Every other failure passes through unchanged. + */ +export const refineConstraints = (effect: Effect.Effect): Effect.Effect => + Effect.mapError(effect, (error): E | ConstraintViolation => Option.getOrElse(constraintViolation(error), () => error)) diff --git a/src/database/database.test-d.ts b/src/database/database.test-d.ts index f684a71..0c2a531 100644 --- a/src/database/database.test-d.ts +++ b/src/database/database.test-d.ts @@ -87,3 +87,10 @@ expectTypeOf(Db.run(upsert, { count: 1 })).toEqualTypeOf>() + +// `refineConstraints` adds the violations; catching one removes it. +const write = Db.execute(Db.sql`INSERT INTO t VALUES (1)`).pipe(Db.refineConstraints) +expectTypeOf(write).toEqualTypeOf>() +expectTypeOf(write.pipe(Effect.catchTag("@maple-dev/effect-orm/UniqueViolation", (error) => Effect.succeed(error.constraint)))).toEqualTypeOf< + Effect.Effect +>() diff --git a/src/database/database.ts b/src/database/database.ts index 3a94eaa..94be4ad 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -30,7 +30,7 @@ import { type TransactionError, } from "./errors" import { isSqlTemplate, renderTemplate, type SqlTemplate } from "./sql" -import { firstLine, reasonOf, sqlStateOf, toDatabaseError } from "./sql-error" +import { errorCodeOf, firstLine, reasonOf, sqlStateOf, toDatabaseError } from "./sql-error" import { traceStatement, type TracedStatement } from "./tracing" /** SQL and the values bound to its placeholders. A `CompiledQuery` is one. */ @@ -452,11 +452,13 @@ const restoreCause = ( : undefined if (error === undefined) return Effect.failCause(cause as Cause.Cause) const sqlState = sqlStateOf(error) + const errorCode = errorCodeOf(error) return Effect.fail( new TransactionCommitFailed({ message: `${depth === 0 ? "COMMIT" : "releasing the savepoint"} failed: ${firstLine(error)}`, reason: reasonOf(error), ...(sqlState === undefined ? undefined : { sqlState }), + ...(errorCode === undefined ? undefined : { errorCode }), cause: error, }), ) diff --git a/src/database/errors.ts b/src/database/errors.ts index 786ab5a..5ceca21 100644 --- a/src/database/errors.ts +++ b/src/database/errors.ts @@ -4,18 +4,63 @@ import { Schema } from "effect" * A statement failed: the server rejected it, or the connection failed while it ran. * * `reason` is the driver's classification (`SqlError.reason._tag`: - * `UniqueViolation`, `SerializationError`, `DeadlockError`, ...) and `sqlState` - * the server's error code when the driver reported one, so callers can branch - * without digging through `cause`. + * `UniqueViolation`, `SerializationError`, `DeadlockError`, ...), `sqlState` + * the server's five-character code (Postgres) and `errorCode` its numeric code + * (ClickHouse), when the driver reported one, so callers can branch without + * digging through `cause`. `refineConstraints` turns a constraint failure into + * a `ConstraintViolation`. */ export class DatabaseError extends Schema.TaggedError()("@maple-dev/effect-orm/DatabaseError", { message: Schema.String, sql: Schema.String, reason: Schema.String, sqlState: Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.Number), cause: Schema.Defect(), }) {} +// What every constraint violation carries. `constraint`, `table` and `column` +// are present when the server named them: Postgres names the constraint and +// table, and the column for NOT NULL; ClickHouse names the CHECK constraint; +// SQLite names the table and a single column, or the CHECK. `cause` is the `DatabaseError` or `TransactionCommitFailed` it came from. +const ViolationFields = { + message: Schema.String, + sql: Schema.optional(Schema.String), + constraint: Schema.optional(Schema.String), + table: Schema.optional(Schema.String), + column: Schema.optional(Schema.String), + sqlState: Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.Number), + cause: Schema.Defect(), +} + +/** A row would duplicate a unique key or primary key. Postgres SQLSTATE 23505, SQLite `SQLITE_CONSTRAINT_UNIQUE` or `_PRIMARYKEY`. */ +export class UniqueViolation extends Schema.TaggedError()( + "@maple-dev/effect-orm/UniqueViolation", + ViolationFields, +) {} + +/** A row references a missing row, or a delete leaves rows that reference it. Postgres SQLSTATE 23503, SQLite `SQLITE_CONSTRAINT_FOREIGNKEY`. */ +export class ForeignKeyViolation extends Schema.TaggedError()( + "@maple-dev/effect-orm/ForeignKeyViolation", + ViolationFields, +) {} + +/** A NOT NULL column got NULL. Postgres SQLSTATE 23502, SQLite `SQLITE_CONSTRAINT_NOTNULL`. */ +export class NotNullViolation extends Schema.TaggedError()( + "@maple-dev/effect-orm/NotNullViolation", + ViolationFields, +) {} + +/** A row fails a CHECK constraint. Postgres SQLSTATE 23514, ClickHouse code 469 (`VIOLATED_CONSTRAINT`), SQLite `SQLITE_CONSTRAINT_CHECK`. */ +export class CheckViolation extends Schema.TaggedError()( + "@maple-dev/effect-orm/CheckViolation", + ViolationFields, +) {} + +/** The constraint violations `refineConstraints` lifts out of `DatabaseError` and `TransactionCommitFailed`. */ +export type ConstraintViolation = UniqueViolation | ForeignKeyViolation | NotNullViolation | CheckViolation + /** The database's dialect has no transactions (ClickHouse), or none at this nesting depth. */ export class TransactionUnsupported extends Schema.TaggedError()( "@maple-dev/effect-orm/TransactionUnsupported", @@ -42,6 +87,7 @@ export class TransactionCommitFailed extends Schema.TaggedError ("reason" in current ? current.reason : "cause" in current ? current.cause : undefined) @@ -37,6 +37,80 @@ export const sqlStateOf = (cause: unknown): string | undefined => { return undefined } +/** + * The server's numeric error code (ClickHouse `469`), when a driver in the + * chain kept it: a number, or a string of digits that is not a five-character + * SQLSTATE. `@effect/sql-clickhouse` keeps it as the client's string `code`, + * `ClickhouseHttpClient` as a number. + */ +export const errorCodeOf = (cause: unknown): number | undefined => { + let current: unknown = cause + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + if ("code" in current) { + const code = current.code + if (typeof code === "number" && Number.isInteger(code)) return code + if (typeof code === "string" && /^[0-9]{1,4}$/.test(code)) return Number(code) + } + const inner = next(current) + if (inner === undefined || inner === current) break + current = inner + } + return undefined +} + +/** + * A string field the server put on its error (`constraint`, `table`, + * `column` on a Postgres error), when a driver in the chain kept it. Effect's + * `UniqueViolation` reason says `"unknown"` when it found no constraint name; + * that is not a name, so the walk goes on. + */ +export const serverFieldOf = (cause: unknown, field: "constraint" | "table" | "column"): string | undefined => { + let current: unknown = cause + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + const value: unknown = (current as Record)[field] + if (typeof value === "string" && value.length > 0 && !(isSqlErrorReason(current) && value === "unknown")) return value + const inner = next(current) + if (inner === undefined || inner === current) break + current = inner + } + return undefined +} + +// SQLite's extended result codes for the constraint kinds a caller branches on. +const SQLITE_CONSTRAINT_CODES: Readonly> = { + 275: "CHECK", + 787: "FOREIGNKEY", + 1299: "NOTNULL", + 1555: "PRIMARYKEY", + 2067: "UNIQUE", +} + +/** The kind of a failed SQLite constraint, as its extended result code names it. */ +export type SqliteConstraint = "UNIQUE" | "PRIMARYKEY" | "FOREIGNKEY" | "NOTNULL" | "CHECK" + +/** + * The SQLite constraint a driver error reports. `node:sqlite` (under + * `@effect/sql-sqlite-node`) keeps the extended result code as `errcode`; D1 + * keeps only text, "... (extended: SQLITE_CONSTRAINT_UNIQUE)". Neither can be + * read as a ClickHouse or Postgres code, so this is its own reader. + */ +export const sqliteConstraintOf = (cause: unknown): SqliteConstraint | undefined => { + let current: unknown = cause + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + if ("code" in current && current.code === "ERR_SQLITE_ERROR" && "errcode" in current && typeof current.errcode === "number") { + return SQLITE_CONSTRAINT_CODES[current.errcode] + } + if ("message" in current && typeof current.message === "string") { + const match = /\(extended: SQLITE_CONSTRAINT_(UNIQUE|PRIMARYKEY|FOREIGNKEY|NOTNULL|CHECK)\)/.exec(current.message) + if (match !== null) return match[1] as SqliteConstraint + } + const inner = next(current) + if (inner === undefined || inner === current) break + current = inner + } + return undefined +} + /** The driver's classification: `SqlError.reason._tag`, or `Unknown`. */ export const reasonOf = (cause: unknown): string => (isSqlError(cause) ? cause.reason._tag : "Unknown") @@ -44,11 +118,13 @@ export const toDatabaseError = (sql: string) => (cause: unknown): DatabaseError => { const sqlState = sqlStateOf(cause) + const errorCode = errorCodeOf(cause) return new DatabaseError({ message: firstLine(cause), sql, reason: reasonOf(cause), ...(sqlState === undefined ? undefined : { sqlState }), + ...(errorCode === undefined ? undefined : { errorCode }), cause, }) } diff --git a/tests/database.clickhouse.test.ts b/tests/database.clickhouse.test.ts index c43317c..1619fd3 100644 --- a/tests/database.clickhouse.test.ts +++ b/tests/database.clickhouse.test.ts @@ -198,6 +198,24 @@ describe("database", () => { expect(result.nulls).toEqual([{ Id: 1 }, { Id: 3 }]) }) + it("keeps the numeric error code, and a violated CHECK is CheckViolation", async () => { + const { raw, error } = await Effect.runPromise( + withDatabase((db) => + Effect.gen(function* () { + yield* db.execute( + Db.sql`CREATE TABLE rents (Id UInt32, Cents Int32, CONSTRAINT positive CHECK Cents > 0) ENGINE = MergeTree ORDER BY Id`, + ) + const insert = db.execute(Db.sql`INSERT INTO rents VALUES (${1}, ${-5})`) + return { raw: yield* Effect.flip(insert), error: yield* Effect.flip(insert.pipe(Db.refineConstraints)) } + }), + ), + ) + expect(raw.errorCode).toBe(469) + expect(error).toBeInstanceOf(Db.CheckViolation) + expect(error).toMatchObject({ constraint: "positive", errorCode: 469 }) + expect((error as Db.CheckViolation).table).toMatch(/\.rents$/) + }) + it("refuses a transaction before sending anything", async () => { const result = await Effect.runPromise( withDatabase((db, sent) =>