diff --git a/drizzle-orm/src/errors.ts b/drizzle-orm/src/errors.ts index a72615c9b..f8b4b7a06 100644 --- a/drizzle-orm/src/errors.ts +++ b/drizzle-orm/src/errors.ts @@ -13,7 +13,8 @@ export class DrizzleError extends Error { export class TransactionRollbackError extends DrizzleError { static override readonly [entityKind]: string = 'TransactionRollbackError'; - constructor() { - super({ message: 'Rollback' }); + constructor(message?: string, readonly details?: Record) { + super({ message: `Rollback${message ? `: ${message}` : ''}` }); + this.name = 'TransactionRollbackError'; } } diff --git a/drizzle-orm/src/index.ts b/drizzle-orm/src/index.ts index bc72260b9..b9093c843 100644 --- a/drizzle-orm/src/index.ts +++ b/drizzle-orm/src/index.ts @@ -11,5 +11,6 @@ export * from './relations.ts'; export * from './sql/index.ts'; export * from './subquery.ts'; export * from './table.ts'; +export * from './tracer.ts'; export * from './utils.ts'; export * from './view-common.ts'; diff --git a/drizzle-orm/src/logger.ts b/drizzle-orm/src/logger.ts index 271fef262..451053b5c 100644 --- a/drizzle-orm/src/logger.ts +++ b/drizzle-orm/src/logger.ts @@ -1,7 +1,26 @@ import { entityKind } from '~/entity.ts'; -export interface Logger { - logQuery(query: string, params: unknown[]): void; +export abstract class Logger { + /** @internal */ + transaction?: { + name: string; + type: 'transaction' | 'savepoint'; + }; + + /** @internal */ + setTransactionDetails(details: { + name: string; + type: 'transaction' | 'savepoint'; + }): void { + this.transaction = details; + } + + abstract logQuery(query: string, params: unknown[], duration: number, failed: boolean, transaction?: { + name: string; + type: 'transaction' | 'savepoint'; + } | undefined): void; + abstract logTransactionBegin(name: string, type: 'transaction' | 'savepoint'): void; + abstract logTransactionEnd(name: string, type: 'transaction' | 'savepoint', status: 'commit' | 'rollback' | 'error', duration: number): void; } export interface LogWriter { @@ -16,16 +35,20 @@ export class ConsoleLogWriter implements LogWriter { } } -export class DefaultLogger implements Logger { +export class DefaultLogger extends Logger { static readonly [entityKind]: string = 'DefaultLogger'; readonly writer: LogWriter; constructor(config?: { writer: LogWriter }) { + super(); this.writer = config?.writer ?? new ConsoleLogWriter(); } - logQuery(query: string, params: unknown[]): void { + logQuery(query: string, params: unknown[], duration: number, failed: boolean, transaction?: { + name: string; + type: 'transaction' | 'savepoint'; + } | undefined): void { const stringifiedParams = params.map((p) => { try { return JSON.stringify(p); @@ -34,14 +57,35 @@ export class DefaultLogger implements Logger { } }); const paramsStr = stringifiedParams.length ? ` -- params: [${stringifiedParams.join(', ')}]` : ''; - this.writer.write(`Query: ${query}${paramsStr}`); + const durationStr = ` [${Math.round(duration)}ms]`; + const openingStr = failed ? 'Failed query' : 'Query'; + const transactionStr = transaction ? ` in ${transaction.type} ${transaction.name}` : ''; + this.writer.write(`${openingStr}${transactionStr}${durationStr}: ${query}${paramsStr}`); + } + + logTransactionBegin(name: string, type: 'transaction' | 'savepoint'): void { + this.writer.write(`Begin ${type} ${name}`); + } + + logTransactionEnd(name: string, type: 'transaction' | 'savepoint', status: 'commit' | 'rollback' | 'error', duration: number): void { + const statusStr = status === 'commit' ? 'Commit' : status === 'rollback' ? 'Rollback' : 'Failed'; + const durationStr = ` [${Math.round(duration)}ms]`; + this.writer.write(`${statusStr} ${type} ${name}${durationStr}`); } } -export class NoopLogger implements Logger { +export class NoopLogger extends Logger { static readonly [entityKind]: string = 'NoopLogger'; logQuery(): void { // noop } + + logTransactionBegin(): void { + // noop + } + + logTransactionEnd(): void { + // noop + } } diff --git a/drizzle-orm/src/pg-core/errors.ts b/drizzle-orm/src/pg-core/errors.ts new file mode 100644 index 000000000..be749804a --- /dev/null +++ b/drizzle-orm/src/pg-core/errors.ts @@ -0,0 +1,431 @@ +import { entityKind } from '~/entity'; +import type { TracedQuery, TracedTransaction } from '~/tracer.ts'; + +// https://www.postgresql.org/docs/current/protocol-error-fields.html +export interface PgErrorDetails { + readonly severity: 'ERROR' | 'FATAL' | 'PANIC' | (string & {}); + readonly severityLocal: 'ERROR' | 'FATAL' | 'PANIC' | (string & {}); + readonly code: PgErrorCode; + readonly message: string; + readonly detail?: string | undefined; + readonly hint?: string | undefined; + readonly position: string; + readonly internalPosition?: string | undefined; + readonly internalQuery?: string | undefined; + readonly where?: string | undefined; + readonly schemaName?: string | undefined; + readonly tableName?: string | undefined; + readonly columnName?: string | undefined; + readonly dataTypeName?: string | undefined; + readonly constraintName?: string | undefined; + readonly file: string; + readonly line: string; + readonly routine: string; +} + +export class PgError extends Error { + static readonly [entityKind]: string = 'PgError'; + + readonly severity: 'ERROR' | 'FATAL' | 'PANIC' | (string & {}); + readonly severityLocal: 'ERROR' | 'FATAL' | 'PANIC' | (string & {}); + readonly code: PgErrorCode; + readonly detail?: string | undefined; + readonly hint?: string | undefined; + readonly position: string; + readonly internalPosition?: string | undefined; + readonly internalQuery?: string | undefined; + readonly where?: string | undefined; + readonly schemaName?: string | undefined; + readonly tableName?: string | undefined; + readonly columnName?: string | undefined; + readonly dataTypeName?: string | undefined; + readonly constraintName?: string | undefined; + readonly file: string; + readonly line: string; + readonly routine: string; + readonly query?: TracedQuery | undefined; + readonly transaction?: TracedTransaction | undefined; + + // Only do this for unique, FK and exclusion constraints + getConstraintColumnNames(): string[] { + if (!['23001', '23503', '23505', '23P01'].includes(this.code)) return []; + + let columns: string[] = []; + if (this.detail) { + // "Key (field_1, field_2)=(1, test@example.com) ..." + // Regex extracts the "field_1, field_2" part + columns = this.detail.match(/\((.*?)\)=/)?.[1]?.split(', ') ?? []; + } + if (this.columnName) { + columns = [this.columnName]; + } + return columns; + } + + constructor(cause: unknown, details: PgErrorDetails & { query?: TracedQuery; transaction?: TracedTransaction }) { + super(`PgError: ${details.message}`, { cause }); + this.name = 'PgError'; + this.severity = details.severity; + this.severityLocal = details.severityLocal; + this.code = details.code; + this.message = details.message; + this.detail = details.detail; + this.hint = details.hint; + this.position = details.position; + this.internalPosition = details.internalPosition; + this.internalQuery = details.internalQuery; + this.where = details.where; + this.schemaName = details.schemaName; + this.tableName = details.tableName; + this.columnName = details.columnName; + this.dataTypeName = details.dataTypeName; + this.constraintName = details.constraintName; + this.file = details.file; + this.line = details.line; + this.routine = details.routine; + this.query = details.query; + this.transaction = details.transaction; + } +} + +export const ERROR = { + SQL_STATEMENT_NOT_YET_COMPLETE: { + SQL_STATEMENT_NOT_YET_COMPLETE: '03000', + }, + CONNECTION_EXCEPTION: { + CONNECTION_EXCEPTION: '08000', + CONNECTION_DOES_NOT_EXIST: '08003', + CONNECTION_FAILURE: '08006', + SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION: '08001', + SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION: '08004', + TRANSACTION_RESOLUTION_UNKNOWN: '08007', + PROTOCOL_VIOLATION: '08P01', + }, + TRIGGERED_ACTION_EXCEPTION: { + TRIGGERED_ACTION_EXCEPTION: '09000', + }, + FEATURE_NOT_SUPPORTED: { + FEATURE_NOT_SUPPORTED: '0A000', + }, + INVALID_TRANSACTION_INITIATION: { + INVALID_TRANSACTION_INITIATION: '0B000', + }, + LOCATOR_EXCEPTION: { + LOCATOR_EXCEPTION: '0F000', + INVALID_LOCATOR_SPECIFICATION: '0F001', + }, + INVALID_GRANTOR: { + INVALID_GRANTOR: '0L000', + INVALID_GRANT_OPERATION: '0LP01', + }, + INVALID_ROLE_SPECIFICATION: { + INVALID_ROLE_SPECIFICATION: '0P000', + }, + DIAGNOSTICS_EXCEPTION: { + DIAGNOSTICS_EXCEPTION: '0Z000', + STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER: '0Z002', + }, + CASE_NOT_FOUND: { + CASE_NOT_FOUND: '20000', + }, + CARDINALITY_VIOLATION: { + CARDINALITY_VIOLATION: '21000', + }, + DATA_EXCEPTION: { + DATA_EXCEPTION: '22000', + ARRAY_SUBSCRIPT_ERROR: '2202E', + CHARACTER_NOT_IN_REPERTOIRE: '22021', + DATETIME_FIELD_OVERFLOW: '22008', + DIVISION_BY_ZERO: '22012', + ERROR_IN_ASSIGNMENT: '22005', + ESCAPE_CHARACTER_CONFLICT: '2200B', + INDICATOR_OVERFLOW: '22022', + INTERVAL_FIELD_OVERFLOW: '22015', + INVALID_ARGUMENT_FOR_LOGARITHM: '2201E', + INVALID_ARGUMENT_FOR_NTILE_FUNCTION: '22014', + INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION: '22016', + INVALID_ARGUMENT_FOR_POWER_FUNCTION: '2201F', + INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION: '2201G', + INVALID_CHARACTER_VALUE_FOR_CAST: '22018', + INVALID_DATETIME_FORMAT: '22007', + INVALID_ESCAPE_CHARACTER: '22019', + INVALID_ESCAPE_OCTET: '2200D', + INVALID_ESCAPE_SEQUENCE: '22025', + NONSTANDARD_USE_OF_ESCAPE_CHARACTER: '22P06', + INVALID_INDICATOR_PARAMETER_VALUE: '22010', + INVALID_PARAMETER_VALUE: '22023', + INVALID_PRECEDING_OR_FOLLOWING_SIZE: '22013', + INVALID_REGULAR_EXPRESSION: '2201B', + INVALID_ROW_COUNT_IN_LIMIT_CLAUSE: '2201W', + INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE: '2201X', + INVALID_TABLESAMPLE_ARGUMENT: '2202H', + INVALID_TABLESAMPLE_REPEAT: '2202G', + INVALID_TIME_ZONE_DISPLACEMENT_VALUE: '22009', + INVALID_USE_OF_ESCAPE_CHARACTER: '2200C', + MOST_SPECIFIC_TYPE_MISMATCH: '2200G', + NULL_VALUE_NOT_ALLOWED: '22004', + NULL_VALUE_NO_INDICATOR_PARAMETER: '22002', + NUMERIC_VALUE_OUT_OF_RANGE: '22003', + SEQUENCE_GENERATOR_LIMIT_EXCEEDED: '2200H', + STRING_DATA_LENGTH_MISMATCH: '22026', + STRING_DATA_RIGHT_TRUNCATION: '22001', + SUBSTRING_ERROR: '22011', + TRIM_ERROR: '22027', + UNTERMINATED_C_STRING: '22024', + ZERO_LENGTH_CHARACTER_STRING: '2200F', + FLOATING_POINT_EXCEPTION: '22P01', + INVALID_TEXT_REPRESENTATION: '22P02', + INVALID_BINARY_REPRESENTATION: '22P03', + BAD_COPY_FILE_FORMAT: '22P04', + UNTRANSLATABLE_CHARACTER: '22P05', + NOT_AN_XML_DOCUMENT: '2200L', + INVALID_XML_DOCUMENT: '2200M', + INVALID_XML_CONTENT: '2200N', + INVALID_XML_COMMENT: '2200S', + INVALID_XML_PROCESSING_INSTRUCTION: '2200T', + DUPLICATE_JSON_OBJECT_KEY_VALUE: '22030', + INVALID_ARGUMENT_FOR_SQL_JSON_DATETIME_FUNCTION: '22031', + INVALID_JSON_TEXT: '22032', + INVALID_SQL_JSON_SUBSCRIPT: '22033', + MORE_THAN_ONE_SQL_JSON_ITEM: '22034', + NO_SQL_JSON_ITEM: '22035', + NON_NUMERIC_SQL_JSON_ITEM: '22036', + NON_UNIQUE_KEYS_IN_A_JSON_OBJECT: '22037', + SINGLETON_SQL_JSON_ITEM_REQUIRED: '22038', + SQL_JSON_ARRAY_NOT_FOUND: '22039', + SQL_JSON_MEMBER_NOT_FOUND: '2203A', + SQL_JSON_NUMBER_NOT_FOUND: '2203B', + SQL_JSON_OBJECT_NOT_FOUND: '2203C', + TOO_MANY_JSON_ARRAY_ELEMENTS: '2203D', + TOO_MANY_JSON_OBJECT_MEMBERS: '2203E', + SQL_JSON_SCALAR_REQUIRED: '2203F', + SQL_JSON_ITEM_CANNOT_BE_CAST_TO_TARGET_TYPE: '2203G', + }, + INTEGRITY_CONSTRAINT_VIOLATION: { + INTEGRITY_CONSTRAINT_VIOLATION: '23000', + RESTRICT_VIOLATION: '23001', + NOT_NULL_VIOLATION: '23502', + FOREIGN_KEY_VIOLATION: '23503', + UNIQUE_VIOLATION: '23505', + CHECK_VIOLATION: '23514', + EXCLUSION_VIOLATION: '23P01', + }, + INVALID_CURSOR_STATE: { + INVALID_CURSOR_STATE: '24000', + }, + INVALID_TRANSACTION_STATE: { + INVALID_TRANSACTION_STATE: '25000', + ACTIVE_SQL_TRANSACTION: '25001', + BRANCH_TRANSACTION_ALREADY_ACTIVE: '25002', + HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL: '25008', + INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION: '25003', + INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION: '25004', + NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION: '25005', + READ_ONLY_SQL_TRANSACTION: '25006', + SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED: '25007', + NO_ACTIVE_SQL_TRANSACTION: '25P01', + IN_FAILED_SQL_TRANSACTION: '25P02', + IDLE_IN_TRANSACTION_SESSION_TIMEOUT: '25P03', + TRANSACTION_TIMEOUT: '25P04', + }, + INVALID_SQL_STATEMENT_NAME: { + INVALID_SQL_STATEMENT_NAME: '26000', + }, + TRIGGERED_DATA_CHANGE_VIOLATION: { + TRIGGERED_DATA_CHANGE_VIOLATION: '27000', + }, + INVALID_AUTHORIZATION_SPECIFICATION: { + INVALID_AUTHORIZATION_SPECIFICATION: '28000', + }, + INVALID_PASSWORD: { + INVALID_PASSWORD: '28P01', + }, + DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST: { + DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST: '2B000', + }, + DEPENDENT_OBJECTS_STILL_EXIST: { + DEPENDENT_OBJECTS_STILL_EXIST: '2BP01', + }, + INVALID_TRANSACTION_TERMINATION: { + INVALID_TRANSACTION_TERMINATION: '2D000', + }, + SQL_ROUTINE_EXCEPTION: { + SQL_ROUTINE_EXCEPTION: '2F000', + FUNCTION_EXECUTED_NO_RETURN_STATEMENT: '2F005', + MODIFYING_SQL_DATA_NOT_PERMITTED: '2F002', + PROHIBITED_SQL_STATEMENT_ATTEMPTED: '2F003', + READING_SQL_DATA_NOT_PERMITTED: '2F004', + }, + INVALID_CURSOR_NAME: { + INVALID_CURSOR_NAME: '34000', + }, + EXTERNAL_ROUTINE_EXCEPTION: { + EXTERNAL_ROUTINE_EXCEPTION: '38000', + CONTAINING_SQL_NOT_PERMITTED: '38001', + MODIFYING_SQL_DATA_NOT_PERMITTED: '38002', + PROHIBITED_SQL_STATEMENT_ATTEMPTED: '38003', + READING_SQL_DATA_NOT_PERMITTED: '38004', + }, + EXTERNAL_ROUTINE_INVOCATION_EXCEPTION: { + EXTERNAL_ROUTINE_INVOCATION_EXCEPTION: '39000', + INVALID_SQLSTATE_RETURNED: '39001', + NULL_VALUE_NOT_ALLOWED: '39004', + TRIGGER_PROTOCOL_VIOLATED: '39P01', + SRF_PROTOCOL_VIOLATED: '39P02', + EVENT_TRIGGER_PROTOCOL_VIOLATED: '39P03', + }, + SAVEPOINT_EXCEPTION: { + SAVEPOINT_EXCEPTION: '3B000', + INVALID_SAVEPOINT_SPECIFICATION: '3B001', + }, + INVALID_CATALOG_NAME: { + INVALID_CATALOG_NAME: '3D000', + }, + INVALID_SCHEMA_NAME: { + INVALID_SCHEMA_NAME: '3F000', + }, + TRANSACTION_ROLLBACK: { + TRANSACTION_ROLLBACK: '40000', + TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION: '40002', + SERIALIZATION_FAILURE: '40001', + STATEMENT_COMPLETION_UNKNOWN: '40003', + DEADLOCK_DETECTED: '40P01', + }, + SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION: { + SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION: '42000', + SYNTAX_ERROR: '42601', + INSUFFICIENT_PRIVILEGE: '42501', + CANNOT_COERCE: '42846', + GROUPING_ERROR: '42803', + WINDOWING_ERROR: '42P20', + INVALID_RECURSION: '42P19', + INVALID_FOREIGN_KEY: '42830', + INVALID_NAME: '42602', + NAME_TOO_LONG: '42622', + RESERVED_NAME: '42939', + DATATYPE_MISMATCH: '42804', + INDETERMINATE_DATATYPE: '42P18', + COLLATION_MISMATCH: '42P21', + INDETERMINATE_COLLATION: '42P22', + WRONG_OBJECT_TYPE: '42809', + GENERATED_ALWAYS: '428C9', + UNDEFINED_COLUMN: '42703', + UNDEFINED_FUNCTION: '42883', + UNDEFINED_TABLE: '42P01', + UNDEFINED_PARAMETER: '42P02', + UNDEFINED_OBJECT: '42704', + DUPLICATE_COLUMN: '42701', + DUPLICATE_CURSOR: '42P03', + DUPLICATE_DATABASE: '42P04', + DUPLICATE_FUNCTION: '42723', + DUPLICATE_PREPARED_STATEMENT: '42P05', + DUPLICATE_SCHEMA: '42P06', + DUPLICATE_TABLE: '42P07', + DUPLICATE_ALIAS: '42712', + DUPLICATE_OBJECT: '42710', + AMBIGUOUS_COLUMN: '42702', + AMBIGUOUS_FUNCTION: '42725', + AMBIGUOUS_PARAMETER: '42P08', + AMBIGUOUS_ALIAS: '42P09', + INVALID_COLUMN_REFERENCE: '42P10', + INVALID_COLUMN_DEFINITION: '42611', + INVALID_CURSOR_DEFINITION: '42P11', + INVALID_DATABASE_DEFINITION: '42P12', + INVALID_FUNCTION_DEFINITION: '42P13', + INVALID_PREPARED_STATEMENT_DEFINITION: '42P14', + INVALID_SCHEMA_DEFINITION: '42P15', + INVALID_TABLE_DEFINITION: '42P16', + INVALID_OBJECT_DEFINITION: '42P17', + }, + WITH_CHECK_OPTION_VIOLATION: { + WITH_CHECK_OPTION_VIOLATION: '44000', + }, + INSUFFICIENT_RESOURCES: { + INSUFFICIENT_RESOURCES: '53000', + DISK_FULL: '53100', + OUT_OF_MEMORY: '53200', + TOO_MANY_CONNECTIONS: '53300', + CONFIGURATION_LIMIT_EXCEEDED: '53400', + }, + PROGRAM_LIMIT_EXCEEDED: { + PROGRAM_LIMIT_EXCEEDED: '54000', + STATEMENT_TOO_COMPLEX: '54001', + TOO_MANY_COLUMNS: '54011', + TOO_MANY_ARGUMENTS: '54023', + }, + OBJECT_NOT_IN_PREREQUISITE_STATE: { + OBJECT_NOT_IN_PREREQUISITE_STATE: '55000', + OBJECT_IN_USE: '55006', + CANT_CHANGE_RUNTIME_PARAM: '55P02', + LOCK_NOT_AVAILABLE: '55P03', + UNSAFE_NEW_ENUM_VALUE_USAGE: '55P04', + }, + OPERATOR_INTERVENTION: { + OPERATOR_INTERVENTION: '57000', + QUERY_CANCELED: '57014', + ADMIN_SHUTDOWN: '57P01', + CRASH_SHUTDOWN: '57P02', + CANNOT_CONNECT_NOW: '57P03', + DATABASE_DROPPED: '57P04', + IDLE_SESSION_TIMEOUT: '57P05', + }, + SYSTEM_ERROR: { + SYSTEM_ERROR: '58000', + IO_ERROR: '58030', + UNDEFINED_FILE: '58P01', + DUPLICATE_FILE: '58P02', + }, + CONFIG_FILE_ERROR: { + CONFIG_FILE_ERROR: 'F0000', + LOCK_FILE_EXISTS: 'F0001', + }, + FDW_ERROR: { + FDW_ERROR: 'HV000', + FDW_COLUMN_NAME_NOT_FOUND: 'HV005', + FDW_DYNAMIC_PARAMETER_VALUE_NEEDED: 'HV002', + FDW_FUNCTION_SEQUENCE_ERROR: 'HV010', + FDW_INCONSISTENT_DESCRIPTOR_INFORMATION: 'HV021', + FDW_INVALID_ATTRIBUTE_VALUE: 'HV024', + FDW_INVALID_COLUMN_NAME: 'HV007', + FDW_INVALID_COLUMN_NUMBER: 'HV008', + FDW_INVALID_DATA_TYPE: 'HV004', + FDW_INVALID_DATA_TYPE_DESCRIPTORS: 'HV006', + FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER: 'HV091', + FDW_INVALID_HANDLE: 'HV00B', + FDW_INVALID_OPTION_INDEX: 'HV00C', + FDW_INVALID_OPTION_NAME: 'HV00D', + FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH: 'HV090', + FDW_INVALID_STRING_FORMAT: 'HV00A', + FDW_INVALID_USE_OF_NULL_POINTER: 'HV009', + FDW_TOO_MANY_HANDLES: 'HV014', + FDW_OUT_OF_MEMORY: 'HV001', + FDW_NO_SCHEMAS: 'HV00P', + FDW_OPTION_NAME_NOT_FOUND: 'HV00J', + FDW_REPLY_HANDLE: 'HV00K', + FDW_SCHEMA_NOT_FOUND: 'HV00Q', + FDW_TABLE_NOT_FOUND: 'HV00R', + FDW_UNABLE_TO_CREATE_EXECUTION: 'HV00L', + FDW_UNABLE_TO_CREATE_REPLY: 'HV00M', + FDW_UNABLE_TO_ESTABLISH_CONNECTION: 'HV00N', + }, + PLPGSQL_ERROR: { + PLPGSQL_ERROR: 'P0000', + RAISE_EXCEPTION: 'P0001', + NO_DATA_FOUND: 'P0002', + TOO_MANY_ROWS: 'P0003', + ASSERT_FAILURE: 'P0004', + }, + INTERNAL_ERROR: { + INTERNAL_ERROR: 'XX000', + DATA_CORRUPTED: 'XX001', + INDEX_CORRUPTED: 'XX002', + }, +} as const; + +export type PgErrorCode = + | { + [K1 in keyof typeof ERROR]: { + [K2 in keyof typeof ERROR[K1]]: typeof ERROR[K1][K2]; + }[keyof typeof ERROR[K1]]; + }[keyof typeof ERROR] + | (string & {}); diff --git a/drizzle-orm/src/pg-core/index.ts b/drizzle-orm/src/pg-core/index.ts index ebc436bc1..3072ecfb6 100644 --- a/drizzle-orm/src/pg-core/index.ts +++ b/drizzle-orm/src/pg-core/index.ts @@ -3,6 +3,7 @@ export * from './checks.ts'; export * from './columns/index.ts'; export * from './db.ts'; export * from './dialect.ts'; +export * from './errors.ts'; export * from './foreign-keys.ts'; export * from './indexes.ts'; export * from './policies.ts'; diff --git a/drizzle-orm/src/pg-core/session.ts b/drizzle-orm/src/pg-core/session.ts index d77f2c4db..e151053cd 100644 --- a/drizzle-orm/src/pg-core/session.ts +++ b/drizzle-orm/src/pg-core/session.ts @@ -1,7 +1,7 @@ import { entityKind } from '~/entity.ts'; import { TransactionRollbackError } from '~/errors.ts'; import type { TablesRelationalConfig } from '~/relations.ts'; -import type { PreparedQuery } from '~/session.ts'; +import type { PreparedQuery, TransactionConfig } from '~/session.ts'; import { type Query, type SQL, sql } from '~/sql/index.ts'; import { tracer } from '~/tracing.ts'; import type { NeonAuthToken } from '~/utils.ts'; @@ -52,7 +52,7 @@ export abstract class PgPreparedQuery implements abstract isResponseInArrayMode(): boolean; } -export interface PgTransactionConfig { +export interface PgTransactionConfig extends TransactionConfig { isolationLevel?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable'; accessMode?: 'read only' | 'read write'; deferrable?: boolean; @@ -141,8 +141,8 @@ export abstract class PgTransaction< super(dialect, session, schema); } - rollback(): never { - throw new TransactionRollbackError(); + rollback(message?: string, details?: Record): never { + throw new TransactionRollbackError(message, details); } /** @internal */ @@ -166,6 +166,7 @@ export abstract class PgTransaction< abstract override transaction( transaction: (tx: PgTransaction) => Promise, + config?: TransactionConfig, ): Promise; } diff --git a/drizzle-orm/src/postgres-js/index.ts b/drizzle-orm/src/postgres-js/index.ts index b1b6a52e7..e2ff9ff5b 100644 --- a/drizzle-orm/src/postgres-js/index.ts +++ b/drizzle-orm/src/postgres-js/index.ts @@ -1,2 +1,3 @@ export * from './driver.ts'; export * from './session.ts'; +export * from './tracer.ts'; diff --git a/drizzle-orm/src/postgres-js/session.ts b/drizzle-orm/src/postgres-js/session.ts index 7509e2a00..c897fac9a 100644 --- a/drizzle-orm/src/postgres-js/session.ts +++ b/drizzle-orm/src/postgres-js/session.ts @@ -8,9 +8,11 @@ import type { SelectedFieldsOrdered } from '~/pg-core/query-builders/select.type import type { PgQueryResultHKT, PgTransactionConfig, PreparedQueryConfig } from '~/pg-core/session.ts'; import { PgPreparedQuery, PgSession } from '~/pg-core/session.ts'; import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; +import type { TransactionConfig } from '~/session.ts'; import { fillPlaceholders, type Query } from '~/sql/sql.ts'; import { tracer } from '~/tracing.ts'; import { type Assume, mapResultRow } from '~/utils.ts'; +import { PostgresJsTracer } from './tracer.ts'; export class PostgresJsPreparedQuery extends PgPreparedQuery { static override readonly [entityKind]: string = 'PostgresJsPreparedQuery'; @@ -36,22 +38,34 @@ export class PostgresJsPreparedQuery extends PgPr 'drizzle.query.params': JSON.stringify(params), }); - this.logger.logQuery(this.queryString, params); - - const { fields, queryString: query, client, joinsNotNullableMap, customResultMapper } = this; + const { fields, queryString, client, joinsNotNullableMap, customResultMapper } = this; if (!fields && !customResultMapper) { - return tracer.startActiveSpan('drizzle.driver.execute', () => { - return client.unsafe(query, params as any[]); - }); + const query = client.unsafe(queryString, params as any[]); + const traced = PostgresJsTracer.traceQuery( + query, + this.logger, + this.queryString, + params, + ); + + return tracer.startActiveSpan('drizzle.driver.execute', () => traced); } + const query = client.unsafe(queryString, params as any[]).values(); + const traced = PostgresJsTracer.traceQuery( + query, + this.logger, + this.queryString, + params, + ); + const rows = await tracer.startActiveSpan('drizzle.driver.execute', () => { span?.setAttributes({ - 'drizzle.query.text': query, + 'drizzle.query.text': queryString, 'drizzle.query.params': JSON.stringify(params), }); - return client.unsafe(query, params as any[]).values(); + return traced; }); return tracer.startActiveSpan('drizzle.mapResponse', () => { @@ -69,14 +83,18 @@ export class PostgresJsPreparedQuery extends PgPr 'drizzle.query.text': this.queryString, 'drizzle.query.params': JSON.stringify(params), }); - this.logger.logQuery(this.queryString, params); - return tracer.startActiveSpan('drizzle.driver.execute', () => { - span?.setAttributes({ - 'drizzle.query.text': this.queryString, - 'drizzle.query.params': JSON.stringify(params), - }); - return this.client.unsafe(this.queryString, params as any[]); - }); + return await PostgresJsTracer.traceQuery( + tracer.startActiveSpan('drizzle.driver.execute', () => { + span?.setAttributes({ + 'drizzle.query.text': this.queryString, + 'drizzle.query.params': JSON.stringify(params), + }); + return this.client.unsafe(this.queryString, params as any[]); + }), + this.logger, + this.queryString, + params, + ); }); } @@ -129,34 +147,53 @@ export class PostgresJsSession< } query(query: string, params: unknown[]): Promise> { - this.logger.logQuery(query, params); - return this.client.unsafe(query, params as any[]).values(); + return PostgresJsTracer.traceQuery( + this.client.unsafe(query, params as any[]).values(), + this.logger, + query, + params, + ); } queryObjects( query: string, params: unknown[], ): Promise> { - return this.client.unsafe(query, params as any[]); + return PostgresJsTracer.traceQuery( + this.client.unsafe(query, params as any[]), + this.logger, + query, + params, + ); } override transaction( transaction: (tx: PostgresJsTransaction) => Promise, config?: PgTransactionConfig, ): Promise { - return this.client.begin(async (client) => { + const name = config?.name ?? PostgresJsTracer.generateTransactionName(); + const tx = this.client.begin(async (client) => { const session = new PostgresJsSession( client, this.dialect, this.schema, this.options, ); + session.logger.setTransactionDetails({ name, type: 'transaction' }); + const tx = new PostgresJsTransaction(this.dialect, session, this.schema); - if (config) { + if (config?.accessMode || config?.isolationLevel || config?.deferrable) { await tx.setTransaction(config); } return transaction(tx); }) as Promise; + + return PostgresJsTracer.traceTransaction( + tx, + this.logger, + name, + 'transaction', + ); } } @@ -178,17 +215,27 @@ export class PostgresJsTransaction< override transaction( transaction: (tx: PostgresJsTransaction) => Promise, + config?: TransactionConfig, ): Promise { - return this.session.client.savepoint((client) => { + const name = config?.name ?? PostgresJsTracer.generateTransactionName(); + const tx = this.session.client.savepoint((client) => { const session = new PostgresJsSession( client, this.dialect, this.schema, this.session.options, ); + session.logger.setTransactionDetails({ name, type: 'savepoint' }); const tx = new PostgresJsTransaction(this.dialect, session, this.schema); return transaction(tx); }) as Promise; + + return PostgresJsTracer.traceTransaction( + tx, + this.session.logger, + name, + 'savepoint', + ); } } diff --git a/drizzle-orm/src/postgres-js/tracer.ts b/drizzle-orm/src/postgres-js/tracer.ts new file mode 100644 index 000000000..4faa69b2b --- /dev/null +++ b/drizzle-orm/src/postgres-js/tracer.ts @@ -0,0 +1,62 @@ +import client from 'postgres'; +import { entityKind } from '~/entity.ts'; +import { PgError, type PgErrorDetails } from '~/pg-core/errors.ts'; +import { DrizzleTracer, type TracedQuery, type TracedTransaction } from '~/tracer.ts'; + +export abstract class PostgresJsTracer extends DrizzleTracer { + static override readonly [entityKind]: string = 'PostgresJsTracer'; + + private static mapDetails(err: client.PostgresError): PgErrorDetails { + return { + code: err.code, + message: err.message, + file: err.file, + line: err.line, + routine: err.routine, + position: err.position, + severity: err.severity, + severityLocal: err.severity_local, + columnName: err.column_name, + constraintName: err.constraint_name, + dataTypeName: err.table_name, + detail: err.detail, + hint: err.hint, + internalPosition: err.internal_position, + internalQuery: err.internal_query, + schemaName: err.schema_name, + tableName: err.table_name, + where: err.where, + }; + } + + static override handleQueryError(err: unknown, queryString: string, queryParams: any[], duration: number): never { + if (err instanceof client.PostgresError) { + const query: TracedQuery = { + sql: queryString, + params: queryParams, + duration, + }; + const details = this.mapDetails(err); + throw new PgError(err, { ...details, query }); + } + throw err; + } + + static override handleTransactionError( + err: unknown, + transactionId: string, + type: 'transaction' | 'savepoint', + duration: number, + ): never { + if (err instanceof client.PostgresError) { + const transaction: TracedTransaction = { + name: transactionId, + duration, + type, + }; + const details = this.mapDetails(err); + throw new PgError(err, { ...details, transaction }); + } + throw err; + } +} diff --git a/drizzle-orm/src/session.ts b/drizzle-orm/src/session.ts index 7446905b6..df89f5267 100644 --- a/drizzle-orm/src/session.ts +++ b/drizzle-orm/src/session.ts @@ -6,3 +6,7 @@ export interface PreparedQuery { /** @internal */ isResponseInArrayMode(): boolean; } + +export interface TransactionConfig { + name?: string; +} diff --git a/drizzle-orm/src/tracer.ts b/drizzle-orm/src/tracer.ts new file mode 100644 index 000000000..b6248d420 --- /dev/null +++ b/drizzle-orm/src/tracer.ts @@ -0,0 +1,74 @@ +import { entityKind, is } from './entity.ts'; +import { TransactionRollbackError } from './errors'; +import type { Logger } from './logger.ts'; +import type { Query } from './sql/sql.ts'; + +export abstract class DrizzleTracer { + static readonly [entityKind]: string = 'DrizzleTracer'; + + static handleQueryError: (err: unknown, queryString: string, queryParams: any[], duration: number) => never; + + static handleTransactionError: ( + err: unknown, + transactionId: string, + type: 'transaction' | 'savepoint', + duration: number, + ) => never; + + static generateTransactionName(): string { + return Math.random().toString(16).substring(2, 6); + } + + static async traceQuery( + query: Promise, + logger: Logger, + queryString: string, + queryParams: any[], + ): Promise { + const start = performance.now(); + const transaction = logger.transaction; + + try { + const result = await query; + const duration = performance.now() - start; + logger.logQuery(queryString, queryParams, duration, false, transaction); + return result; + } catch (err) { + const duration = performance.now() - start; + logger.logQuery(queryString, queryParams, duration, true, transaction); + throw this.handleQueryError(err, queryString, queryParams, duration); + } + } + + static async traceTransaction( + transaction: Promise, + logger: Logger, + transactionName: string, + type: 'transaction' | 'savepoint', + ): Promise { + const start = performance.now(); + logger.logTransactionBegin(transactionName, type); + + try { + const result = await transaction; + const duration = performance.now() - start; + logger.logTransactionEnd(transactionName, type, 'commit', duration); + return result; + } catch (err) { + const duration = performance.now() - start; + const status = is(err, TransactionRollbackError) ? 'rollback' : 'error'; + logger.logTransactionEnd(transactionName, type, status, duration); + throw this.handleTransactionError(err, transactionName, type, duration); + } + } +} + +export interface TracedQuery extends Query { + duration?: number; +} + +export interface TracedTransaction { + name: string; + type: 'transaction' | 'savepoint'; + duration?: number; +} diff --git a/drizzle-orm/src/utils.ts b/drizzle-orm/src/utils.ts index 51d30e97c..d819ef7b1 100644 --- a/drizzle-orm/src/utils.ts +++ b/drizzle-orm/src/utils.ts @@ -251,9 +251,7 @@ export type RequireAtLeastOne = Keys extends : never; type ExpectedConfigShape = { - logger?: boolean | { - logQuery(query: string, params: unknown[]): void; - }; + logger?: boolean | Logger; schema?: Record; casing?: 'snake_case' | 'camelCase'; }; diff --git a/integration-tests/tests/pg/pg-common.ts b/integration-tests/tests/pg/pg-common.ts index 5e5f4ec72..c46821292 100644 --- a/integration-tests/tests/pg/pg-common.ts +++ b/integration-tests/tests/pg/pg-common.ts @@ -41,6 +41,7 @@ import { char, cidr, date, + ERROR, except, exceptAll, foreignKey, @@ -60,6 +61,7 @@ import { numeric, PgDialect, pgEnum, + PgError, pgMaterializedView, PgPolicy, pgPolicy, @@ -2827,6 +2829,33 @@ export function tests() { await db.execute(sql`drop table ${users}`); }); + test('transaction rollback with details', async (ctx) => { + const { db } = ctx.pg; + + const users = pgTable('users_transactions_rollback', { + id: serial('id').primaryKey(), + balance: integer('balance').notNull(), + }); + + await db.execute(sql`drop table if exists ${users}`); + + await db.execute( + sql`create table users_transactions_rollback (id serial not null primary key, balance integer not null)`, + ); + + const transaction = db.transaction(async (tx) => { + await tx.insert(users).values({ balance: 100 }); + tx.rollback('custom message', { id: '1' }); + }); + + await expect(async () => await transaction).rejects.toThrowError(TransactionRollbackError); + + const caught = (await transaction.catch((e) => e)) as TransactionRollbackError; + + expect(caught.message).toBe('Rollback: custom message'); + expect(caught.details).toEqual({ id: '1' }); + }); + test('nested transaction', async (ctx) => { const { db } = ctx.pg; @@ -5418,5 +5447,80 @@ export function tests() { { id: 4, id1: 5, name: 'Bob' }, ]); }); + + test('error handling', async (ctx) => { + const { db } = ctx.pg; + const q = db.execute(sql`selec 1`); + await expect(() => q).rejects.toThrow(PgError); + + const caught = (await q.catch((e) => e)) as PgError; + expect(caught.severity).toBe('ERROR'); + expect(caught.code).toBe(ERROR.SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION.SYNTAX_ERROR); + expect(caught.message).toBeTypeOf('string'); + expect(caught.query).toStrictEqual({ + sql: 'selec 1', + params: [], + duration: expect.any(Number), + }); + }); + + test('error handling: integrity constraint violation', async (ctx) => { + const { db } = ctx.pg; + + const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').notNull().unique(), + }); + + await db.execute(sql`drop table if exists ${users}`); + await db.execute(sql`create table ${users} (id serial primary key, email text not null unique)`); + await db.insert(users).values({ email: 'first@email.com' }); + + const q = db.insert(users).values({ email: 'first@email.com' }); + await expect(() => q).rejects.toThrow(PgError); + + const caught = (await q.catch((e) => e)) as PgError; + expect(caught.severity).toBe('ERROR'); + expect(caught.code).toBe(ERROR.INTEGRITY_CONSTRAINT_VIOLATION.UNIQUE_VIOLATION); + expect(caught.message).toBeTypeOf('string'); + expect(caught.getConstraintColumnNames()).toEqual(['email']); + expect(caught.query).toStrictEqual({ + sql: 'insert into "users" ("id", "email") values (default, $1)', + params: ['first@email.com'], + duration: expect.any(Number), + }); + }); + + test('error handling: integrity constraint violation with multiple columns', async (ctx) => { + const { db } = ctx.pg; + + const users = pgTable('users', { + id: serial('id').primaryKey(), + email1: text('email1').notNull(), + email2: text('email2').notNull(), + }, (users) => [ + unique('unique').on(users.email1, users.email2), + ]); + + await db.execute(sql`drop table if exists ${users}`); + await db.execute( + sql`create table ${users} (id serial primary key, email1 text not null, email2 text not null, unique(email1, email2))`, + ); + await db.insert(users).values({ email1: 'first@email.com', email2: 'second@email.com' }); + + const q = db.insert(users).values({ email1: 'first@email.com', email2: 'second@email.com' }); + await expect(() => q).rejects.toThrow(PgError); + + const caught = (await q.catch((e) => e)) as PgError; + expect(caught.severity).toBe('ERROR'); + expect(caught.code).toBe(ERROR.INTEGRITY_CONSTRAINT_VIOLATION.UNIQUE_VIOLATION); + expect(caught.message).toBeTypeOf('string'); + expect(caught.getConstraintColumnNames()).toEqual(['email1', 'email2']); + expect(caught.query).toStrictEqual({ + sql: 'insert into "users" ("id", "email1", "email2") values (default, $1, $2)', + params: ['first@email.com', 'second@email.com'], + duration: expect.any(Number), + }); + }); }); }