diff --git a/drizzle-orm/src/aws-data-api/pg/driver.ts b/drizzle-orm/src/aws-data-api/pg/driver.ts index 7395930ad0..6d613624d4 100644 --- a/drizzle-orm/src/aws-data-api/pg/driver.ts +++ b/drizzle-orm/src/aws-data-api/pg/driver.ts @@ -1,5 +1,6 @@ import { RDSDataClient, type RDSDataClientConfig } from '@aws-sdk/client-rds-data'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -22,6 +23,7 @@ import { AwsDataApiSession } from './session.ts'; export interface PgDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; database: string; resourceArn: string; secretArn: string; @@ -119,7 +121,12 @@ function construct = Record db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/aws-data-api/pg/session.ts b/drizzle-orm/src/aws-data-api/pg/session.ts index 6c915967f7..92af7b085e 100644 --- a/drizzle-orm/src/aws-data-api/pg/session.ts +++ b/drizzle-orm/src/aws-data-api/pg/session.ts @@ -9,6 +9,7 @@ import type { Cache } from '~/cache/core/cache.ts'; import { NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { type PgDialect, @@ -151,6 +152,7 @@ export class AwsDataApiPreparedQuery< export interface AwsDataApiSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; database: string; resourceArn: string; secretArn: string; @@ -188,6 +190,7 @@ export class AwsDataApiSession< database: options.database, }; this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery< @@ -206,19 +209,21 @@ export class AwsDataApiSession< cacheConfig?: WithCacheConfig, transactionId?: string, ): AwsDataApiPreparedQuery { - return new AwsDataApiPreparedQuery( - this.client, - query.sql, - query.params, - query.typings ?? [], - this.options, - this.cache, - queryMetadata, - cacheConfig, - fields, - transactionId ?? this.transactionId, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new AwsDataApiPreparedQuery( + this.client, + query.sql, + query.params, + query.typings ?? [], + this.options, + this.cache, + queryMetadata, + cacheConfig, + fields, + transactionId ?? this.transactionId, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/better-sqlite3/driver.ts b/drizzle-orm/src/better-sqlite3/driver.ts index fc91b8f934..2aa1e73286 100644 --- a/drizzle-orm/src/better-sqlite3/driver.ts +++ b/drizzle-orm/src/better-sqlite3/driver.ts @@ -54,7 +54,7 @@ function construct = Record db).$client = client; // ( db).$cache = config.cache; diff --git a/drizzle-orm/src/better-sqlite3/session.ts b/drizzle-orm/src/better-sqlite3/session.ts index 3f67934651..8bb65db97a 100644 --- a/drizzle-orm/src/better-sqlite3/session.ts +++ b/drizzle-orm/src/better-sqlite3/session.ts @@ -2,6 +2,7 @@ import type { Database, RunResult, Statement } from 'better-sqlite3'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -21,6 +22,7 @@ import { mapResultRow } from '~/utils.ts'; export interface BetterSQLiteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -43,6 +45,7 @@ export class BetterSQLiteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery>( @@ -58,17 +61,19 @@ export class BetterSQLiteSession< cacheConfig?: WithCacheConfig, ): PreparedQuery { const stmt = this.client.prepare(query.sql); - return new PreparedQuery( - stmt, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new PreparedQuery( + stmt, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/bun-sql/driver.ts b/drizzle-orm/src/bun-sql/driver.ts index 8e930510f6..6428001ed1 100644 --- a/drizzle-orm/src/bun-sql/driver.ts +++ b/drizzle-orm/src/bun-sql/driver.ts @@ -49,7 +49,11 @@ function construct = Record; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/bun-sql/session.ts b/drizzle-orm/src/bun-sql/session.ts index cada89d485..6b70a3d268 100644 --- a/drizzle-orm/src/bun-sql/session.ts +++ b/drizzle-orm/src/bun-sql/session.ts @@ -4,6 +4,7 @@ import type { SavepointSQL, SQL, TransactionSQL } from 'bun'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; @@ -105,6 +106,7 @@ export class BunSQLPreparedQuery extends PgPrepar export interface BunSQLSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class BunSQLSession< @@ -127,6 +129,7 @@ export class BunSQLSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -141,17 +144,19 @@ export class BunSQLSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new BunSQLPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new BunSQLPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/bun-sqlite/driver.ts b/drizzle-orm/src/bun-sqlite/driver.ts index 9d2f9415be..2f2d2f2051 100644 --- a/drizzle-orm/src/bun-sqlite/driver.ts +++ b/drizzle-orm/src/bun-sqlite/driver.ts @@ -75,7 +75,7 @@ function construct = Record; ( db).$client = client; diff --git a/drizzle-orm/src/bun-sqlite/session.ts b/drizzle-orm/src/bun-sqlite/session.ts index 88d7364614..4885c3e0b3 100644 --- a/drizzle-orm/src/bun-sqlite/session.ts +++ b/drizzle-orm/src/bun-sqlite/session.ts @@ -2,6 +2,7 @@ import type { Database, Statement as BunStatement } from 'bun:sqlite'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -19,6 +20,7 @@ import { mapResultRow } from '~/utils.ts'; export interface SQLiteBunSessionOptions { logger?: Logger; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -40,6 +42,7 @@ export class SQLiteBunSession< ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } exec(query: string): void { @@ -54,14 +57,16 @@ export class SQLiteBunSession< customResultMapper?: (rows: unknown[][]) => unknown, ): PreparedQuery { const stmt = this.client.prepare(query.sql); - return new PreparedQuery( - stmt, - query, - this.logger, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new PreparedQuery( + stmt, + query, + this.logger, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/d1/driver.ts b/drizzle-orm/src/d1/driver.ts index 1a688a3f04..271a2246ac 100644 --- a/drizzle-orm/src/d1/driver.ts +++ b/drizzle-orm/src/d1/driver.ts @@ -66,7 +66,11 @@ export function drizzle< }; } - const session = new SQLiteD1Session(client as D1Database, dialect, schema, { logger, cache: config.cache }); + const session = new SQLiteD1Session(client as D1Database, dialect, schema, { + logger, + cache: config.cache, + onError: config.onError, + }); const db = new DrizzleD1Database('async', dialect, session, schema) as DrizzleD1Database; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/d1/session.ts b/drizzle-orm/src/d1/session.ts index 363ea4a9a7..ad96ceba07 100644 --- a/drizzle-orm/src/d1/session.ts +++ b/drizzle-orm/src/d1/session.ts @@ -4,6 +4,7 @@ import type { BatchItem } from '~/batch.ts'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -23,6 +24,7 @@ import { mapResultRow } from '~/utils.ts'; export interface SQLiteD1SessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -45,6 +47,7 @@ export class SQLiteD1Session< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -60,17 +63,19 @@ export class SQLiteD1Session< cacheConfig?: WithCacheConfig, ): D1PreparedQuery { const stmt = this.client.prepare(query.sql); - return new D1PreparedQuery( - stmt, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new D1PreparedQuery( + stmt, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/durable-sqlite/driver.ts b/drizzle-orm/src/durable-sqlite/driver.ts index 0be110084b..a6c2464084 100644 --- a/drizzle-orm/src/durable-sqlite/driver.ts +++ b/drizzle-orm/src/durable-sqlite/driver.ts @@ -52,7 +52,10 @@ export function drizzle< }; } - const session = new SQLiteDOSession(client as DurableObjectStorage, dialect, schema, { logger }); + const session = new SQLiteDOSession(client as DurableObjectStorage, dialect, schema, { + logger, + onError: config.onError, + }); const db = new DrizzleSqliteDODatabase('sync', dialect, session, schema) as DrizzleSqliteDODatabase; ( db).$client = client; diff --git a/drizzle-orm/src/durable-sqlite/session.ts b/drizzle-orm/src/durable-sqlite/session.ts index a514a95f95..e799adfcd4 100644 --- a/drizzle-orm/src/durable-sqlite/session.ts +++ b/drizzle-orm/src/durable-sqlite/session.ts @@ -1,4 +1,5 @@ import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -16,6 +17,7 @@ import { mapResultRow } from '~/utils.ts'; export interface SQLiteDOSessionOptions { logger?: Logger; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -40,6 +42,7 @@ export class SQLiteDOSession, TSchem ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } prepareQuery>( @@ -49,14 +52,16 @@ export class SQLiteDOSession, TSchem isResponseInArrayMode: boolean, customResultMapper?: (rows: unknown[][]) => unknown, ): SQLiteDOPreparedQuery { - return new SQLiteDOPreparedQuery( - this.client, - query, - this.logger, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new SQLiteDOPreparedQuery( + this.client, + query, + this.logger, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/errors.ts b/drizzle-orm/src/errors.ts index 913e2dd36e..32d8e3e804 100644 --- a/drizzle-orm/src/errors.ts +++ b/drizzle-orm/src/errors.ts @@ -11,19 +11,248 @@ export class DrizzleError extends Error { } export class DrizzleQueryError extends Error { + static readonly [entityKind]: string = 'DrizzleQueryError'; + constructor( public query: string, public params: any[], public override cause?: Error, ) { super(`Failed query: ${query}\nparams: ${params}`); - Error.captureStackTrace(this, DrizzleQueryError); + Error.captureStackTrace?.(this, DrizzleQueryError); // ES2022+: preserves original error on `.cause` if (cause) (this as any).cause = cause; } } +/** The normalized category of an integrity-constraint violation. */ +export type ConstraintViolationKind = 'unique' | 'not_null' | 'foreign_key' | 'check'; + +/** Best-effort metadata extracted from the underlying driver error. */ +export interface ConstraintViolationDetails { + constraintName?: string; + table?: string; + columns?: string[]; +} + +/** + * Base class for integrity-constraint violations, normalized across dialects. + * The original driver error is always available on `.cause`. + */ +export class DrizzleConstraintError extends DrizzleQueryError { + static override readonly [entityKind]: string = 'DrizzleConstraintError'; + + readonly kind: ConstraintViolationKind; + readonly constraintName?: string; + readonly table?: string; + readonly columns?: string[]; + + constructor( + kind: ConstraintViolationKind, + query: string, + params: any[], + cause: Error, + details: ConstraintViolationDetails = {}, + ) { + super(query, params, cause); + this.name = 'DrizzleConstraintError'; + this.kind = kind; + this.constraintName = details.constraintName; + this.table = details.table; + this.columns = details.columns; + Error.captureStackTrace?.(this, DrizzleConstraintError); + } +} + +export class UniqueConstraintError extends DrizzleConstraintError { + static override readonly [entityKind]: string = 'UniqueConstraintError'; + + constructor(query: string, params: any[], cause: Error, details?: ConstraintViolationDetails) { + super('unique', query, params, cause, details); + this.name = 'UniqueConstraintError'; + Error.captureStackTrace?.(this, UniqueConstraintError); + } +} + +export class NotNullConstraintError extends DrizzleConstraintError { + static override readonly [entityKind]: string = 'NotNullConstraintError'; + + constructor(query: string, params: any[], cause: Error, details?: ConstraintViolationDetails) { + super('not_null', query, params, cause, details); + this.name = 'NotNullConstraintError'; + Error.captureStackTrace?.(this, NotNullConstraintError); + } +} + +export class ForeignKeyConstraintError extends DrizzleConstraintError { + static override readonly [entityKind]: string = 'ForeignKeyConstraintError'; + + constructor(query: string, params: any[], cause: Error, details?: ConstraintViolationDetails) { + super('foreign_key', query, params, cause, details); + this.name = 'ForeignKeyConstraintError'; + Error.captureStackTrace?.(this, ForeignKeyConstraintError); + } +} + +export class CheckConstraintError extends DrizzleConstraintError { + static override readonly [entityKind]: string = 'CheckConstraintError'; + + constructor(query: string, params: any[], cause: Error, details?: ConstraintViolationDetails) { + super('check', query, params, cause, details); + this.name = 'CheckConstraintError'; + Error.captureStackTrace?.(this, CheckConstraintError); + } +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +/** + * Wrap a PostgreSQL driver error (pg, postgres.js, neon, pglite, vercel-postgres, …). + * Classification is driven by the SQLSTATE on `.code`, which is consistent across + * every Postgres driver; column/table/constraint names are best-effort. + * @internal + */ +export function wrapPgError(query: string, params: any[], error: Error): DrizzleQueryError { + const driverError = error as Record; + const code = firstString(driverError['code']); + if (code === undefined) return new DrizzleQueryError(query, params, error); + + const columnName = firstString(driverError['column'], driverError['column_name']); + const details: ConstraintViolationDetails = { + constraintName: firstString(driverError['constraint'], driverError['constraint_name']), + table: firstString(driverError['table'], driverError['table_name']), + columns: columnName ? [columnName] : undefined, + }; + + switch (code) { + case '23505': { + return new UniqueConstraintError(query, params, error, details); + } + case '23502': { + return new NotNullConstraintError(query, params, error, details); + } + case '23503': { + return new ForeignKeyConstraintError(query, params, error, details); + } + case '23514': { + return new CheckConstraintError(query, params, error, details); + } + default: { + return new DrizzleQueryError(query, params, error); + } + } +} + +/** + * Wrap a MySQL-protocol driver error (mysql2, SingleStore, TiDB, …). + * Classification is driven by the numeric `.errno`; names are parsed from `sqlMessage`. + * @internal + */ +export function wrapMySqlError(query: string, params: any[], error: Error): DrizzleQueryError { + const driverError = error as Record; + const errno = typeof driverError['errno'] === 'number' ? driverError['errno'] as number : undefined; + if (errno === undefined) return new DrizzleQueryError(query, params, error); + + const message = firstString(driverError['sqlMessage'], driverError['message']) ?? ''; + + switch (errno) { + case 1062: { // ER_DUP_ENTRY + // "Duplicate entry '...' for key 'constraint_name'" + const constraintName = message.match(/for key '([^']+)'/)?.[1]; + return new UniqueConstraintError(query, params, error, { constraintName }); + } + case 1048: { // ER_BAD_NULL_ERROR + // "Column 'col' cannot be null" + const column = message.match(/Column '([^']+)'/)?.[1]; + return new NotNullConstraintError(query, params, error, { columns: column ? [column] : undefined }); + } + case 1452: // ER_NO_REFERENCED_ROW_2 + case 1216: { // ER_NO_REFERENCED_ROW + // "... (`schema`.`table`, CONSTRAINT `fk_name` FOREIGN KEY ...)" + const constraintName = message.match(/CONSTRAINT `([^`]+)`/)?.[1]; + const table = message.match(/`[^`]*`\.`([^`]+)`/)?.[1]; + return new ForeignKeyConstraintError(query, params, error, { constraintName, table }); + } + case 3819: { // ER_CHECK_CONSTRAINT_VIOLATED + // "Check constraint 'chk_name' is violated." + const constraintName = message.match(/Check constraint '([^']+)'/)?.[1]; + return new CheckConstraintError(query, params, error, { constraintName }); + } + default: { + return new DrizzleQueryError(query, params, error); + } + } +} + +function parseSqliteColumns(message: string): { table?: string; columns?: string[] } { + // "UNIQUE constraint failed: users.email, users.name" + const list = message.match(/constraint failed: (.+)$/)?.[1]; + if (!list) return {}; + const pairs = list.split(',').map((part) => part.trim()).filter(Boolean); + const columns: string[] = []; + let table: string | undefined; + for (const pair of pairs) { + const dot = pair.indexOf('.'); + if (dot === -1) continue; + table ??= pair.slice(0, dot); + columns.push(pair.slice(dot + 1)); + } + return { table, columns: columns.length > 0 ? columns : undefined }; +} + +/** + * Wrap a SQLite driver error (better-sqlite3, bun:sqlite, libsql, d1, expo, op-sqlite, …). + * Prefers the extended `.code` (`SQLITE_CONSTRAINT_*`) and falls back to message text + * for drivers that don't surface extended result codes (e.g. libsql, d1). + * @internal + */ +export function wrapSqliteError(query: string, params: any[], error: Error): DrizzleQueryError { + const driverError = error as Record; + const code = firstString(driverError['code']); + const message = firstString(driverError['message']) ?? ''; + + if (code !== undefined && code.startsWith('SQLITE_CONSTRAINT')) { + switch (code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + return new UniqueConstraintError(query, params, error, parseSqliteColumns(message)); + } + case 'SQLITE_CONSTRAINT_NOTNULL': { + return new NotNullConstraintError(query, params, error, parseSqliteColumns(message)); + } + case 'SQLITE_CONSTRAINT_FOREIGNKEY': { + return new ForeignKeyConstraintError(query, params, error); + } + case 'SQLITE_CONSTRAINT_CHECK': { + const constraintName = message.match(/CHECK constraint failed: (\S+)/)?.[1]; + return new CheckConstraintError(query, params, error, { constraintName }); + } + } + } + + if (message.includes('UNIQUE constraint failed')) { + return new UniqueConstraintError(query, params, error, parseSqliteColumns(message)); + } + if (message.includes('NOT NULL constraint failed')) { + return new NotNullConstraintError(query, params, error, parseSqliteColumns(message)); + } + if (message.includes('FOREIGN KEY constraint failed')) { + return new ForeignKeyConstraintError(query, params, error); + } + if (message.includes('CHECK constraint failed')) { + const constraintName = message.match(/CHECK constraint failed: (\S+)/)?.[1]; + return new CheckConstraintError(query, params, error, { constraintName }); + } + + return new DrizzleQueryError(query, params, error); +} + export class TransactionRollbackError extends DrizzleError { static override readonly [entityKind]: string = 'TransactionRollbackError'; diff --git a/drizzle-orm/src/expo-sqlite/driver.ts b/drizzle-orm/src/expo-sqlite/driver.ts index fce53eed21..9a32403e57 100644 --- a/drizzle-orm/src/expo-sqlite/driver.ts +++ b/drizzle-orm/src/expo-sqlite/driver.ts @@ -45,7 +45,7 @@ export function drizzle = Record; ( db).$client = client; diff --git a/drizzle-orm/src/expo-sqlite/session.ts b/drizzle-orm/src/expo-sqlite/session.ts index cb880ac6e3..92cc136b3d 100644 --- a/drizzle-orm/src/expo-sqlite/session.ts +++ b/drizzle-orm/src/expo-sqlite/session.ts @@ -1,5 +1,6 @@ import type { SQLiteDatabase, SQLiteRunResult, SQLiteStatement } from 'expo-sqlite'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -18,6 +19,7 @@ import { mapResultRow } from '~/utils.ts'; export interface ExpoSQLiteSessionOptions { logger?: Logger; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -38,6 +40,7 @@ export class ExpoSQLiteSession< ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } prepareQuery>( @@ -48,14 +51,16 @@ export class ExpoSQLiteSession< customResultMapper?: (rows: unknown[][]) => unknown, ): ExpoSQLitePreparedQuery { const stmt = this.client.prepareSync(query.sql); - return new ExpoSQLitePreparedQuery( - stmt, - query, - this.logger, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new ExpoSQLitePreparedQuery( + stmt, + query, + this.logger, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/gel-core/session.ts b/drizzle-orm/src/gel-core/session.ts index d7844e788d..9b179f6f3e 100644 --- a/drizzle-orm/src/gel-core/session.ts +++ b/drizzle-orm/src/gel-core/session.ts @@ -38,6 +38,16 @@ export abstract class GelPreparedQuery implements } } + /** Set by the session; called with the wrapped error before it is thrown. @internal */ + onError?: (error: DrizzleQueryError) => void; + + /** @internal */ + protected mapError(queryString: string, params: any[], e: unknown): DrizzleQueryError { + const error = new DrizzleQueryError(queryString, params, e as Error); + this.onError?.(error); + return error; + } + /** @internal */ protected async queryWithCache( queryString: string, @@ -48,7 +58,7 @@ export abstract class GelPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -57,7 +67,7 @@ export abstract class GelPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -75,7 +85,7 @@ export abstract class GelPreparedQuery implements ]); return res; } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -84,7 +94,7 @@ export abstract class GelPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -100,7 +110,7 @@ export abstract class GelPreparedQuery implements try { result = await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } // put actual key @@ -121,7 +131,7 @@ export abstract class GelPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -156,8 +166,17 @@ export abstract class GelSession< > { static readonly [entityKind]: string = 'GelSession'; + /** Set by concrete sessions from the driver config; forwarded to prepared queries. @internal */ + protected onError?: (error: DrizzleQueryError) => void; + constructor(protected dialect: GelDialect) {} + /** @internal */ + attachErrorHandler>(query: T): T { + query.onError = this.onError; + return query; + } + abstract prepareQuery( query: Query, fields: SelectedFieldsOrdered | undefined, diff --git a/drizzle-orm/src/gel/driver.ts b/drizzle-orm/src/gel/driver.ts index 8825cf67fa..e67751997f 100644 --- a/drizzle-orm/src/gel/driver.ts +++ b/drizzle-orm/src/gel/driver.ts @@ -1,6 +1,7 @@ import { type Client, type ConnectOptions, createClient } from 'gel'; import type { Cache } from '~/cache/core/index.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { GelDatabase } from '~/gel-core/db.ts'; import { GelDialect } from '~/gel-core/dialect.ts'; import type { GelQueryResultHKT } from '~/gel-core/session.ts'; @@ -19,6 +20,7 @@ import { GelDbSession } from './session.ts'; export interface GelDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class GelDriver { @@ -36,6 +38,7 @@ export class GelDriver { return new GelDbSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -73,7 +76,7 @@ function construct< }; } - const driver = new GelDriver(client, dialect, { logger, cache: config.cache }); + const driver = new GelDriver(client, dialect, { logger, cache: config.cache, onError: config.onError }); const session = driver.createSession(schema); const db = new GelJsDatabase(dialect, session, schema as any) as GelJsDatabase; ( db).$client = client; diff --git a/drizzle-orm/src/gel/session.ts b/drizzle-orm/src/gel/session.ts index d902541e63..d49a184628 100644 --- a/drizzle-orm/src/gel/session.ts +++ b/drizzle-orm/src/gel/session.ts @@ -3,6 +3,7 @@ import type { Transaction } from 'gel/dist/transaction'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { GelDialect } from '~/gel-core/dialect.ts'; import type { SelectedFieldsOrdered } from '~/gel-core/query-builders/select.types.ts'; import { GelPreparedQuery, GelSession, GelTransaction, type PreparedQueryConfig } from '~/gel-core/session.ts'; @@ -104,6 +105,7 @@ export class GelDbPreparedQuery extends GelPrepar export interface GelSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class GelDbSession, TSchema extends TablesRelationalConfig> @@ -123,6 +125,7 @@ export class GelDbSession, TSchema e super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -137,17 +140,19 @@ export class GelDbSession, TSchema e }, cacheConfig?: WithCacheConfig, ): GelDbPreparedQuery { - return new GelDbPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new GelDbPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/libsql/driver-core.ts b/drizzle-orm/src/libsql/driver-core.ts index c427637e82..5cfb0ed8ee 100644 --- a/drizzle-orm/src/libsql/driver-core.ts +++ b/drizzle-orm/src/libsql/driver-core.ts @@ -56,7 +56,13 @@ export function construct< }; } - const session = new LibSQLSession(client, dialect, schema, { logger, cache: config.cache }, undefined); + const session = new LibSQLSession( + client, + dialect, + schema, + { logger, cache: config.cache, onError: config.onError }, + undefined, + ); const db = new LibSQLDatabase('async', dialect, session, schema) as LibSQLDatabase; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/libsql/session.ts b/drizzle-orm/src/libsql/session.ts index b4c331068b..9d0240bf0e 100644 --- a/drizzle-orm/src/libsql/session.ts +++ b/drizzle-orm/src/libsql/session.ts @@ -3,6 +3,7 @@ import type { BatchItem as BatchItem } from '~/batch.ts'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -22,6 +23,7 @@ import { mapResultRow } from '~/utils.ts'; export interface LibSQLSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -45,6 +47,7 @@ export class LibSQLSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery>( @@ -59,18 +62,20 @@ export class LibSQLSession< }, cacheConfig?: WithCacheConfig, ): LibSQLPreparedQuery { - return new LibSQLPreparedQuery( - this.client, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - this.tx, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new LibSQLPreparedQuery( + this.client, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + this.tx, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/mysql-core/session.ts b/drizzle-orm/src/mysql-core/session.ts index d4ae50f9b7..d09bfab243 100644 --- a/drizzle-orm/src/mysql-core/session.ts +++ b/drizzle-orm/src/mysql-core/session.ts @@ -1,7 +1,7 @@ import { type Cache, hashQuery, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind, is } from '~/entity.ts'; -import { DrizzleQueryError, TransactionRollbackError } from '~/errors.ts'; +import { type DrizzleQueryError, TransactionRollbackError, wrapMySqlError } from '~/errors.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; import { type Query, type SQL, sql } from '~/sql/sql.ts'; import type { Assume, Equal } from '~/utils.ts'; @@ -66,6 +66,16 @@ export abstract class MySqlPreparedQuery { } } + /** Set by the session; called with the wrapped error before it is thrown. @internal */ + onError?: (error: DrizzleQueryError) => void; + + /** @internal */ + protected mapError(queryString: string, params: any[], e: unknown): DrizzleQueryError { + const error = wrapMySqlError(queryString, params, e as Error); + this.onError?.(error); + return error; + } + /** @internal */ protected async queryWithCache( queryString: string, @@ -76,7 +86,7 @@ export abstract class MySqlPreparedQuery { try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -85,7 +95,7 @@ export abstract class MySqlPreparedQuery { try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -103,7 +113,7 @@ export abstract class MySqlPreparedQuery { ]); return res; } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -112,7 +122,7 @@ export abstract class MySqlPreparedQuery { try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -128,7 +138,7 @@ export abstract class MySqlPreparedQuery { try { result = await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } // put actual key @@ -149,7 +159,7 @@ export abstract class MySqlPreparedQuery { try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -175,8 +185,17 @@ export abstract class MySqlSession< > { static readonly [entityKind]: string = 'MySqlSession'; + /** Set by concrete sessions from the driver config; forwarded to prepared queries. @internal */ + protected onError?: (error: DrizzleQueryError) => void; + constructor(protected dialect: MySqlDialect) {} + /** @internal */ + attachErrorHandler>(query: T): T { + query.onError = this.onError; + return query; + } + abstract prepareQuery( query: Query, fields: SelectedFieldsOrdered | undefined, diff --git a/drizzle-orm/src/mysql-proxy/driver.ts b/drizzle-orm/src/mysql-proxy/driver.ts index bb0c21134f..6b7b99aa82 100644 --- a/drizzle-orm/src/mysql-proxy/driver.ts +++ b/drizzle-orm/src/mysql-proxy/driver.ts @@ -48,6 +48,6 @@ export function drizzle = Record; } diff --git a/drizzle-orm/src/mysql-proxy/session.ts b/drizzle-orm/src/mysql-proxy/session.ts index 452402d8c2..342d89cf0e 100644 --- a/drizzle-orm/src/mysql-proxy/session.ts +++ b/drizzle-orm/src/mysql-proxy/session.ts @@ -3,6 +3,7 @@ import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { MySqlDialect } from '~/mysql-core/dialect.ts'; @@ -27,6 +28,7 @@ export type MySqlRawQueryResult = [ResultSetHeader, FieldPacket[]]; export interface MySqlRemoteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class MySqlRemoteSession< @@ -47,6 +49,7 @@ export class MySqlRemoteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -61,18 +64,20 @@ export class MySqlRemoteSession< }, cacheConfig?: WithCacheConfig, ): PreparedQueryKind { - return new PreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new PreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ) as PreparedQueryKind; } diff --git a/drizzle-orm/src/mysql2/driver.ts b/drizzle-orm/src/mysql2/driver.ts index 4d1db742ae..c5acb29a63 100644 --- a/drizzle-orm/src/mysql2/driver.ts +++ b/drizzle-orm/src/mysql2/driver.ts @@ -2,6 +2,7 @@ import { type Connection as CallbackConnection, createPool, type Pool as Callbac import type { Connection, Pool } from 'mysql2/promise'; import type { Cache } from '~/cache/core/index.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { MySqlDatabase } from '~/mysql-core/db.ts'; @@ -21,6 +22,7 @@ import { MySql2Session } from './session.ts'; export interface MySqlDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class MySql2Driver { @@ -41,6 +43,7 @@ export class MySql2Driver { logger: this.options.logger, mode, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -98,7 +101,11 @@ function construct< const mode = config.mode ?? 'default'; - const driver = new MySql2Driver(clientForInstance as MySql2Client, dialect, { logger, cache: config.cache }); + const driver = new MySql2Driver(clientForInstance as MySql2Client, dialect, { + logger, + cache: config.cache, + onError: config.onError, + }); const session = driver.createSession(schema, mode); const db = new MySql2Database(dialect, session, schema as any, mode) as MySql2Database; ( db).$client = client; diff --git a/drizzle-orm/src/mysql2/session.ts b/drizzle-orm/src/mysql2/session.ts index 7c0b35c9ce..677b069d10 100644 --- a/drizzle-orm/src/mysql2/session.ts +++ b/drizzle-orm/src/mysql2/session.ts @@ -14,6 +14,7 @@ import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { MySqlDialect } from '~/mysql-core/dialect.ts'; @@ -198,6 +199,7 @@ export class MySql2PreparedQuery extends MyS export interface MySql2SessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; mode: Mode; } @@ -220,6 +222,7 @@ export class MySql2Session< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; this.mode = options.mode; } @@ -237,18 +240,20 @@ export class MySql2Session< ): PreparedQueryKind { // Add returningId fields // Each driver gets them from response from database - return new MySql2PreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new MySql2PreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ) as PreparedQueryKind; } diff --git a/drizzle-orm/src/neon-http/driver.ts b/drizzle-orm/src/neon-http/driver.ts index 53ed6e2117..d257ea4500 100644 --- a/drizzle-orm/src/neon-http/driver.ts +++ b/drizzle-orm/src/neon-http/driver.ts @@ -3,6 +3,7 @@ import { neon, types } from '@neondatabase/serverless'; import type { BatchItem, BatchResponse } from '~/batch.ts'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -15,6 +16,7 @@ import { type NeonHttpClient, type NeonHttpQueryResultHKT, NeonHttpSession } fro export interface NeonDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NeonHttpDriver { @@ -34,6 +36,7 @@ export class NeonHttpDriver { return new NeonHttpSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } @@ -151,7 +154,7 @@ function construct< }; } - const driver = new NeonHttpDriver(client, dialect, { logger, cache: config.cache }); + const driver = new NeonHttpDriver(client, dialect, { logger, cache: config.cache, onError: config.onError }); const session = driver.createSession(schema); const db = new NeonHttpDatabase( diff --git a/drizzle-orm/src/neon-http/session.ts b/drizzle-orm/src/neon-http/session.ts index 567c464710..0bf2a9ce00 100644 --- a/drizzle-orm/src/neon-http/session.ts +++ b/drizzle-orm/src/neon-http/session.ts @@ -3,6 +3,7 @@ import type { BatchItem } from '~/batch.ts'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; @@ -144,6 +145,7 @@ export class NeonHttpPreparedQuery extends PgPrep export interface NeonHttpSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NeonHttpSession< @@ -169,6 +171,7 @@ export class NeonHttpSession< this.clientQuery = (client as any).query ?? client as any; this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -183,16 +186,18 @@ export class NeonHttpSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new NeonHttpPreparedQuery( - this.client, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new NeonHttpPreparedQuery( + this.client, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/neon-serverless/driver.ts b/drizzle-orm/src/neon-serverless/driver.ts index f226c026e8..5e45ce6045 100644 --- a/drizzle-orm/src/neon-serverless/driver.ts +++ b/drizzle-orm/src/neon-serverless/driver.ts @@ -1,6 +1,7 @@ import { neonConfig, Pool, type PoolConfig } from '@neondatabase/serverless'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -18,6 +19,7 @@ import { NeonSession } from './session.ts'; export interface NeonDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NeonDriver { @@ -36,6 +38,7 @@ export class NeonDriver { return new NeonSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -76,7 +79,7 @@ function construct< }; } - const driver = new NeonDriver(client, dialect, { logger, cache: config.cache }); + const driver = new NeonDriver(client, dialect, { logger, cache: config.cache, onError: config.onError }); const session = driver.createSession(schema); const db = new NeonDatabase(dialect, session, schema as any) as NeonDatabase; ( db).$client = client; diff --git a/drizzle-orm/src/neon-serverless/session.ts b/drizzle-orm/src/neon-serverless/session.ts index bc60642a18..7be581eeee 100644 --- a/drizzle-orm/src/neon-serverless/session.ts +++ b/drizzle-orm/src/neon-serverless/session.ts @@ -11,6 +11,7 @@ import { import { type Cache, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; @@ -183,6 +184,7 @@ export class NeonPreparedQuery extends PgPrepared export interface NeonSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NeonSession< @@ -203,6 +205,7 @@ export class NeonSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -217,18 +220,20 @@ export class NeonSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new NeonPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - name, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new NeonPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + name, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/netlify-db/driver.ts b/drizzle-orm/src/netlify-db/driver.ts index 558f98080b..0c38dacbcb 100644 --- a/drizzle-orm/src/netlify-db/driver.ts +++ b/drizzle-orm/src/netlify-db/driver.ts @@ -3,6 +3,7 @@ import { getDatabase } from '@netlify/db'; import type { BatchItem, BatchResponse } from '~/batch.ts'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import type { NeonHttpClient, NeonHttpQueryResultHKT } from '~/neon-http/session.ts'; @@ -32,6 +33,7 @@ export type DrizzleClient = ServerlessDrizzleClient | ServerDrizzleClient; export interface NetlifyDbDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NetlifyDbDriver { @@ -52,6 +54,7 @@ export class NetlifyDbDriver { return new NetlifyDbSession(this.httpClient, this.pool, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } @@ -112,7 +115,11 @@ function construct< }; } - const driver = new NetlifyDbDriver(httpClient, pool, dialect, { logger, cache: config.cache }); + const driver = new NetlifyDbDriver(httpClient, pool, dialect, { + logger, + cache: config.cache, + onError: config.onError, + }); const session = driver.createSession(schema); const db = new NetlifyDbDatabase( diff --git a/drizzle-orm/src/netlify-db/session.ts b/drizzle-orm/src/netlify-db/session.ts index 9a4375f13b..ec7fd36367 100644 --- a/drizzle-orm/src/netlify-db/session.ts +++ b/drizzle-orm/src/netlify-db/session.ts @@ -78,6 +78,7 @@ export class NetlifyDbSession< this.clientQuery = (httpClient as any).query ?? httpClient as any; this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -92,16 +93,18 @@ export class NetlifyDbSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new NeonHttpPreparedQuery( - this.httpClient, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new NeonHttpPreparedQuery( + this.httpClient, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } @@ -202,6 +205,7 @@ export class NetlifyDbWsSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -216,18 +220,20 @@ export class NetlifyDbWsSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new NeonPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - name, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new NeonPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + name, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/node-postgres/driver.ts b/drizzle-orm/src/node-postgres/driver.ts index 1e425d102c..08694a1dd6 100644 --- a/drizzle-orm/src/node-postgres/driver.ts +++ b/drizzle-orm/src/node-postgres/driver.ts @@ -1,6 +1,7 @@ import pg, { type Pool, type PoolConfig } from 'pg'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -18,6 +19,7 @@ import { NodePgSession } from './session.ts'; export interface PgDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NodePgDriver { @@ -36,6 +38,7 @@ export class NodePgDriver { return new NodePgSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -76,7 +79,7 @@ function construct< }; } - const driver = new NodePgDriver(client, dialect, { logger, cache: config.cache }); + const driver = new NodePgDriver(client, dialect, { logger, cache: config.cache, onError: config.onError }); const session = driver.createSession(schema); const db = new NodePgDatabase(dialect, session, schema as any) as NodePgDatabase; ( db).$client = client; diff --git a/drizzle-orm/src/node-postgres/session.ts b/drizzle-orm/src/node-postgres/session.ts index 8c668a695b..569b99a0f4 100644 --- a/drizzle-orm/src/node-postgres/session.ts +++ b/drizzle-orm/src/node-postgres/session.ts @@ -3,6 +3,7 @@ import pg from 'pg'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; import { PgTransaction } from '~/pg-core/index.ts'; @@ -196,6 +197,7 @@ export class NodePgPreparedQuery extends PgPrepar export interface NodePgSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class NodePgSession< @@ -216,6 +218,7 @@ export class NodePgSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -230,18 +233,20 @@ export class NodePgSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new NodePgPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - name, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new NodePgPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + name, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/op-sqlite/driver.ts b/drizzle-orm/src/op-sqlite/driver.ts index 07b21b61ab..491c3a4c4d 100644 --- a/drizzle-orm/src/op-sqlite/driver.ts +++ b/drizzle-orm/src/op-sqlite/driver.ts @@ -45,7 +45,11 @@ export function drizzle = Record; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/op-sqlite/session.ts b/drizzle-orm/src/op-sqlite/session.ts index a8a515c42d..8525004e29 100644 --- a/drizzle-orm/src/op-sqlite/session.ts +++ b/drizzle-orm/src/op-sqlite/session.ts @@ -2,6 +2,7 @@ import type { OPSQLiteConnection, QueryResult } from '@op-engineering/op-sqlite' import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -21,6 +22,7 @@ import { mapResultRow } from '~/utils.ts'; export interface OPSQLiteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -43,6 +45,7 @@ export class OPSQLiteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery>( @@ -57,17 +60,19 @@ export class OPSQLiteSession< }, cacheConfig?: WithCacheConfig, ): OPSQLitePreparedQuery { - return new OPSQLitePreparedQuery( - this.client, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new OPSQLitePreparedQuery( + this.client, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/pg-core/session.ts b/drizzle-orm/src/pg-core/session.ts index 2b111fa828..5189ee1f5f 100644 --- a/drizzle-orm/src/pg-core/session.ts +++ b/drizzle-orm/src/pg-core/session.ts @@ -1,7 +1,7 @@ import { type Cache, hashQuery, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind, is } from '~/entity.ts'; -import { DrizzleQueryError, TransactionRollbackError } from '~/errors.ts'; +import { type DrizzleQueryError, TransactionRollbackError, wrapPgError } from '~/errors.ts'; import type { TablesRelationalConfig } from '~/relations.ts'; import type { PreparedQuery } from '~/session.ts'; import { type Query, type SQL, sql } from '~/sql/index.ts'; @@ -60,6 +60,16 @@ export abstract class PgPreparedQuery implements /** @internal */ joinsNotNullableMap?: Record; + /** Set by the session; called with the wrapped error before it is thrown. @internal */ + onError?: (error: DrizzleQueryError) => void; + + /** @internal */ + protected mapError(queryString: string, params: any[], e: unknown): DrizzleQueryError { + const error = wrapPgError(queryString, params, e as Error); + this.onError?.(error); + return error; + } + /** @internal */ protected async queryWithCache( queryString: string, @@ -70,7 +80,7 @@ export abstract class PgPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -79,7 +89,7 @@ export abstract class PgPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -97,7 +107,7 @@ export abstract class PgPreparedQuery implements ]); return res; } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -106,7 +116,7 @@ export abstract class PgPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -122,7 +132,7 @@ export abstract class PgPreparedQuery implements try { result = await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } // put actual key await this.cache.put( @@ -142,7 +152,7 @@ export abstract class PgPreparedQuery implements try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -172,8 +182,17 @@ export abstract class PgSession< > { static readonly [entityKind]: string = 'PgSession'; + /** Set by concrete sessions from the driver config; forwarded to prepared queries. @internal */ + protected onError?: (error: DrizzleQueryError) => void; + constructor(protected dialect: PgDialect) {} + /** @internal */ + attachErrorHandler>(query: T): T { + query.onError = this.onError; + return query; + } + abstract prepareQuery( query: Query, fields: SelectedFieldsOrdered | undefined, diff --git a/drizzle-orm/src/pg-proxy/driver.ts b/drizzle-orm/src/pg-proxy/driver.ts index 6016dd34cd..d9fd21fbb5 100644 --- a/drizzle-orm/src/pg-proxy/driver.ts +++ b/drizzle-orm/src/pg-proxy/driver.ts @@ -50,7 +50,11 @@ export function drizzle = Record; ( db).$cache = config.cache; if (( db).$cache) { diff --git a/drizzle-orm/src/pg-proxy/session.ts b/drizzle-orm/src/pg-proxy/session.ts index 5f098a8d25..bf22aeefaa 100644 --- a/drizzle-orm/src/pg-proxy/session.ts +++ b/drizzle-orm/src/pg-proxy/session.ts @@ -1,6 +1,7 @@ import { type Cache, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; @@ -18,6 +19,7 @@ import type { RemoteCallback } from './driver.ts'; export interface PgRemoteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PgRemoteSession< @@ -38,6 +40,7 @@ export class PgRemoteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -52,18 +55,20 @@ export class PgRemoteSession< }, cacheConfig?: WithCacheConfig, ): PreparedQuery { - return new PreparedQuery( - this.client, - query.sql, - query.params, - query.typings, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new PreparedQuery( + this.client, + query.sql, + query.params, + query.typings, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/pglite/driver.ts b/drizzle-orm/src/pglite/driver.ts index 9d5f52a34b..31f28c5a9d 100644 --- a/drizzle-orm/src/pglite/driver.ts +++ b/drizzle-orm/src/pglite/driver.ts @@ -1,6 +1,7 @@ import { PGlite, type PGliteOptions } from '@electric-sql/pglite'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -18,6 +19,7 @@ import { PgliteSession } from './session.ts'; export interface PgDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PgliteDriver { @@ -36,6 +38,7 @@ export class PgliteDriver { return new PgliteSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -73,7 +76,7 @@ function construct = Record; ( db).$client = client; diff --git a/drizzle-orm/src/pglite/session.ts b/drizzle-orm/src/pglite/session.ts index 7e15ceff30..32f576e5d9 100644 --- a/drizzle-orm/src/pglite/session.ts +++ b/drizzle-orm/src/pglite/session.ts @@ -1,5 +1,6 @@ import type { PGlite, QueryOptions, Results, Row, Transaction } from '@electric-sql/pglite'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; import { PgTransaction } from '~/pg-core/index.ts'; @@ -118,6 +119,7 @@ export class PglitePreparedQuery extends PgPrepar export interface PgliteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PgliteSession< @@ -138,6 +140,7 @@ export class PgliteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -152,18 +155,20 @@ export class PgliteSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new PglitePreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - name, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new PglitePreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + name, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/planetscale-serverless/driver.ts b/drizzle-orm/src/planetscale-serverless/driver.ts index c03086f91c..167cad8687 100644 --- a/drizzle-orm/src/planetscale-serverless/driver.ts +++ b/drizzle-orm/src/planetscale-serverless/driver.ts @@ -1,6 +1,7 @@ import type { Config } from '@planetscale/database'; import { Client } from '@planetscale/database'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { MySqlDatabase } from '~/mysql-core/db.ts'; @@ -18,6 +19,7 @@ import { PlanetscaleSession } from './session.ts'; export interface PlanetscaleSDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PlanetScaleDatabase< @@ -73,7 +75,11 @@ const db = drizzle(client); }; } - const session = new PlanetscaleSession(client, dialect, undefined, schema, { logger, cache: config.cache }); + const session = new PlanetscaleSession(client, dialect, undefined, schema, { + logger, + cache: config.cache, + onError: config.onError, + }); const db = new PlanetScaleDatabase(dialect, session, schema as any, 'planetscale') as PlanetScaleDatabase; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/planetscale-serverless/session.ts b/drizzle-orm/src/planetscale-serverless/session.ts index 471aed4a28..0290f5b6bf 100644 --- a/drizzle-orm/src/planetscale-serverless/session.ts +++ b/drizzle-orm/src/planetscale-serverless/session.ts @@ -3,6 +3,7 @@ import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { MySqlDialect } from '~/mysql-core/dialect.ts'; @@ -113,6 +114,7 @@ export class PlanetScalePreparedQuery extend export interface PlanetscaleSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PlanetscaleSession< @@ -136,6 +138,7 @@ export class PlanetscaleSession< this.client = tx ?? baseClient; this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -150,18 +153,20 @@ export class PlanetscaleSession< }, cacheConfig?: WithCacheConfig, ): MySqlPreparedQuery { - return new PlanetScalePreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new PlanetScalePreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ); } diff --git a/drizzle-orm/src/postgres-js/driver.ts b/drizzle-orm/src/postgres-js/driver.ts index 411df042da..6ce2233649 100644 --- a/drizzle-orm/src/postgres-js/driver.ts +++ b/drizzle-orm/src/postgres-js/driver.ts @@ -56,7 +56,11 @@ function construct = Record; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/postgres-js/session.ts b/drizzle-orm/src/postgres-js/session.ts index 3673dd8b30..72d1ab563e 100644 --- a/drizzle-orm/src/postgres-js/session.ts +++ b/drizzle-orm/src/postgres-js/session.ts @@ -2,6 +2,7 @@ import type { Row, RowList, Sql, TransactionSql } from 'postgres'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { PgDialect } from '~/pg-core/dialect.ts'; @@ -102,6 +103,7 @@ export class PostgresJsPreparedQuery extends PgPr export interface PostgresJsSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class PostgresJsSession< @@ -124,6 +126,7 @@ export class PostgresJsSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -138,17 +141,19 @@ export class PostgresJsSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new PostgresJsPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new PostgresJsPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/prisma/mysql/driver.ts b/drizzle-orm/src/prisma/mysql/driver.ts index c6ca143df1..56d04b654e 100644 --- a/drizzle-orm/src/prisma/mysql/driver.ts +++ b/drizzle-orm/src/prisma/mysql/driver.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client/extension'; import { Prisma } from '@prisma/client'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { MySqlDatabase, MySqlDialect } from '~/mysql-core/index.ts'; @@ -15,9 +16,13 @@ export class PrismaMySqlDatabase { static override readonly [entityKind]: string = 'PrismaMySqlDatabase'; - constructor(client: PrismaClient, logger: Logger | undefined) { + constructor( + client: PrismaClient, + logger: Logger | undefined, + onError: ((error: DrizzleQueryError) => void) | undefined, + ) { const dialect = new MySqlDialect(); - super(dialect, new PrismaMySqlSession(dialect, client, { logger }), undefined, 'default'); + super(dialect, new PrismaMySqlSession(dialect, client, { logger, onError }), undefined, 'default'); } } @@ -35,7 +40,7 @@ export function drizzle(config: PrismaMySqlConfig = {}) { return client.$extends({ name: 'drizzle', client: { - $drizzle: new PrismaMySqlDatabase(client, logger), + $drizzle: new PrismaMySqlDatabase(client, logger, config.onError), }, }); }); diff --git a/drizzle-orm/src/prisma/mysql/session.ts b/drizzle-orm/src/prisma/mysql/session.ts index 377c40fa6b..2acb2c7fd0 100644 --- a/drizzle-orm/src/prisma/mysql/session.ts +++ b/drizzle-orm/src/prisma/mysql/session.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from '@prisma/client/extension'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import type { MySqlDialect, @@ -38,6 +39,7 @@ export class PrismaMySqlPreparedQuery extends MySqlPreparedQuery void; } export class PrismaMySqlSession extends MySqlSession { @@ -52,6 +54,7 @@ export class PrismaMySqlSession extends MySqlSession { ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } override execute(query: SQL): Promise { @@ -65,7 +68,7 @@ export class PrismaMySqlSession extends MySqlSession { override prepareQuery( query: Query, ): MySqlPreparedQuery { - return new PrismaMySqlPreparedQuery(this.prisma, query, this.logger); + return this.attachErrorHandler(new PrismaMySqlPreparedQuery(this.prisma, query, this.logger)); } override transaction( diff --git a/drizzle-orm/src/prisma/pg/driver.ts b/drizzle-orm/src/prisma/pg/driver.ts index f9038d8a13..4186d64163 100644 --- a/drizzle-orm/src/prisma/pg/driver.ts +++ b/drizzle-orm/src/prisma/pg/driver.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client/extension'; import { Prisma } from '@prisma/client'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase, PgDialect } from '~/pg-core/index.ts'; @@ -13,9 +14,13 @@ import { PrismaPgSession } from './session.ts'; export class PrismaPgDatabase extends PgDatabase> { static override readonly [entityKind]: string = 'PrismaPgDatabase'; - constructor(client: PrismaClient, logger: Logger | undefined) { + constructor( + client: PrismaClient, + logger: Logger | undefined, + onError: ((error: DrizzleQueryError) => void) | undefined, + ) { const dialect = new PgDialect(); - super(dialect, new PrismaPgSession(dialect, client, { logger }), undefined); + super(dialect, new PrismaPgSession(dialect, client, { logger, onError }), undefined); } } @@ -33,7 +38,7 @@ export function drizzle(config: PrismaPgConfig = {}) { return client.$extends({ name: 'drizzle', client: { - $drizzle: new PrismaPgDatabase(client, logger), + $drizzle: new PrismaPgDatabase(client, logger, config.onError), }, }); }); diff --git a/drizzle-orm/src/prisma/pg/session.ts b/drizzle-orm/src/prisma/pg/session.ts index 809c229b7e..bb7f8bc399 100644 --- a/drizzle-orm/src/prisma/pg/session.ts +++ b/drizzle-orm/src/prisma/pg/session.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from '@prisma/client/extension'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import type { PgDialect, @@ -41,6 +42,7 @@ export class PrismaPgPreparedQuery extends PgPreparedQuery void; } export class PrismaPgSession extends PgSession { @@ -55,6 +57,7 @@ export class PrismaPgSession extends PgSession { ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } override execute(query: SQL): Promise { @@ -62,7 +65,7 @@ export class PrismaPgSession extends PgSession { } override prepareQuery(query: Query): PgPreparedQuery { - return new PrismaPgPreparedQuery(this.prisma, query, this.logger); + return this.attachErrorHandler(new PrismaPgPreparedQuery(this.prisma, query, this.logger)); } override transaction( diff --git a/drizzle-orm/src/prisma/sqlite/driver.ts b/drizzle-orm/src/prisma/sqlite/driver.ts index 2a8f1e4c85..fdd605df81 100644 --- a/drizzle-orm/src/prisma/sqlite/driver.ts +++ b/drizzle-orm/src/prisma/sqlite/driver.ts @@ -20,7 +20,7 @@ export function drizzle(config: PrismaSQLiteConfig = {}) { } return Prisma.defineExtension((client) => { - const session = new PrismaSQLiteSession(client, dialect, { logger }); + const session = new PrismaSQLiteSession(client, dialect, { logger, onError: config.onError }); return client.$extends({ name: 'drizzle', diff --git a/drizzle-orm/src/prisma/sqlite/session.ts b/drizzle-orm/src/prisma/sqlite/session.ts index 3a10fddbda..09afbf4177 100644 --- a/drizzle-orm/src/prisma/sqlite/session.ts +++ b/drizzle-orm/src/prisma/sqlite/session.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from '@prisma/client/extension'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import type { Query } from '~/sql/sql.ts'; import { fillPlaceholders } from '~/sql/sql.ts'; @@ -57,6 +58,7 @@ export class PrismaSQLitePreparedQuery void; } export class PrismaSQLiteSession extends SQLiteSession<'async', unknown, Record, Record> { @@ -71,6 +73,7 @@ export class PrismaSQLiteSession extends SQLiteSession<'async', unknown, Record< ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } override prepareQuery>( @@ -78,7 +81,9 @@ export class PrismaSQLiteSession extends SQLiteSession<'async', unknown, Record< fields: SelectedFieldsOrdered | undefined, executeMethod: SQLiteExecuteMethod, ): PrismaSQLitePreparedQuery { - return new PrismaSQLitePreparedQuery(this.prisma, query, this.logger, executeMethod); + return this.attachErrorHandler( + new PrismaSQLitePreparedQuery(this.prisma, query, this.logger, executeMethod), + ); } override transaction( diff --git a/drizzle-orm/src/singlestore-core/session.ts b/drizzle-orm/src/singlestore-core/session.ts index 04a1ac6627..ff29022586 100644 --- a/drizzle-orm/src/singlestore-core/session.ts +++ b/drizzle-orm/src/singlestore-core/session.ts @@ -1,7 +1,7 @@ import { type Cache, hashQuery, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind, is } from '~/entity.ts'; -import { DrizzleQueryError, TransactionRollbackError } from '~/errors.ts'; +import { type DrizzleQueryError, TransactionRollbackError, wrapMySqlError } from '~/errors.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; import { type Query, type SQL, sql } from '~/sql/sql.ts'; import type { Assume, Equal } from '~/utils.ts'; @@ -64,6 +64,16 @@ export abstract class SingleStorePreparedQuery void; + + /** @internal */ + protected mapError(queryString: string, params: any[], e: unknown): DrizzleQueryError { + const error = wrapMySqlError(queryString, params, e as Error); + this.onError?.(error); + return error; + } + /** @internal */ protected async queryWithCache( queryString: string, @@ -74,7 +84,7 @@ export abstract class SingleStorePreparedQuery { static readonly [entityKind]: string = 'SingleStoreSession'; + /** Set by concrete sessions from the driver config; forwarded to prepared queries. @internal */ + protected onError?: (error: DrizzleQueryError) => void; + constructor(protected dialect: SingleStoreDialect) {} + /** @internal */ + attachErrorHandler>(query: T): T { + query.onError = this.onError; + return query; + } + abstract prepareQuery< T extends SingleStorePreparedQueryConfig, TPreparedQueryHKT extends SingleStorePreparedQueryHKT, diff --git a/drizzle-orm/src/singlestore-proxy/driver.ts b/drizzle-orm/src/singlestore-proxy/driver.ts index ea24ae2d8e..85082e1168 100644 --- a/drizzle-orm/src/singlestore-proxy/driver.ts +++ b/drizzle-orm/src/singlestore-proxy/driver.ts @@ -52,7 +52,7 @@ export function drizzle = Record; diff --git a/drizzle-orm/src/singlestore-proxy/session.ts b/drizzle-orm/src/singlestore-proxy/session.ts index 42cc8ecdef..f1ef2c5f60 100644 --- a/drizzle-orm/src/singlestore-proxy/session.ts +++ b/drizzle-orm/src/singlestore-proxy/session.ts @@ -1,6 +1,7 @@ import type { FieldPacket, ResultSetHeader } from 'mysql2/promise'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -24,6 +25,7 @@ export type SingleStoreRawQueryResult = [ResultSetHeader, FieldPacket[]]; export interface SingleStoreRemoteSessionOptions { logger?: Logger; + onError?: (error: DrizzleQueryError) => void; } export class SingleStoreRemoteSession< @@ -42,6 +44,7 @@ export class SingleStoreRemoteSession< ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } prepareQuery( @@ -51,15 +54,17 @@ export class SingleStoreRemoteSession< generatedIds?: Record[], returningIds?: SelectedFieldsOrdered, ): PreparedQueryKind { - return new PreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new PreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ) as PreparedQueryKind; } diff --git a/drizzle-orm/src/singlestore/driver.ts b/drizzle-orm/src/singlestore/driver.ts index ccea3e0e11..2fc53110f4 100644 --- a/drizzle-orm/src/singlestore/driver.ts +++ b/drizzle-orm/src/singlestore/driver.ts @@ -2,6 +2,7 @@ import { type Connection as CallbackConnection, createPool, type Pool as Callbac import type { Connection, Pool } from 'mysql2/promise'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { @@ -24,6 +25,7 @@ import { SingleStoreDriverSession } from './session.ts'; export interface SingleStoreDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class SingleStoreDriverDriver { @@ -42,6 +44,7 @@ export class SingleStoreDriverDriver { return new SingleStoreDriverSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -93,6 +96,7 @@ function construct< const driver = new SingleStoreDriverDriver(clientForInstance as SingleStoreDriverClient, dialect, { logger, cache: config.cache, + onError: config.onError, }); const session = driver.createSession(schema); const db = new SingleStoreDriverDatabase(dialect, session, schema as any) as SingleStoreDriverDatabase; diff --git a/drizzle-orm/src/singlestore/session.ts b/drizzle-orm/src/singlestore/session.ts index 0b05e1c311..c8505b1d40 100644 --- a/drizzle-orm/src/singlestore/session.ts +++ b/drizzle-orm/src/singlestore/session.ts @@ -14,6 +14,7 @@ import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -197,6 +198,7 @@ export class SingleStoreDriverPreparedQuery void; } export class SingleStoreDriverSession< @@ -217,6 +219,7 @@ export class SingleStoreDriverSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -233,18 +236,20 @@ export class SingleStoreDriverSession< ): PreparedQueryKind { // Add returningId fields // Each driver gets them from response from database - return new SingleStoreDriverPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new SingleStoreDriverPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ) as PreparedQueryKind; } diff --git a/drizzle-orm/src/sql-js/driver.ts b/drizzle-orm/src/sql-js/driver.ts index 994d80bc88..7e08883de5 100644 --- a/drizzle-orm/src/sql-js/driver.ts +++ b/drizzle-orm/src/sql-js/driver.ts @@ -40,6 +40,6 @@ export function drizzle = Record; } diff --git a/drizzle-orm/src/sql-js/session.ts b/drizzle-orm/src/sql-js/session.ts index a502791915..ce08c5cc29 100644 --- a/drizzle-orm/src/sql-js/session.ts +++ b/drizzle-orm/src/sql-js/session.ts @@ -1,5 +1,6 @@ import type { BindParams, Database } from 'sql.js'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -17,6 +18,7 @@ import { mapResultRow } from '~/utils.ts'; export interface SQLJsSessionOptions { logger?: Logger; + onError?: (error: DrizzleQueryError) => void; } type PreparedQueryConfig = Omit; @@ -37,6 +39,7 @@ export class SQLJsSession< ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); + this.onError = options.onError; } prepareQuery>( @@ -45,7 +48,9 @@ export class SQLJsSession< executeMethod: SQLiteExecuteMethod, isResponseInArrayMode: boolean, ): PreparedQuery { - return new PreparedQuery(this.client, query, this.logger, fields, executeMethod, isResponseInArrayMode); + return this.attachErrorHandler( + new PreparedQuery(this.client, query, this.logger, fields, executeMethod, isResponseInArrayMode), + ); } override transaction( diff --git a/drizzle-orm/src/sqlite-core/session.ts b/drizzle-orm/src/sqlite-core/session.ts index 3c68366a06..4e833fac5f 100644 --- a/drizzle-orm/src/sqlite-core/session.ts +++ b/drizzle-orm/src/sqlite-core/session.ts @@ -1,7 +1,7 @@ import { type Cache, hashQuery, NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind, is } from '~/entity.ts'; -import { DrizzleError, DrizzleQueryError, TransactionRollbackError } from '~/errors.ts'; +import { DrizzleError, type DrizzleQueryError, TransactionRollbackError, wrapSqliteError } from '~/errors.ts'; import { QueryPromise } from '~/query-promise.ts'; import type { TablesRelationalConfig } from '~/relations.ts'; import type { PreparedQuery } from '~/session.ts'; @@ -67,6 +67,16 @@ export abstract class SQLitePreparedQuery impleme } } + /** Set by the session; called with the wrapped error before it is thrown. @internal */ + onError?: (error: DrizzleQueryError) => void; + + /** @internal */ + protected mapError(queryString: string, params: any[], e: unknown): DrizzleQueryError { + const error = wrapSqliteError(queryString, params, e as Error); + this.onError?.(error); + return error; + } + /** @internal */ protected async queryWithCache( queryString: string, @@ -77,7 +87,7 @@ export abstract class SQLitePreparedQuery impleme try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -86,7 +96,7 @@ export abstract class SQLitePreparedQuery impleme try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -104,7 +114,7 @@ export abstract class SQLitePreparedQuery impleme ]); return res; } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -113,7 +123,7 @@ export abstract class SQLitePreparedQuery impleme try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -129,7 +139,7 @@ export abstract class SQLitePreparedQuery impleme try { result = await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } // put actual key @@ -150,7 +160,7 @@ export abstract class SQLitePreparedQuery impleme try { return await query(); } catch (e) { - throw new DrizzleQueryError(queryString, params, e as Error); + throw this.mapError(queryString, params, e); } } @@ -217,11 +227,20 @@ export abstract class SQLiteSession< > { static readonly [entityKind]: string = 'SQLiteSession'; + /** Set by concrete sessions from the driver config; forwarded to prepared queries. @internal */ + protected onError?: (error: DrizzleQueryError) => void; + constructor( /** @internal */ readonly dialect: { sync: SQLiteSyncDialect; async: SQLiteAsyncDialect }[TResultKind], ) {} + /** @internal */ + attachErrorHandler>(query: T): T { + query.onError = this.onError; + return query; + } + abstract prepareQuery( query: Query, fields: SelectedFieldsOrdered | undefined, diff --git a/drizzle-orm/src/sqlite-proxy/driver.ts b/drizzle-orm/src/sqlite-proxy/driver.ts index 2e8663a30a..655b771b5d 100644 --- a/drizzle-orm/src/sqlite-proxy/driver.ts +++ b/drizzle-orm/src/sqlite-proxy/driver.ts @@ -91,7 +91,11 @@ export function drizzle = Record; ( db).$cache = cache; if (( db).$cache) { diff --git a/drizzle-orm/src/sqlite-proxy/session.ts b/drizzle-orm/src/sqlite-proxy/session.ts index bdf94cc6cc..c87e7f5ea6 100644 --- a/drizzle-orm/src/sqlite-proxy/session.ts +++ b/drizzle-orm/src/sqlite-proxy/session.ts @@ -2,6 +2,7 @@ import type { BatchItem } from '~/batch.ts'; import { type Cache, NoopCache } from '~/cache/core/index.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; @@ -22,6 +23,7 @@ import type { AsyncBatchRemoteCallback, AsyncRemoteCallback, RemoteCallback, Sql export interface SQLiteRemoteSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export type PreparedQueryConfig = Omit; @@ -45,6 +47,7 @@ export class SQLiteRemoteSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery>( @@ -59,17 +62,19 @@ export class SQLiteRemoteSession< }, cacheConfig?: WithCacheConfig, ): RemotePreparedQuery { - return new RemotePreparedQuery( - this.client, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new RemotePreparedQuery( + this.client, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/tidb-serverless/driver.ts b/drizzle-orm/src/tidb-serverless/driver.ts index 4ec77c6983..ddaef6949c 100644 --- a/drizzle-orm/src/tidb-serverless/driver.ts +++ b/drizzle-orm/src/tidb-serverless/driver.ts @@ -1,5 +1,6 @@ import { type Config, connect, type Connection } from '@tidbcloud/serverless'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { MySqlDatabase } from '~/mysql-core/db.ts'; @@ -17,6 +18,7 @@ import { TiDBServerlessSession } from './session.ts'; export interface TiDBServerlessSDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class TiDBServerlessDatabase< @@ -52,7 +54,11 @@ function construct = Record; ( db).$client = client; ( db).$cache = config.cache; diff --git a/drizzle-orm/src/tidb-serverless/session.ts b/drizzle-orm/src/tidb-serverless/session.ts index bf555a5c7f..5d9e4df3a0 100644 --- a/drizzle-orm/src/tidb-serverless/session.ts +++ b/drizzle-orm/src/tidb-serverless/session.ts @@ -4,6 +4,7 @@ import type { WithCacheConfig } from '~/cache/core/types.ts'; import { Column } from '~/column.ts'; import { entityKind, is } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; import type { MySqlDialect } from '~/mysql-core/dialect.ts'; @@ -104,6 +105,7 @@ export class TiDBServerlessPreparedQuery ext export interface TiDBServerlessSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class TiDBServerlessSession< @@ -127,6 +129,7 @@ export class TiDBServerlessSession< this.client = tx ?? baseClient; this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -141,18 +144,20 @@ export class TiDBServerlessSession< }, cacheConfig?: WithCacheConfig, ): MySqlPreparedQuery { - return new TiDBServerlessPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - customResultMapper, - generatedIds, - returningIds, + return this.attachErrorHandler( + new TiDBServerlessPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + customResultMapper, + generatedIds, + returningIds, + ), ); } diff --git a/drizzle-orm/src/utils.ts b/drizzle-orm/src/utils.ts index 6f7659485f..c64a9492d3 100644 --- a/drizzle-orm/src/utils.ts +++ b/drizzle-orm/src/utils.ts @@ -2,6 +2,7 @@ import type { Cache } from './cache/core/cache.ts'; import type { AnyColumn } from './column.ts'; import { Column } from './column.ts'; import { is } from './entity.ts'; +import type { DrizzleQueryError } from './errors.ts'; import type { Logger } from './logger.ts'; import type { SelectedFieldsOrdered } from './operations.ts'; import type { TableLike } from './query-builders/select.types.ts'; @@ -238,6 +239,11 @@ export interface DrizzleConfig = Record< schema?: TSchema; casing?: Casing; cache?: Cache; + /** + * Called with the wrapped {@link DrizzleQueryError} whenever a query fails, before it is thrown. + * Return `void` to let the original error propagate, or throw to replace it. + */ + onError?: (error: DrizzleQueryError) => void; } export type ValidateShape = T extends ValidShape ? Exclude extends never ? TResult @@ -277,6 +283,7 @@ type ExpectedConfigShape = { }; schema?: Record; casing?: 'snake_case' | 'camelCase'; + onError?: (error: DrizzleQueryError) => void; }; // If this errors, you must update config shape checker function with new config specs @@ -312,6 +319,13 @@ export function isConfig(data: any): boolean { return true; } + if ('onError' in data) { + const type = typeof data['onError']; + if (type !== 'function' && type !== 'undefined') return false; + + return true; + } + if ('mode' in data) { if (data['mode'] !== 'default' || data['mode'] !== 'planetscale' || data['mode'] !== undefined) return false; diff --git a/drizzle-orm/src/vercel-postgres/driver.ts b/drizzle-orm/src/vercel-postgres/driver.ts index 4294b72af3..c2b6449d9a 100644 --- a/drizzle-orm/src/vercel-postgres/driver.ts +++ b/drizzle-orm/src/vercel-postgres/driver.ts @@ -1,6 +1,7 @@ import { sql } from '@vercel/postgres'; import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -17,6 +18,7 @@ import { type VercelPgClient, type VercelPgQueryResultHKT, VercelPgSession } fro export interface VercelPgDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class VercelPgDriver { @@ -35,6 +37,7 @@ export class VercelPgDriver { return new VercelPgSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } } @@ -72,7 +75,7 @@ function construct = Record; ( db).$client = client; diff --git a/drizzle-orm/src/vercel-postgres/session.ts b/drizzle-orm/src/vercel-postgres/session.ts index c20c0b223a..d1aac56242 100644 --- a/drizzle-orm/src/vercel-postgres/session.ts +++ b/drizzle-orm/src/vercel-postgres/session.ts @@ -12,6 +12,7 @@ import type { Cache } from '~/cache/core/cache.ts'; import { NoopCache } from '~/cache/core/cache.ts'; import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import { type Logger, NoopLogger } from '~/logger.ts'; import { type PgDialect, PgTransaction } from '~/pg-core/index.ts'; import type { SelectedFieldsOrdered } from '~/pg-core/query-builders/select.types.ts'; @@ -183,6 +184,7 @@ export class VercelPgPreparedQuery extends PgPrep export interface VercelPgSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class VercelPgSession< @@ -203,6 +205,7 @@ export class VercelPgSession< super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -217,18 +220,20 @@ export class VercelPgSession< }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new VercelPgPreparedQuery( - this.client, - query.sql, - query.params, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - name, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new VercelPgPreparedQuery( + this.client, + query.sql, + query.params, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + name, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/src/xata-http/driver.ts b/drizzle-orm/src/xata-http/driver.ts index e878ae6a98..15d4a59c65 100644 --- a/drizzle-orm/src/xata-http/driver.ts +++ b/drizzle-orm/src/xata-http/driver.ts @@ -1,5 +1,6 @@ import type { Cache } from '~/cache/core/cache.ts'; import { entityKind } from '~/entity.ts'; +import type { DrizzleQueryError } from '~/errors.ts'; import type { Logger } from '~/logger.ts'; import { DefaultLogger } from '~/logger.ts'; import { PgDatabase } from '~/pg-core/db.ts'; @@ -13,6 +14,7 @@ import { XataHttpSession } from './session.ts'; export interface XataDriverOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class XataHttpDriver { @@ -32,6 +34,7 @@ export class XataHttpDriver { return new XataHttpSession(this.client, this.dialect, schema, { logger: this.options.logger, cache: this.options.cache, + onError: this.options.onError, }); } @@ -73,7 +76,7 @@ export function drizzle = Record extends PgPrep export interface XataHttpSessionOptions { logger?: Logger; cache?: Cache; + onError?: (error: DrizzleQueryError) => void; } export class XataHttpSession, TSchema extends TablesRelationalConfig> @@ -116,6 +118,7 @@ export class XataHttpSession, TSchem super(dialect); this.logger = options.logger ?? new NoopLogger(); this.cache = options.cache ?? new NoopCache(); + this.onError = options.onError; } prepareQuery( @@ -130,16 +133,18 @@ export class XataHttpSession, TSchem }, cacheConfig?: WithCacheConfig, ): PgPreparedQuery { - return new XataHttpPreparedQuery( - this.client, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - isResponseInArrayMode, - customResultMapper, + return this.attachErrorHandler( + new XataHttpPreparedQuery( + this.client, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + isResponseInArrayMode, + customResultMapper, + ), ); } diff --git a/drizzle-orm/tests/errors.test.ts b/drizzle-orm/tests/errors.test.ts new file mode 100644 index 0000000000..9ca0eee99d --- /dev/null +++ b/drizzle-orm/tests/errors.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test, vi } from 'vitest'; +import { is } from '~/entity.ts'; +import { drizzle as pgDrizzle } from '~/node-postgres/index.ts'; +import { sql } from '~/sql/sql.ts'; +import { + CheckConstraintError, + DrizzleConstraintError, + DrizzleQueryError, + ForeignKeyConstraintError, + NotNullConstraintError, + UniqueConstraintError, + wrapMySqlError, + wrapPgError, + wrapSqliteError, +} from '~/errors.ts'; + +const query = 'insert into "users" ("email") values ($1)'; +const params = ['test@example.com']; + +function pgError(props: Record): Error { + return Object.assign(new Error('pg error'), props); +} + +describe('wrapPgError', () => { + test('unique_violation (23505)', () => { + const cause = pgError({ code: '23505', table: 'users', constraint: 'users_email_unique' }); + const wrapped = wrapPgError(query, params, cause); + + expect(wrapped).toBeInstanceOf(UniqueConstraintError); + expect(is(wrapped, UniqueConstraintError)).toBe(true); + expect(is(wrapped, DrizzleConstraintError)).toBe(true); + expect(is(wrapped, DrizzleQueryError)).toBe(true); + const e = wrapped as UniqueConstraintError; + expect(e.kind).toBe('unique'); + expect(e.constraintName).toBe('users_email_unique'); + expect(e.table).toBe('users'); + expect(e.cause).toBe(cause); + expect(e.query).toBe(query); + expect(e.params).toEqual(params); + }); + + test('not_null_violation (23502)', () => { + const cause = pgError({ code: '23502', table: 'users', column: 'name' }); + const wrapped = wrapPgError(query, params, cause) as NotNullConstraintError; + + expect(is(wrapped, NotNullConstraintError)).toBe(true); + expect(wrapped.kind).toBe('not_null'); + expect(wrapped.table).toBe('users'); + expect(wrapped.columns).toEqual(['name']); + }); + + test('foreign_key_violation (23503)', () => { + const cause = pgError({ code: '23503', table: 'posts', constraint: 'posts_user_id_fk' }); + const wrapped = wrapPgError(query, params, cause) as ForeignKeyConstraintError; + + expect(is(wrapped, ForeignKeyConstraintError)).toBe(true); + expect(wrapped.kind).toBe('foreign_key'); + expect(wrapped.constraintName).toBe('posts_user_id_fk'); + }); + + test('check_violation (23514)', () => { + const cause = pgError({ code: '23514', table: 'users', constraint: 'users_age_check' }); + const wrapped = wrapPgError(query, params, cause) as CheckConstraintError; + + expect(is(wrapped, CheckConstraintError)).toBe(true); + expect(wrapped.kind).toBe('check'); + expect(wrapped.constraintName).toBe('users_age_check'); + }); + + test('reads postgres.js name aliases (constraint_name/table_name/column_name)', () => { + const cause = pgError({ + code: '23505', + table_name: 'users', + column_name: 'email', + constraint_name: 'users_email_unique', + }); + const wrapped = wrapPgError(query, params, cause) as UniqueConstraintError; + + expect(wrapped.constraintName).toBe('users_email_unique'); + expect(wrapped.table).toBe('users'); + expect(wrapped.columns).toEqual(['email']); + }); + + test('falls back to DrizzleQueryError for unrecognized SQLSTATE', () => { + const cause = pgError({ code: '42P01' }); // undefined_table + const wrapped = wrapPgError(query, params, cause); + + expect(is(wrapped, DrizzleQueryError)).toBe(true); + expect(is(wrapped, DrizzleConstraintError)).toBe(false); + expect(wrapped.cause).toBe(cause); + }); + + test('falls back when there is no code', () => { + const cause = new Error('connection refused'); + const wrapped = wrapPgError(query, params, cause); + + expect(is(wrapped, DrizzleQueryError)).toBe(true); + expect(is(wrapped, DrizzleConstraintError)).toBe(false); + }); +}); + +describe('wrapMySqlError', () => { + test('ER_DUP_ENTRY (1062)', () => { + const cause = Object.assign(new Error('dup'), { + errno: 1062, + sqlMessage: "Duplicate entry 'test@example.com' for key 'users_email_unique'", + }); + const wrapped = wrapMySqlError(query, params, cause) as UniqueConstraintError; + + expect(is(wrapped, UniqueConstraintError)).toBe(true); + expect(wrapped.constraintName).toBe('users_email_unique'); + expect(wrapped.cause).toBe(cause); + }); + + test('ER_BAD_NULL_ERROR (1048)', () => { + const cause = Object.assign(new Error('null'), { + errno: 1048, + sqlMessage: "Column 'name' cannot be null", + }); + const wrapped = wrapMySqlError(query, params, cause) as NotNullConstraintError; + + expect(is(wrapped, NotNullConstraintError)).toBe(true); + expect(wrapped.columns).toEqual(['name']); + }); + + test('ER_NO_REFERENCED_ROW_2 (1452)', () => { + const cause = Object.assign(new Error('fk'), { + errno: 1452, + sqlMessage: + 'Cannot add or update a child row: a foreign key constraint fails (`db`.`posts`, CONSTRAINT `posts_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`))', + }); + const wrapped = wrapMySqlError(query, params, cause) as ForeignKeyConstraintError; + + expect(is(wrapped, ForeignKeyConstraintError)).toBe(true); + expect(wrapped.constraintName).toBe('posts_user_id_fk'); + expect(wrapped.table).toBe('posts'); + }); + + test('ER_CHECK_CONSTRAINT_VIOLATED (3819)', () => { + const cause = Object.assign(new Error('check'), { + errno: 3819, + sqlMessage: "Check constraint 'users_age_check' is violated.", + }); + const wrapped = wrapMySqlError(query, params, cause) as CheckConstraintError; + + expect(is(wrapped, CheckConstraintError)).toBe(true); + expect(wrapped.constraintName).toBe('users_age_check'); + }); + + test('falls back when there is no errno', () => { + const cause = new Error('some non-mysql2 error'); + const wrapped = wrapMySqlError(query, params, cause); + + expect(is(wrapped, DrizzleQueryError)).toBe(true); + expect(is(wrapped, DrizzleConstraintError)).toBe(false); + }); +}); + +describe('wrapSqliteError', () => { + test('SQLITE_CONSTRAINT_UNIQUE via extended code', () => { + const cause = Object.assign(new Error('UNIQUE constraint failed: users.email'), { + code: 'SQLITE_CONSTRAINT_UNIQUE', + }); + const wrapped = wrapSqliteError(query, params, cause) as UniqueConstraintError; + + expect(is(wrapped, UniqueConstraintError)).toBe(true); + expect(wrapped.table).toBe('users'); + expect(wrapped.columns).toEqual(['email']); + }); + + test('SQLITE_CONSTRAINT_PRIMARYKEY maps to unique', () => { + const cause = Object.assign(new Error('UNIQUE constraint failed: users.id'), { + code: 'SQLITE_CONSTRAINT_PRIMARYKEY', + }); + const wrapped = wrapSqliteError(query, params, cause); + + expect(is(wrapped, UniqueConstraintError)).toBe(true); + }); + + test('composite unique extracts all columns', () => { + const cause = Object.assign(new Error('UNIQUE constraint failed: t.a, t.b'), { + code: 'SQLITE_CONSTRAINT_UNIQUE', + }); + const wrapped = wrapSqliteError(query, params, cause) as UniqueConstraintError; + + expect(wrapped.table).toBe('t'); + expect(wrapped.columns).toEqual(['a', 'b']); + }); + + test('NOT NULL via message fallback (libsql/d1 have no extended code)', () => { + const cause = new Error('NOT NULL constraint failed: users.name'); + const wrapped = wrapSqliteError(query, params, cause) as NotNullConstraintError; + + expect(is(wrapped, NotNullConstraintError)).toBe(true); + expect(wrapped.table).toBe('users'); + expect(wrapped.columns).toEqual(['name']); + }); + + test('FOREIGN KEY via message fallback', () => { + const cause = new Error('FOREIGN KEY constraint failed'); + const wrapped = wrapSqliteError(query, params, cause); + + expect(is(wrapped, ForeignKeyConstraintError)).toBe(true); + }); + + test('CHECK via message fallback extracts name', () => { + const cause = new Error('CHECK constraint failed: users_age_check'); + const wrapped = wrapSqliteError(query, params, cause) as CheckConstraintError; + + expect(is(wrapped, CheckConstraintError)).toBe(true); + expect(wrapped.constraintName).toBe('users_age_check'); + }); + + test('falls back to DrizzleQueryError for non-constraint errors', () => { + const cause = Object.assign(new Error('no such table: users'), { code: 'SQLITE_ERROR' }); + const wrapped = wrapSqliteError(query, params, cause); + + expect(is(wrapped, DrizzleQueryError)).toBe(true); + expect(is(wrapped, DrizzleConstraintError)).toBe(false); + }); +}); + +describe('onError config hook', () => { + test('is invoked with the wrapped DrizzleQueryError before it is thrown', async () => { + const onError = vi.fn(); + const db = pgDrizzle.mock({ onError }); + + await expect(db.execute(sql`select 1`)).rejects.toThrow(DrizzleQueryError); + + expect(onError).toHaveBeenCalledOnce(); + expect(is(onError.mock.calls[0]![0], DrizzleQueryError)).toBe(true); + }); + + test('is not invoked when no error occurs and is optional', () => { + expect(() => pgDrizzle.mock()).not.toThrow(); + }); +}); + +describe('error class shape', () => { + test('full inheritance chain and discriminant switch', () => { + const wrapped = wrapPgError(query, params, pgError({ code: '23505', constraint: 'c' })); + + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped).toBeInstanceOf(DrizzleQueryError); + expect(wrapped).toBeInstanceOf(DrizzleConstraintError); + expect(wrapped).toBeInstanceOf(UniqueConstraintError); + + const constraint = wrapped as DrizzleConstraintError; + let label: string; + switch (constraint.kind) { + case 'unique': { + label = 'unique'; + break; + } + default: { + label = 'other'; + } + } + expect(label).toBe('unique'); + }); +});