From f1845ee84eb61a894155944a6efae6b926a4a47d Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Sun, 4 Feb 2024 15:03:56 +0100 Subject: [PATCH] feat(orm): better Error handling + UniqueConstraintFailure With this commit all errors public ORM end points (query patch, query delete, session insert, session update, session remove) are caught and normalized to either DatabaseError or to one of its subclasses. All errors now contain `cause` to be able to get the original error in the error chain. This way no information is lost. UniqueConstraintFailure now contains the full error message from the underlying database driver to get more information about it. Adjusted: DatabasePatchError: - [x] MySQL: query patch - [x] SQLite: query patch - [x] Postgres: query patch - [x] Mongo: query patch DatabaseDeleteError: - [x] MySQL: query delete, session remove - [x] SQLite: query delete, session remove - [x] Postgres: query delete, session remove - [x] Mongo: query delete, session remove DatabaseUpdateError: - [x] MySQL: session update - [x] SQLite: session update - [x] Postgres: session update - [x] Mongo: session update DatabaseInsertError: - [x] MySQL: session insert - [x] SQLite: session insert - [x] Postgres: session insert - [x] Mongo: session insert UniqueConstraintFailure: - [x] MySQL: session insert, query patch, session update - [x] SQLite: session insert, query patch, session update - [x] Postgres: session insert, query patch, session update - [x] Mongo: session insert, query patch, session update --- packages/core/src/core.ts | 5 +- packages/core/src/reactive.ts | 2 + packages/core/tsconfig.json | 1 + packages/filesystem/tsconfig.json | 4 +- packages/framework/tsconfig.json | 1 + packages/mongo/src/client/error.ts | 7 +- packages/mongo/src/error.ts | 19 +++++ packages/mongo/src/persistence.ts | 71 ++++++++++++++--- packages/mongo/src/query.resolver.ts | 16 +++- packages/mongo/tests/mongo.spec.ts | 43 ++++++++++- packages/mongo/tsconfig.json | 1 + packages/mysql/src/mysql-adapter.ts | 89 ++++++++++++++++------ packages/mysql/tests/mysql.spec.ts | 41 +++++++++- packages/mysql/tsconfig.json | 1 + packages/orm-integration/src/various.ts | 33 ++++---- packages/orm/src/database-session.ts | 2 +- packages/orm/src/query.ts | 92 +++++++++++++++++++++++ packages/orm/src/type.ts | 64 ++++++++++++++-- packages/orm/tsconfig.json | 4 +- packages/postgres/src/postgres-adapter.ts | 85 ++++++++++++++------- packages/postgres/tests/postgres.spec.ts | 52 ++++++++++++- packages/postgres/tsconfig.json | 4 +- packages/sql/src/sql-adapter.ts | 91 +++++++++++++++++----- packages/sql/tsconfig.json | 1 + packages/sqlite/src/sqlite-adapter.ts | 86 +++++++++++++++------ packages/sqlite/tests/sqlite.spec.ts | 41 +++++++++- packages/sqlite/tsconfig.json | 1 + tsconfig.base.json | 8 ++ 28 files changed, 716 insertions(+), 149 deletions(-) create mode 100644 packages/mongo/src/error.ts create mode 100644 tsconfig.base.json diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index c0d199a93..6b167eb4f 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -26,9 +26,10 @@ import { eachPair } from './iterators.js'; export class CustomError extends Error { public name: string; public stack?: string; + public cause?: Error | any; - constructor(public message: string = '') { - super(message); + constructor(...args: any[]) { + super(...args); this.name = this.constructor.name; } } diff --git a/packages/core/src/reactive.ts b/packages/core/src/reactive.ts index 9addabd04..392ca3c00 100644 --- a/packages/core/src/reactive.ts +++ b/packages/core/src/reactive.ts @@ -8,6 +8,8 @@ * You should have received a copy of the MIT License along with this program. */ +declare var requestAnimationFrame: any; +declare var cancelAnimationFrame: any; export const nextTick = typeof requestAnimationFrame !== 'undefined' ? (cb: () => void) => requestAnimationFrame(cb) : (cb: () => void) => setTimeout(cb); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index ffdf72510..99041c0ba 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/packages/filesystem/tsconfig.json b/packages/filesystem/tsconfig.json index 1d1eb0366..6fbebb66b 100644 --- a/packages/filesystem/tsconfig.json +++ b/packages/filesystem/tsconfig.json @@ -14,7 +14,9 @@ "outDir": "./dist/cjs", "declaration": true, "composite": true, - "types": [] + "types": [ + "node" + ] }, "include": [ "src", diff --git a/packages/framework/tsconfig.json b/packages/framework/tsconfig.json index 102ce1c55..a14075981 100644 --- a/packages/framework/tsconfig.json +++ b/packages/framework/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/packages/mongo/src/client/error.ts b/packages/mongo/src/client/error.ts index 8db0cc98a..760d49d17 100644 --- a/packages/mongo/src/client/error.ts +++ b/packages/mongo/src/client/error.ts @@ -9,7 +9,7 @@ */ import { BaseResponse } from './command/command.js'; -import { DatabaseError, UniqueConstraintFailure } from '@deepkit/orm'; +import { DatabaseError } from '@deepkit/orm'; /** @@ -19,14 +19,9 @@ export function handleErrorResponse(response: BaseResponse): DatabaseError | und const message = response.errmsg || (response.writeErrors && response.writeErrors.length ? response.writeErrors[0].errmsg : undefined); if (!message || 'string' !== typeof message) return; - if (message.includes('duplicate key error')) { - return new UniqueConstraintFailure(); - } - if (message) { return Object.assign(new MongoDatabaseError(message), { code: response.code || 0 }); } - return; } diff --git a/packages/mongo/src/error.ts b/packages/mongo/src/error.ts new file mode 100644 index 000000000..3027a1bbc --- /dev/null +++ b/packages/mongo/src/error.ts @@ -0,0 +1,19 @@ +import { DatabaseError, DatabaseSession, UniqueConstraintFailure } from '@deepkit/orm'; + +/** + * Converts a specific database error to a more specific error, if possible. + */ +export function handleSpecificError(session: DatabaseSession, error: DatabaseError): Error { + let cause: any = error; + while (cause) { + if (cause instanceof Error) { + if (cause.message.includes('duplicate key error') + ) { + return new UniqueConstraintFailure(`${cause.message}`, { cause: error }); + } + cause = cause.cause; + } + } + + return error; +} diff --git a/packages/mongo/src/persistence.ts b/packages/mongo/src/persistence.ts index de647d836..ad42e5307 100644 --- a/packages/mongo/src/persistence.ts +++ b/packages/mongo/src/persistence.ts @@ -8,7 +8,17 @@ * You should have received a copy of the MIT License along with this program. */ -import { DatabasePersistence, DatabasePersistenceChangeSet, DatabaseSession, getClassState, getInstanceState, OrmEntity } from '@deepkit/orm'; +import { + DatabaseDeleteError, + DatabaseInsertError, + DatabasePersistence, + DatabasePersistenceChangeSet, + DatabaseSession, + DatabaseUpdateError, + getClassState, + getInstanceState, + OrmEntity, +} from '@deepkit/orm'; import { convertClassQueryToMongo } from './mapping.js'; import { FilterQuery } from './query.model.js'; import { MongoClient } from './client/client.js'; @@ -22,6 +32,7 @@ import { MongoConnection } from './client/connection.js'; import { getPartialSerializeFunction, ReflectionClass } from '@deepkit/type'; import { ObjectId } from '@deepkit/bson'; import { mongoSerializer } from './mongo-serializer.js'; +import { handleSpecificError } from './error.js'; export class MongoPersistence extends DatabasePersistence { protected connection?: MongoConnection; @@ -41,10 +52,15 @@ export class MongoPersistence extends DatabasePersistence { return this.connection; } + handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + async remove(classSchema: ReflectionClass, items: T[]): Promise { const classState = getClassState(classSchema); const partialSerialize = getPartialSerializeFunction(classSchema.type, mongoSerializer.serializeRegistry); + let command: DeleteCommand; if (classSchema.getPrimaries().length === 1) { const pk = classSchema.getPrimary(); const pkName = pk.name; @@ -55,13 +71,26 @@ export class MongoPersistence extends DatabasePersistence { const converted = partialSerialize(pk); ids.push(converted[pkName]); } - await (await this.getConnection()).execute(new DeleteCommand(classSchema, { [pkName]: { $in: ids } })); + + command = new DeleteCommand(classSchema, { [pkName]: { $in: ids } }); } else { const fields: any[] = []; for (const item of items) { fields.push(partialSerialize(getInstanceState(classState, item).getLastKnownPK())); } - await (await this.getConnection()).execute(new DeleteCommand(classSchema, { $or: fields })); + command = new DeleteCommand(classSchema, { $or: fields }); + } + + try { + await (await this.getConnection()).execute(command); + } catch (error: any) { + error = new DatabaseDeleteError( + classSchema, + `Could not remove ${classSchema.getClassName()} from database`, + { cause: error }, + ); + error.items = items; + throw this.handleSpecificError(error); } } @@ -104,7 +133,17 @@ export class MongoPersistence extends DatabasePersistence { if (this.session.logger.active) this.session.logger.log('insert', classSchema.getClassName(), items.length); - await connection.execute(new InsertCommand(classSchema, insert)); + try { + await connection.execute(new InsertCommand(classSchema, insert)); + } catch (error: any) { + error = new DatabaseInsertError( + classSchema, + items as OrmEntity[], + `Could not insert ${classSchema.getClassName()} into database`, + { cause: error }, + ); + throw this.handleSpecificError(error); + } } async update(classSchema: ReflectionClass, changeSets: DatabasePersistenceChangeSet[]): Promise { @@ -157,17 +196,27 @@ export class MongoPersistence extends DatabasePersistence { const connection = await this.getConnection(); - const res = await connection.execute(new UpdateCommand(classSchema, updates)); + try { + const res = await connection.execute(new UpdateCommand(classSchema, updates)); - if (res > 0 && hasAtomic) { - const returnings = await connection.execute(new FindCommand(classSchema, { [primaryKeyName]: { $in: pks } }, projection)); - for (const returning of returnings) { - const r = assignReturning[returning[primaryKeyName]]; + if (res > 0 && hasAtomic) { + const returnings = await connection.execute(new FindCommand(classSchema, { [primaryKeyName]: { $in: pks } }, projection)); + for (const returning of returnings) { + const r = assignReturning[returning[primaryKeyName]]; - for (const name of r.names) { - r.item[name] = returning[name]; + for (const name of r.names) { + r.item[name] = returning[name]; + } } } + } catch (error: any) { + error = new DatabaseUpdateError( + classSchema, + changeSets, + `Could not update ${classSchema.getClassName()} in database`, + { cause: error }, + ); + throw this.handleSpecificError(error); } } } diff --git a/packages/mongo/src/query.resolver.ts b/packages/mongo/src/query.resolver.ts index 91c94a67c..cd2930034 100644 --- a/packages/mongo/src/query.resolver.ts +++ b/packages/mongo/src/query.resolver.ts @@ -8,7 +8,7 @@ * You should have received a copy of the MIT License along with this program. */ -import { DatabaseAdapter, DatabaseSession, DeleteResult, Formatter, GenericQueryResolver, OrmEntity, PatchResult } from '@deepkit/orm'; +import { DatabaseAdapter, DatabaseDeleteError, DatabasePatchError, DatabaseSession, DeleteResult, Formatter, GenericQueryResolver, OrmEntity, PatchResult } from '@deepkit/orm'; import { Changes, getPartialSerializeFunction, @@ -19,7 +19,7 @@ import { ReflectionVisibility, resolveForeignReflectionClass, serializer, - typeOf + typeOf, } from '@deepkit/type'; import { MongoClient } from './client/client.js'; import { AggregateCommand } from './client/command/aggregate.js'; @@ -34,6 +34,7 @@ import { MongoConnection } from './client/connection.js'; import { MongoDatabaseAdapter } from './adapter.js'; import { empty } from '@deepkit/core'; import { mongoSerializer } from './mongo-serializer.js'; +import { handleSpecificError } from './error.js'; export function getMongoFilter(classSchema: ReflectionClass, model: MongoQueryModel): any { return convertClassQueryToMongo(classSchema, (model.filter || {}) as FilterQuery, {}, { @@ -70,6 +71,10 @@ export class MongoQueryResolver extends GenericQueryResolve return await this.count(model) > 0; } + handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + protected getPrimaryKeysProjection(classSchema: ReflectionClass) { const pk: { [name: string]: 1 | 0 } = { _id: 0 }; for (const property of classSchema.getPrimaries()) { @@ -108,6 +113,10 @@ export class MongoQueryResolver extends GenericQueryResolve const query = convertClassQueryToMongo(this.classSchema, { [primaryKeyName]: { $in: primaryKeys.map(v => v[primaryKeyName]) } } as FilterQuery); await connection.execute(new DeleteCommand(this.classSchema, query, queryModel.limit)); + } catch (error: any) { + error = new DatabaseDeleteError(this.classSchema, `Could not delete ${this.classSchema.getClassName()} in database`, { cause: error }); + error.query = queryModel; + throw this.handleSpecificError(error); } finally { connection.release(); } @@ -181,6 +190,9 @@ export class MongoQueryResolver extends GenericQueryResolve patchResult.returning[name].push(converted[name]); } } + } catch (error: any) { + error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); + throw this.handleSpecificError(error); } finally { connection.release(); } diff --git a/packages/mongo/tests/mongo.spec.ts b/packages/mongo/tests/mongo.spec.ts index f81049aa0..4e4973ee2 100644 --- a/packages/mongo/tests/mongo.spec.ts +++ b/packages/mongo/tests/mongo.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@jest/globals'; import { arrayBufferFrom, + AutoIncrement, BackReference, cast, entity, @@ -11,12 +12,14 @@ import { ReflectionClass, ReflectionKind, serialize, + Unique, UUID, uuid, } from '@deepkit/type'; -import { getInstanceStateFromItem } from '@deepkit/orm'; +import { getInstanceStateFromItem, UniqueConstraintFailure } from '@deepkit/orm'; import { SimpleModel, SuperSimple } from './entities.js'; import { createDatabase } from './utils.js'; +import { databaseFactory } from './factory.js'; Error.stackTraceLimit = 100; @@ -617,3 +620,41 @@ test('batch', async () => { expect(items.length).toBe(1000); } }); + +test('unique constraint 1', async () => { + class Model { + id: number & PrimaryKey & AutoIncrement = 0; + constructor(public username: string & Unique = '') {} + } + + const database = await databaseFactory([Model]); + + await database.persist(new Model('peter')); + await database.persist(new Model('paul')); + + { + const m1 = new Model('peter'); + await expect(database.persist(m1)).rejects.toThrow('username dup key'); + await expect(database.persist(m1)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m1 = new Model('marie'); + const m2 = new Model('marie'); + await expect(database.persist(m1, m2)).rejects.toThrow('username dup key'); + await expect(database.persist(m1, m2)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m = await database.query(Model).filter({username: 'paul'}).findOne(); + m.username = 'peter'; + await expect(database.persist(m)).rejects.toThrow('username dup key'); + await expect(database.persist(m)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const p = database.query(Model).filter({username: 'paul'}).patchOne({username: 'peter'}); + await expect(p).rejects.toThrow('username dup key'); + await expect(p).rejects.toBeInstanceOf(UniqueConstraintFailure); + } +}); diff --git a/packages/mongo/tsconfig.json b/packages/mongo/tsconfig.json index 012d38c18..e9f0f1cf9 100644 --- a/packages/mongo/tsconfig.json +++ b/packages/mongo/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/packages/mysql/src/mysql-adapter.ts b/packages/mysql/src/mysql-adapter.ts index 0fca66ba7..694d2edae 100644 --- a/packages/mysql/src/mysql-adapter.ts +++ b/packages/mysql/src/mysql-adapter.ts @@ -28,11 +28,15 @@ import { SQLStatement, } from '@deepkit/sql'; import { + DatabaseDeleteError, DatabaseLogger, + DatabasePatchError, DatabasePersistenceChangeSet, DatabaseSession, DatabaseTransaction, + DatabaseUpdateError, DeleteResult, + ensureDatabaseError, OrmEntity, PatchResult, primaryKeyObjectConverter, @@ -43,12 +47,24 @@ import { Changes, getPatchSerializeFunction, getSerializeFunction, ReceiveType, import { AbstractClassType, asyncOperation, ClassType, empty, isArray } from '@deepkit/core'; import { FrameCategory, Stopwatch } from '@deepkit/stopwatch'; -function handleError(error: Error | string): void { - const message = 'string' === typeof error ? error : error.message; - if (message.includes('Duplicate entry')) { - //todo: extract table name, column name, and find ClassSchema - throw new UniqueConstraintFailure(); +/** + * Converts a specific database error to a more specific error, if possible. + */ +function handleSpecificError(session: DatabaseSession, error: Error): Error { + let cause: any = error; + while (cause) { + if (cause instanceof Error) { + if (cause.message.includes('Duplicate entry ') + ) { + // Some database drivers contain the SQL, some not. We try to exclude the SQL from the message. + // Cause is attached to the error, so we don't lose information. + return new UniqueConstraintFailure(`${cause.message.split('\n')[0]}`, { cause: error }); + } + cause = cause.cause; + } } + + return error; } export class MySQLStatement extends SQLStatement { @@ -68,7 +84,7 @@ export class MySQLStatement extends SQLStatement { this.logger.logQuery(this.sql, params); return rows[0]; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -88,7 +104,7 @@ export class MySQLStatement extends SQLStatement { this.logger.logQuery(this.sql, params); return rows; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -128,7 +144,7 @@ export class MySQLConnection extends SQLConnection { this.logger.logQuery(sql, params); this.lastExecResult = isArray(res) ? res : [res]; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, sql, params); throw error; } finally { @@ -237,6 +253,10 @@ export class MySQLPersistence extends SQLPersistence { super(platform, connectionPool, session); } + override handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + async batchUpdate(entity: PreparedEntity, changeSets: DatabasePersistenceChangeSet[]): Promise { const prepared = prepareBatchUpdate(this.platform, entity, changeSets, { setNamesWithTableName: true }); if (!prepared) return; @@ -321,26 +341,37 @@ export class MySQLPersistence extends SQLPersistence { ${endSelect} `; - const connection = await this.getConnection(); //will automatically be released in SQLPersistence - const result = await connection.execAndReturnAll(sql, params); + try { + const connection = await this.getConnection(); //will automatically be released in SQLPersistence + const result = await connection.execAndReturnAll(sql, params); - if (!empty(prepared.setReturning)) { - const returning = result[1][0]; - const ids = JSON.parse(returning['@_pk']) as (number | string)[]; - const parsedReturning: { [name: string]: any[] } = {}; - for (const i in prepared.setReturning) { - parsedReturning[i] = JSON.parse(returning['@_f_' + i]) as any[]; - } + if (!empty(prepared.setReturning)) { + const returning = result[1][0]; + const ids = JSON.parse(returning['@_pk']) as (number | string)[]; + const parsedReturning: { [name: string]: any[] } = {}; + for (const i in prepared.setReturning) { + parsedReturning[i] = JSON.parse(returning['@_f_' + i]) as any[]; + } - for (let i = 0; i < ids.length; i++) { - const id = ids[i]; - const r = prepared.assignReturning[id]; - if (!r) continue; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const r = prepared.assignReturning[id]; + if (!r) continue; - for (const name of r.names) { - r.item[name] = parsedReturning[name][i]; + for (const name of r.names) { + r.item[name] = parsedReturning[name][i]; + } } } + } catch (error: any) { + const reflection = ReflectionClass.from(entity.type); + error = new DatabaseUpdateError( + reflection, + changeSets, + `Could not update ${reflection.getClassName()} in database`, + { cause: error }, + ); + throw this.handleSpecificError(error); } } @@ -364,6 +395,10 @@ export class MySQLPersistence extends SQLPersistence { } export class MySQLQueryResolver extends SQLQueryResolver { + override handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + async delete(model: SQLQueryModel, deleteResult: DeleteResult): Promise { const primaryKey = this.classSchema.getPrimary(); const pkField = this.platform.quoteIdentifier(primaryKey.name); @@ -388,6 +423,10 @@ export class MySQLQueryResolver extends SQLQueryResolver const pk = returning[0]['@_pk']; if (pk) deleteResult.primaryKeys = JSON.parse(pk).map(primaryKeyConverted); deleteResult.modified = deleteResult.primaryKeys.length; + } catch (error: any) { + error = new DatabaseDeleteError(this.classSchema, 'Could not delete in database', { cause: error }); + error.query = model; + throw this.handleSpecificError(error); } finally { connection.release(); } @@ -497,7 +536,9 @@ export class MySQLQueryResolver extends SQLQueryResolver for (const i in aggregateFields) { patchResult.returning[i] = (JSON.parse(returning['@_f_' + asAliasName(i)]) as any[]).map(aggregateFields[i].converted); } - + } catch (error: any) { + error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); + throw this.handleSpecificError(error); } finally { connection.release(); } diff --git a/packages/mysql/tests/mysql.spec.ts b/packages/mysql/tests/mysql.spec.ts index 13ac6006f..19af392b2 100644 --- a/packages/mysql/tests/mysql.spec.ts +++ b/packages/mysql/tests/mysql.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from '@jest/globals'; import { createPool } from 'mariadb'; import { MySQLConnectionPool } from '../src/mysql-adapter.js'; -import { AutoIncrement, cast, entity, PrimaryKey } from '@deepkit/type'; +import { AutoIncrement, cast, entity, PrimaryKey, Unique } from '@deepkit/type'; import { databaseFactory } from './factory.js'; +import { UniqueConstraintFailure } from '@deepkit/orm'; test('connection MySQLConnectionPool', async () => { const pool = createPool({ @@ -284,3 +285,41 @@ test('ensure bigints are handled correctly', async () => { // expect(typeof items[1].id).toBe('bigint'); // expect(items[1].id).toEqual(9007199254740999n); }); + +test('unique constraint 1', async () => { + class Model { + id: number & PrimaryKey & AutoIncrement = 0; + constructor(public username: string & Unique = '') {} + } + + const database = await databaseFactory([Model]); + + await database.persist(new Model('peter')); + await database.persist(new Model('paul')); + + { + const m1 = new Model('peter'); + await expect(database.persist(m1)).rejects.toThrow('Duplicate entry \'peter\' for key'); + await expect(database.persist(m1)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m1 = new Model('marie'); + const m2 = new Model('marie'); + await expect(database.persist(m1, m2)).rejects.toThrow('Duplicate entry \'marie\' for key'); + await expect(database.persist(m1, m2)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m = await database.query(Model).filter({username: 'paul'}).findOne(); + m.username = 'peter'; + await expect(database.persist(m)).rejects.toThrow('Duplicate entry \'peter\' for key'); + await expect(database.persist(m)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const p = database.query(Model).filter({username: 'paul'}).patchOne({username: 'peter'}); + await expect(p).rejects.toThrow('Duplicate entry \'peter\' for key'); + await expect(p).rejects.toBeInstanceOf(UniqueConstraintFailure); + } +}); diff --git a/packages/mysql/tsconfig.json b/packages/mysql/tsconfig.json index 872c886a2..59f08c86e 100644 --- a/packages/mysql/tsconfig.json +++ b/packages/mysql/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/packages/orm-integration/src/various.ts b/packages/orm-integration/src/various.ts index 85d2ae6fb..bd0809dc5 100644 --- a/packages/orm-integration/src/various.ts +++ b/packages/orm-integration/src/various.ts @@ -1,17 +1,5 @@ import { expect } from '@jest/globals'; -import { - AutoIncrement, - BackReference, - cast, - DatabaseField, - entity, - isReferenceInstance, - PrimaryKey, - Reference, - Unique, - uuid, - UUID, -} from '@deepkit/type'; +import { AutoIncrement, BackReference, cast, DatabaseField, entity, isReferenceInstance, PrimaryKey, Reference, Unique, uuid, UUID } from '@deepkit/type'; import { identifier, sql, SQLDatabaseAdapter } from '@deepkit/sql'; import { DatabaseFactory } from './test.js'; import { hydrateEntity, isDatabaseOf, UniqueConstraintFailure } from '@deepkit/orm'; @@ -403,37 +391,42 @@ export const variousTests = { } const database = await databaseFactory([Model]); + await database.query(Model).deleteMany(); await database.persist(new Model('Peter2'), new Model('Peter3'), new Model('Marie')); { - const items = await database.query(Model).filter({ username: { $like: 'Peter%' } }).find(); + const items = await database.query(Model) + .filter({ username: { $like: 'Peter%' } }).orderBy('username').find(); expect(items).toHaveLength(2); expect(items).toMatchObject([{ username: 'Peter2' }, { username: 'Peter3' }]); } { - const items = await database.query(Model).filter({ username: { $like: 'Pet%' } }).find(); + const items = await database.query(Model) + .filter({ username: { $like: 'Pet%' } }).orderBy('username').find(); expect(items).toHaveLength(2); expect(items).toMatchObject([{ username: 'Peter2' }, { username: 'Peter3' }]); } { - const items = await database.query(Model).filter({ username: { $like: 'Peter_' } }).find(); + const items = await database.query(Model) + .filter({ username: { $like: 'Peter_' } }).orderBy('username').find(); expect(items).toHaveLength(2); expect(items).toMatchObject([{ username: 'Peter2' }, { username: 'Peter3' }]); } { - const items = await database.query(Model).filter({ username: { $like: '%r%' } }).find(); + const items = await database.query(Model) + .filter({ username: { $like: '%r%' } }).orderBy('username').find(); expect(items).toHaveLength(3); - expect(items).toMatchObject([{ username: 'Peter2' }, { username: 'Peter3' }, { username: 'Marie' }]); + expect(items).toMatchObject([ { username: 'Marie' }, { username: 'Peter2' }, { username: 'Peter3' }]); } { await database.query(Model).filter({ username: { $like: 'Mar%' } }).patchOne({ username: 'Marie2' }); - const items = await database.query(Model).find(); - expect(items).toMatchObject([{ username: 'Peter2' }, { username: 'Peter3' }, { username: 'Marie2' }]); + const items = await database.query(Model).orderBy('username').find(); + expect(items).toMatchObject([ { username: 'Marie2' }, { username: 'Peter2' }, { username: 'Peter3' } ]); } }, async deepObjectPatch(databaseFactory: DatabaseFactory) { diff --git a/packages/orm/src/database-session.ts b/packages/orm/src/database-session.ts index e7e5888e0..669258d9f 100644 --- a/packages/orm/src/database-session.ts +++ b/packages/orm/src/database-session.ts @@ -282,7 +282,7 @@ export abstract class DatabaseTransaction { } } -export class DatabaseSession { +export class DatabaseSession { public readonly id = SESSION_IDS++; public withIdentityMap = true; diff --git a/packages/orm/src/query.ts b/packages/orm/src/query.ts index b106fb5c7..4cd4b3815 100644 --- a/packages/orm/src/query.ts +++ b/packages/orm/src/query.ts @@ -746,6 +746,16 @@ export class Query extends BaseQuery { return cloned as any; } + /** + * Clones the query and returns a new instance. + * This happens automatically for each modification, so you don't need to call it manually. + * + * ```typescript + * let query1 = database.query(User); + * let query2 = query1.filter({name: 'Peter'}); + * // query1 is not modified, query2 is a new instance with the filter applied + * ``` + */ clone(): this { const cloned = new (this['constructor'] as ClassType)(this.classSchema, this.session, this.resolver); cloned.model = this.model.clone(cloned) as this['model']; @@ -772,6 +782,11 @@ export class Query extends BaseQuery { return query as this; } + /** + * Returns the number of items matching the query. + * + * @throws DatabaseError + */ public async count(fromHas: boolean = false): Promise { let query: Query | undefined = undefined; @@ -790,6 +805,11 @@ export class Query extends BaseQuery { } } + /** + * Fetches all items matching the query. + * + * @throws DatabaseError + */ public async find(): Promise[]> { const frame = this.session.stopwatch?.start('Find:' + this.classSchema.getClassName(), FrameCategory.database); let query: Query | undefined = undefined; @@ -808,6 +828,11 @@ export class Query extends BaseQuery { } } + /** + * Fetches a single item matching the query or undefined. + * + * @throws DatabaseError + */ public async findOneOrUndefined(): Promise | undefined> { const frame = this.session.stopwatch?.start('FindOne:' + this.classSchema.getClassName(), FrameCategory.database); let query: Query | undefined = undefined; @@ -826,16 +851,31 @@ export class Query extends BaseQuery { } } + /** + * Fetches a single item matching the query. + * + * @throws DatabaseError + */ public async findOne(): Promise> { const item = await this.findOneOrUndefined(); if (!item) throw new ItemNotFound(`Item ${this.classSchema.getClassName()} not found`); return item as Resolve; } + /** + * Deletes all items matching the query. + * + * @throws DatabaseDeleteError + */ public async deleteMany(): Promise> { return await this.delete(this) as any; } + /** + * Deletes a single item matching the query. + * + * @throws DatabaseDeleteError + */ public async deleteOne(): Promise> { return await this.delete(this.limit(1)); } @@ -889,10 +929,22 @@ export class Query extends BaseQuery { } } + /** + * Updates all items matching the query with the given patch. + * + * @throws DatabasePatchError + * @throws UniqueConstraintFailure + */ public async patchMany(patch: ChangesInterface | DeepPartial): Promise> { return await this.patch(this, patch); } + /** + * Updates a single item matching the query with the given patch. + * + * @throws DatabasePatchError + * @throws UniqueConstraintFailure + */ public async patchOne(patch: ChangesInterface | DeepPartial): Promise> { return await this.patch(this.limit(1), patch); } @@ -976,10 +1028,25 @@ export class Query extends BaseQuery { } } + /** + * Returns true if the query matches at least one item. + * + * @throws DatabaseError + */ public async has(): Promise { return await this.count(true) > 0; } + /** + * Returns the primary keys of the query. + * + * ```typescript + * const ids = await database.query(User).ids(); + * // ids: number[] + * ``` + * + * @throws DatabaseError + */ public async ids(singleKey?: false): Promise[]>; public async ids(singleKey: true): Promise[]>; public async ids(singleKey: boolean = false): Promise[] | PrimaryKeyType[]> { @@ -997,16 +1064,41 @@ export class Query extends BaseQuery { return data; } + /** + * Returns the specified field of the query from all items. + * + * ```typescript + * const usernames = await database.query(User).findField('username'); + * // usernames: string[] + * ``` + * + * @throws DatabaseError + */ public async findField>(name: K): Promise { const items = await this.select(name as keyof Resolve).find() as T[]; return items.map(v => v[name]); } + /** + * Returns the specified field of the query from a single item, throws if not found. + * + * ```typescript + * const username = await database.query(User).findOneField('username'); + * ``` + * + * @throws ItemNotFound if no item is found + * @throws DatabaseError + */ public async findOneField>(name: K): Promise { const item = await this.select(name as keyof Resolve).findOne() as T; return item[name]; } + /** + * Returns the specified field of the query from a single item or undefined. + * + * @throws DatabaseError + */ public async findOneFieldOrUndefined>(name: K): Promise { const item = await this.select(name as keyof Resolve).findOneOrUndefined(); if (item) return item[name]; diff --git a/packages/orm/src/type.ts b/packages/orm/src/type.ts index c2456d6e8..3e56efd1f 100644 --- a/packages/orm/src/type.ts +++ b/packages/orm/src/type.ts @@ -8,8 +8,10 @@ * You should have received a copy of the MIT License along with this program. */ -import { PrimaryKeyFields, PrimaryKeyType, ReflectionClass, ValidationErrorItem } from '@deepkit/type'; +import { Changes, PrimaryKeyFields, PrimaryKeyType, ReflectionClass, ValidationErrorItem } from '@deepkit/type'; import { CustomError } from '@deepkit/core'; +import { DatabasePersistenceChangeSet } from './database-adapter.js'; +import { DatabaseQueryModel } from './query.js'; export interface OrmEntity { } @@ -20,6 +22,59 @@ export type DeleteResult = { modified: number, primaryKeys: PrimaryKeyFields< export class DatabaseError extends CustomError { } +/** + * Wraps whatever error into a DatabaseError, if it's not already a DatabaseError. + */ +export function ensureDatabaseError(error: Error | string): Error { + if ('string' === typeof error) return new DatabaseError(error); + if (error instanceof DatabaseError) return error; + + return new DatabaseError(error.message, { cause: error }); +} + +export class DatabaseInsertError extends DatabaseError { + constructor( + public readonly entity: ReflectionClass, + public readonly items: OrmEntity[], + ...args: ConstructorParameters + ) { + super(...args); + } +} + +export class DatabaseUpdateError extends DatabaseError { + constructor( + public readonly entity: ReflectionClass, + public readonly changeSets: DatabasePersistenceChangeSet[], + ...args: ConstructorParameters + ) { + super(...args); + } +} + +export class DatabasePatchError extends DatabaseError { + constructor( + public readonly entity: ReflectionClass, + public readonly query: DatabaseQueryModel, + public readonly changeSets: Changes, + ...args: ConstructorParameters + ) { + super(...args); + } +} + +export class DatabaseDeleteError extends DatabaseError { + public readonly query?: DatabaseQueryModel; + public readonly items?: OrmEntity[]; + + constructor( + public readonly entity: ReflectionClass, + ...args: ConstructorParameters + ) { + super(...args); + } +} + export class DatabaseValidationError extends DatabaseError { constructor( public readonly classSchema: ReflectionClass, @@ -30,11 +85,4 @@ export class DatabaseValidationError extends DatabaseError { } export class UniqueConstraintFailure extends DatabaseError { - constructor( - // public readonly classSchema: ClassSchema, - // public readonly property: PropertySchema, - ) { - super('Unique constraint failure'); - // super(`Unique constraint failure for ${classSchema.getClassName()}.${property.name}`); - } } diff --git a/packages/orm/tsconfig.json b/packages/orm/tsconfig.json index 3fef63a2c..473a66117 100644 --- a/packages/orm/tsconfig.json +++ b/packages/orm/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, @@ -22,8 +23,7 @@ "include": [ "src", "browser.ts", - "index.ts", - "../../../../Library/Caches/JetBrains/WebStorm2021.2/javascript/typings/jest/27.0.1/node_modules/@types/jest/index.d.ts" + "index.ts" ], "exclude": [ "tests" diff --git a/packages/postgres/src/postgres-adapter.ts b/packages/postgres/src/postgres-adapter.ts index 1469771bf..13244a4f4 100644 --- a/packages/postgres/src/postgres-adapter.ts +++ b/packages/postgres/src/postgres-adapter.ts @@ -28,11 +28,16 @@ import { SQLStatement, } from '@deepkit/sql'; import { + DatabaseDeleteError, + DatabaseError, DatabaseLogger, + DatabasePatchError, DatabasePersistenceChangeSet, DatabaseSession, DatabaseTransaction, + DatabaseUpdateError, DeleteResult, + ensureDatabaseError, OrmEntity, PatchResult, primaryKeyObjectConverter, @@ -45,14 +50,27 @@ import { AbstractClassType, asyncOperation, ClassType, empty } from '@deepkit/co import { FrameCategory, Stopwatch } from '@deepkit/stopwatch'; import { Changes, getPatchSerializeFunction, getSerializeFunction, ReceiveType, ReflectionClass, ReflectionKind, ReflectionProperty, resolvePath } from '@deepkit/type'; -function handleError(error: Error | string): void { - const message = 'string' === typeof error ? error : error.message; - if (message.includes('violates unique constraint')) { - //todo: extract table name, column name, and find ClassSchema - throw new UniqueConstraintFailure(); +/** + * Converts a specific database error to a more specific error, if possible. + */ +function handleSpecificError(session: DatabaseSession, error: DatabaseError): Error { + let cause: any = error; + while (cause) { + if (cause instanceof Error) { + if (cause.message.includes('duplicate key value') + && 'table' in cause && 'string' === typeof cause.table + && 'detail' in cause && 'string' === typeof cause.detail + ) { + return new UniqueConstraintFailure(`${cause.message}: ${cause.detail}`, { cause: error }); + } + cause = cause.cause; + } } + + return error; } + export class PostgresStatement extends SQLStatement { protected released = false; @@ -72,7 +90,7 @@ export class PostgresStatement extends SQLStatement { }); return res.rows[0]; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -92,7 +110,7 @@ export class PostgresStatement extends SQLStatement { }); return res.rows; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -135,7 +153,7 @@ export class PostgresConnection extends SQLConnection { this.lastReturningRows = res.rows; this.changes = res.rowCount; } catch (error: any) { - handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, sql, params); throw error; } finally { @@ -249,6 +267,10 @@ export class PostgresPersistence extends SQLPersistence { super(platform, connectionPool, session); } + override handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + async batchUpdate(entity: PreparedEntity, changeSets: DatabasePersistenceChangeSet[]): Promise { const prepared = prepareBatchUpdate(this.platform, entity, changeSets); if (!prepared) return; @@ -326,15 +348,26 @@ export class PostgresPersistence extends SQLPersistence { RETURNING ${returningSelect.join(', ')}; `; - const connection = await this.getConnection(); //will automatically be released in SQLPersistence - const result = await connection.execAndReturnAll(sql, params); - for (const returning of result) { - const r = prepared.assignReturning[returning[prepared.pkName]]; - if (!r) continue; + try { + const connection = await this.getConnection(); //will automatically be released in SQLPersistence + const result = await connection.execAndReturnAll(sql, params); + for (const returning of result) { + const r = prepared.assignReturning[returning[prepared.pkName]]; + if (!r) continue; - for (const name of r.names) { - r.item[name] = returning[name]; + for (const name of r.names) { + r.item[name] = returning[name]; + } } + } catch (error: any) { + const reflection = ReflectionClass.from(entity.type); + error = new DatabaseUpdateError( + reflection, + changeSets, + `Could not update ${reflection.getClassName()} in database`, + { cause: error }, + ); + throw this.handleSpecificError(error); } } @@ -366,20 +399,9 @@ export class PostgresPersistence extends SQLPersistence { return `INSERT INTO ${this.platform.getTableIdentifier(classSchema)} (${fields.join(', ')}) VALUES (${values.join('), (')}) ${returning}`; } - - protected placeholderPosition: number = 1; - - protected resetPlaceholderSymbol() { - this.placeholderPosition = 1; - } - - protected getPlaceholderSymbol() { - return '$' + this.placeholderPosition++; - } } export class PostgresSQLQueryResolver extends SQLQueryResolver { - async delete(model: SQLQueryModel, deleteResult: DeleteResult): Promise { const primaryKey = this.classSchema.getPrimary(); const pkField = this.platform.quoteIdentifier(primaryKey.name); @@ -404,11 +426,19 @@ export class PostgresSQLQueryResolver extends SQLQueryResol for (const row of rows) { deleteResult.primaryKeys.push(primaryKeyConverted(row[primaryKey.name])); } + } catch (error: any) { + error = new DatabaseDeleteError(this.classSchema, 'Could not delete in database', { cause: error }); + error.query = model; + throw this.handleSpecificError(error); } finally { connection.release(); } } + override handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + async patch(model: SQLQueryModel, changes: Changes, patchResult: PatchResult): Promise { const select: string[] = []; const selectParams: any[] = []; @@ -511,6 +541,9 @@ export class PostgresSQLQueryResolver extends SQLQueryResol patchResult.returning[i].push(aggregateFields[i].converted(returning[i])); } } + } catch (error: any) { + error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); + throw this.handleSpecificError(error); } finally { connection.release(); } diff --git a/packages/postgres/tests/postgres.spec.ts b/packages/postgres/tests/postgres.spec.ts index cb827f098..6b9adb743 100644 --- a/packages/postgres/tests/postgres.spec.ts +++ b/packages/postgres/tests/postgres.spec.ts @@ -1,7 +1,9 @@ -import { AutoIncrement, cast, entity, float, float32, float64, int16, int32, int8, integer, PrimaryKey, uint16, uint32, uint8 } from '@deepkit/type'; +import { AutoIncrement, cast, entity, PrimaryKey, Unique } from '@deepkit/type'; import { expect, test } from '@jest/globals'; import pg from 'pg'; import { databaseFactory } from './factory.js'; +import { DatabaseError, DatabaseInsertError, UniqueConstraintFailure } from '@deepkit/orm'; +import { assertInstanceOf } from '@deepkit/core'; test('count', async () => { const pool = new pg.Pool({ @@ -181,3 +183,51 @@ test('json field and query', async () => { expect(res).toMatchObject([{ id: 2, raw: { productId: 2, name: 'second' } }]); } }); + +test('unique constraint 1', async () => { + class Model { + id: number & PrimaryKey & AutoIncrement = 0; + constructor(public username: string & Unique = '') {} + } + + const database = await databaseFactory([Model]); + + await database.persist(new Model('peter')); + await database.persist(new Model('paul')); + + { + const m1 = new Model('peter'); + await expect(database.persist(m1)).rejects.toThrow('Key (username)=(peter) already exists'); + await expect(database.persist(m1)).rejects.toBeInstanceOf(UniqueConstraintFailure); + + try { + await database.persist(m1) + } catch (error: any) { + assertInstanceOf(error, UniqueConstraintFailure); + assertInstanceOf(error.cause, DatabaseInsertError); + assertInstanceOf(error.cause.cause, DatabaseError); + // error.cause.cause.cause is from the driver + expect(error.cause.cause.cause.table).toBe('Model'); + } + } + + { + const m1 = new Model('marie'); + const m2 = new Model('marie'); + await expect(database.persist(m1, m2)).rejects.toThrow('Key (username)=(marie) already exists'); + await expect(database.persist(m1, m2)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m = await database.query(Model).filter({username: 'paul'}).findOne(); + m.username = 'peter'; + await expect(database.persist(m)).rejects.toThrow('Key (username)=(peter) already exists'); + await expect(database.persist(m)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const p = database.query(Model).filter({username: 'paul'}).patchOne({username: 'peter'}); + await expect(p).rejects.toThrow('Key (username)=(peter) already exists'); + await expect(p).rejects.toBeInstanceOf(UniqueConstraintFailure); + } +}); diff --git a/packages/postgres/tsconfig.json b/packages/postgres/tsconfig.json index 4cac532d8..65d223fa1 100644 --- a/packages/postgres/tsconfig.json +++ b/packages/postgres/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, @@ -25,7 +26,8 @@ ], "include": [ "src", - "index.ts" + "index.ts", + "tests/**/*.ts" ], "exclude": [ "tests" diff --git a/packages/sql/src/sql-adapter.ts b/packages/sql/src/sql-adapter.ts index 03572ae6f..7e3e4d81d 100644 --- a/packages/sql/src/sql-adapter.ts +++ b/packages/sql/src/sql-adapter.ts @@ -13,14 +13,18 @@ import { Database, DatabaseAdapter, DatabaseAdapterQueryFactory, + DatabaseDeleteError, DatabaseEntityRegistry, DatabaseError, + DatabaseInsertError, DatabaseLogger, + DatabasePatchError, DatabasePersistence, DatabasePersistenceChangeSet, DatabaseQueryModel, DatabaseSession, DatabaseTransaction, + DatabaseUpdateError, DeleteResult, FilterQuery, FindQuery, @@ -33,7 +37,7 @@ import { RawFactory, Replace, Resolve, - SORT_ORDER + SORT_ORDER, } from '@deepkit/orm'; import { AbstractClassType, ClassType, isArray, isClass } from '@deepkit/core'; import { @@ -46,9 +50,8 @@ import { ReceiveType, ReflectionClass, ReflectionKind, - ReflectionProperty, resolveReceiveType, - Type + Type, } from '@deepkit/type'; import { DefaultPlatform, SqlPlaceholderStrategy } from './platform/default-platform.js'; import { Sql, SqlBuilder } from './sql-builder.js'; @@ -106,7 +109,7 @@ export abstract class SQLConnection { protected connectionPool: SQLConnectionPool, public logger: DatabaseLogger = new DatabaseLogger, public transaction?: DatabaseTransaction, - public stopwatch?: Stopwatch + public stopwatch?: Stopwatch, ) { } @@ -199,7 +202,7 @@ export class SQLQueryResolver extends GenericQueryResolver< protected connectionPool: SQLConnectionPool, protected platform: DefaultPlatform, classSchema: ReflectionClass, - session: DatabaseSession + session: DatabaseSession, ) { super(classSchema, session); } @@ -209,7 +212,7 @@ export class SQLQueryResolver extends GenericQueryResolver< this.classSchema, this.platform.serializer, this.session.getHydrator(), - withIdentityMap ? this.session.identityMap : undefined + withIdentityMap ? this.session.identityMap : undefined, ); } @@ -217,6 +220,14 @@ export class SQLQueryResolver extends GenericQueryResolver< return this.platform.getTableIdentifier(schema); } + /** + * If possible, this method should handle specific SQL errors and convert + * them to more specific error classes with more information, e.g. unique constraint. + */ + handleSpecificError(error: Error): Error { + return error; + } + async count(model: SQLQueryModel): Promise { const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; const sqlBuilder = new SqlBuilder(this.platform); @@ -232,6 +243,8 @@ export class SQLQueryResolver extends GenericQueryResolver< //postgres has bigint as return type of COUNT, so we need to convert always return Number(row.count); + } catch (error: any) { + throw this.handleSpecificError(error); } finally { connection.release(); } @@ -252,8 +265,11 @@ export class SQLQueryResolver extends GenericQueryResolver< try { await connection.run(sql.sql, sql.params); deleteResult.modified = await connection.getChanges(); - //todo, implement deleteResult.primaryKeys + } catch (error: any) { + error = new DatabaseDeleteError(this.classSchema, `Could not delete ${this.classSchema.getClassName()} in database`, { cause: error }); + error.query = model; + throw this.handleSpecificError(error); } finally { connection.release(); } @@ -273,7 +289,8 @@ export class SQLQueryResolver extends GenericQueryResolver< try { rows = await connection.execAndReturnAll(sql.sql, sql.params); } catch (error: any) { - throw new DatabaseError(`Could not query ${this.classSchema.getClassName()} due to SQL error ${error}.\nSQL: ${sql.sql}\nParams: ${JSON.stringify(sql.params)}. Error: ${error}`); + error = this.handleSpecificError(error); + throw new DatabaseError(`Could not query ${this.classSchema.getClassName()} due to SQL error ${error}`, { cause: error }); } finally { connection.release(); } @@ -324,6 +341,9 @@ export class SQLQueryResolver extends GenericQueryResolver< try { await connection.run(sql.sql, sql.params); patchResult.modified = await connection.getChanges(); + } catch (error: any) { + error = new DatabasePatchError(this.classSchema, model, changes, `Could not patch ${this.classSchema.getClassName()} in database`, { cause: error }); + throw this.handleSpecificError(error); } finally { connection.release(); } @@ -359,7 +379,7 @@ export class SqlQuery { convertToSQL( platform: DefaultPlatform, placeholderStrategy: SqlPlaceholderStrategy, - tableName?: string + tableName?: string, ): SqlStatement { let sql = ''; const params: any[] = []; @@ -418,7 +438,7 @@ export class SQLDatabaseQuery extends Query { constructor( classSchema: ReflectionClass, protected databaseSession: DatabaseSession, - protected resolver: SQLQueryResolver + protected resolver: SQLQueryResolver, ) { super(classSchema, databaseSession, resolver); if (!databaseSession.withIdentityMap) this.model.withIdentityMap = false; @@ -458,7 +478,7 @@ export class SQLDatabaseQueryFactory extends DatabaseAdapterQueryFactory { createQuery(classType: ReceiveType | ClassType | AbstractClassType | ReflectionClass): SQLDatabaseQuery { return new SQLDatabaseQuery(ReflectionClass.from(classType), this.databaseSession, - new SQLQueryResolver(this.connectionPool, this.platform, ReflectionClass.from(classType), this.databaseSession) + new SQLQueryResolver(this.connectionPool, this.platform, ReflectionClass.from(classType), this.databaseSession), ); } } @@ -723,6 +743,14 @@ export class SQLPersistence extends DatabasePersistence { super(); } + /** + * If possible, this method should handle specific SQL errors and convert + * them to more specific error classes with more information, e.g. unique constraint. + */ + handleSpecificError(error: Error): Error { + return error; + } + async getConnection(): Promise> { if (!this.connection) { this.connection = await this.connectionPool.getConnection(this.session.logger, this.session.assignedTransaction, this.session.stopwatch); @@ -799,7 +827,19 @@ export class SQLPersistence extends DatabasePersistence { } const sql = updates.join(';\n'); - await (await this.getConnection()).run(sql); + + try { + await (await this.getConnection()).run(sql); + } catch (error: any) { + const reflection = ReflectionClass.from(entity.type); + error = new DatabaseUpdateError( + reflection, + changeSets, + `Could not update ${reflection.getClassName()} in database`, + { cause: error }, + ); + throw this.handleSpecificError(error); + } } protected async batchInsert(classSchema: ReflectionClass, items: T[]) { @@ -835,10 +875,13 @@ export class SQLPersistence extends DatabasePersistence { try { await (await this.getConnection()).run(sql, params); } catch (error: any) { - if (error instanceof DatabaseError) { - throw error; - } - throw new DatabaseError(`Could not insert ${classSchema.getClassName()} into database: ${String(error)}, sql: ${sql}, params: ${params}`); + error = new DatabaseInsertError( + classSchema, + items as OrmEntity[], + `Could not insert ${classSchema.getClassName()} into database`, + { cause: error }, + ); + throw this.handleSpecificError(error); } } @@ -864,7 +907,17 @@ export class SQLPersistence extends DatabasePersistence { const sql = `DELETE FROM ${this.platform.getTableIdentifier(classSchema)} WHERE ${this.platform.quoteIdentifier(pkName)} IN (${pks})`; - await (await this.getConnection()).run(sql, params); + try { + await (await this.getConnection()).run(sql, params); + } catch (error: any) { + error = new DatabaseDeleteError( + classSchema, + `Could not delete ${classSchema.getClassName()} from database`, + { cause: error }, + ); + error.items = items; + throw this.handleSpecificError(error); + } } } @@ -872,7 +925,7 @@ export function prepareBatchUpdate( platform: DefaultPlatform, entity: PreparedEntity, changeSets: DatabasePersistenceChangeSet[], - options: { setNamesWithTableName?: true } = {} + options: { setNamesWithTableName?: true } = {}, ) { const partialSerialize = getPartialSerializeFunction(entity.type, platform.serializer.serializeRegistry); const tableName = entity.tableNameEscaped; @@ -941,7 +994,7 @@ export function prepareBatchUpdate( aggregateSelects[fieldName].push({ id: changeSet.primaryKey[pkName], - sql: `_origin.${platform.quoteIdentifier(fieldName)} + ${platform.quoteValue(value)}` + sql: `_origin.${platform.quoteIdentifier(fieldName)} + ${platform.quoteValue(value)}`, }); } } diff --git a/packages/sql/tsconfig.json b/packages/sql/tsconfig.json index ed9944dc6..b0de86a35 100644 --- a/packages/sql/tsconfig.json +++ b/packages/sql/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/packages/sqlite/src/sqlite-adapter.ts b/packages/sqlite/src/sqlite-adapter.ts index 091681865..23600bf38 100644 --- a/packages/sqlite/src/sqlite-adapter.ts +++ b/packages/sqlite/src/sqlite-adapter.ts @@ -11,21 +11,26 @@ import { AbstractClassType, asyncOperation, ClassType, empty } from '@deepkit/core'; import { DatabaseAdapter, + DatabaseDeleteError, DatabaseError, DatabaseLogger, + DatabasePatchError, DatabasePersistenceChangeSet, DatabaseSession, DatabaseTransaction, + DatabaseUpdateError, DeleteResult, + ensureDatabaseError, OrmEntity, PatchResult, primaryKeyObjectConverter, - UniqueConstraintFailure + UniqueConstraintFailure, } from '@deepkit/orm'; import { asAliasName, DefaultPlatform, prepareBatchUpdate, + PreparedEntity, splitDotPath, SqlBuilder, SQLConnection, @@ -37,13 +42,30 @@ import { SQLQueryModel, SQLQueryResolver, SQLStatement, - PreparedEntity } from '@deepkit/sql'; import { Changes, getPatchSerializeFunction, getSerializeFunction, ReceiveType, ReflectionClass, resolvePath } from '@deepkit/type'; import sqlite3 from 'better-sqlite3'; import { SQLitePlatform } from './sqlite-platform.js'; import { FrameCategory, Stopwatch } from '@deepkit/stopwatch'; +/** + * Converts a specific database error to a more specific error, if possible. + */ +function handleSpecificError(session: DatabaseSession, error: DatabaseError): Error { + let cause: any = error; + while (cause) { + if (cause instanceof Error) { + if (cause.message.includes('UNIQUE constraint failed') + ) { + return new UniqueConstraintFailure(`${cause.message}`, { cause: error }); + } + cause = cause.cause; + } + } + + return error; +} + export class SQLiteStatement extends SQLStatement { constructor(protected logger: DatabaseLogger, protected sql: string, protected stmt: sqlite3.Statement, protected stopwatch?: Stopwatch) { super(); @@ -56,7 +78,8 @@ export class SQLiteStatement extends SQLStatement { const res = this.stmt.get(...params); this.logger.logQuery(this.sql, params); return res; - } catch (error) { + } catch (error: any) { + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -71,7 +94,8 @@ export class SQLiteStatement extends SQLStatement { const res = this.stmt.all(...params); this.logger.logQuery(this.sql, params); return res; - } catch (error) { + } catch (error: any) { + error = ensureDatabaseError(error); this.logger.failedQuery(error, this.sql, params); throw error; } finally { @@ -140,14 +164,6 @@ export class SQLiteConnection extends SQLConnection { return new SQLiteStatement(this.logger, sql, this.db.prepare(sql), this.stopwatch); } - protected handleError(error: Error | string): void { - const message = 'string' === typeof error ? error : error.message; - if (message.includes('UNIQUE constraint failed')) { - //todo: extract table name, column name, and find ClassSchema - throw new UniqueConstraintFailure(); - } - } - async run(sql: string, params: any[] = []) { const frame = this.stopwatch ? this.stopwatch.start('Query', FrameCategory.databaseQuery) : undefined; try { @@ -157,7 +173,7 @@ export class SQLiteConnection extends SQLConnection { const result = stmt.run(...params); this.changes = result.changes; } catch (error: any) { - this.handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, sql, params); throw error; } finally { @@ -172,7 +188,7 @@ export class SQLiteConnection extends SQLConnection { this.db.exec(sql); this.logger.logQuery(sql, []); } catch (error: any) { - this.handleError(error); + error = ensureDatabaseError(error); this.logger.failedQuery(error, sql, []); throw error; } finally { @@ -271,6 +287,10 @@ export class SQLitePersistence extends SQLPersistence { super(platform, connectionPool, database); } + override handleSpecificError(error: Error): Error { + return handleSpecificError(this.session, error); + } + protected getInsertSQL(classSchema: ReflectionClass, fields: string[], values: string[]): string { if (fields.length === 0) { const pkName = this.platform.quoteIdentifier(classSchema.getPrimary().name); @@ -363,18 +383,29 @@ export class SQLitePersistence extends SQLPersistence { _b WHERE ${prepared.tableName}.${prepared.pkField} = _b.${prepared.originPkField}; `; - await connection.exec(updateSql); + try { + await connection.exec(updateSql); - if (!empty(prepared.setReturning)) { - const returnings = await connection.execAndReturnAll('SELECT * FROM _b'); - for (const returning of returnings) { - const r = prepared.assignReturning[returning[prepared.originPkName]]; - if (!r) continue; + if (!empty(prepared.setReturning)) { + const returnings = await connection.execAndReturnAll('SELECT * FROM _b'); + for (const returning of returnings) { + const r = prepared.assignReturning[returning[prepared.originPkName]]; + if (!r) continue; - for (const name of r.names) { - r.item[name] = returning[name]; + for (const name of r.names) { + r.item[name] = returning[name]; + } } } + } catch (error: any) { + const reflection = ReflectionClass.from(entity.type); + error = new DatabaseUpdateError( + reflection, + changeSets, + `Could not update ${reflection.getClassName()} in database`, + { cause: error }, + ); + throw this.handleSpecificError(error); } } @@ -404,6 +435,10 @@ export class SQLiteQueryResolver extends SQLQueryResolver, deleteResult: DeleteResult): Promise { // if (model.hasJoins()) throw new Error('Delete with joins not supported. Fetch first the ids then delete.'); const sqlBuilderFrame = this.session.stopwatch ? this.session.stopwatch.start('SQL Builder') : undefined; @@ -432,6 +467,10 @@ export class SQLiteQueryResolver extends SQLQueryResolver extends SQLQueryResolver { @@ -938,3 +939,41 @@ test('uuid 3', async () => { const hash = hasher(user); expect(hash).toContain(user.id); }); + +test('unique constraint 1', async () => { + class Model { + id: number & PrimaryKey & AutoIncrement = 0; + constructor(public username: string & Unique = '') {} + } + + const database = await databaseFactory([Model]); + + await database.persist(new Model('peter')); + await database.persist(new Model('paul')); + + { + const m1 = new Model('peter'); + await expect(database.persist(m1)).rejects.toThrow('constraint failed'); + await expect(database.persist(m1)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m1 = new Model('marie'); + const m2 = new Model('marie'); + await expect(database.persist(m1, m2)).rejects.toThrow('constraint failed'); + await expect(database.persist(m1, m2)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const m = await database.query(Model).filter({username: 'paul'}).findOne(); + m.username = 'peter'; + await expect(database.persist(m)).rejects.toThrow('constraint failed'); + await expect(database.persist(m)).rejects.toBeInstanceOf(UniqueConstraintFailure); + } + + { + const p = database.query(Model).filter({username: 'paul'}).patchOne({username: 'peter'}); + await expect(p).rejects.toThrow('constraint failed'); + await expect(p).rejects.toBeInstanceOf(UniqueConstraintFailure); + } +}); diff --git a/packages/sqlite/tsconfig.json b/packages/sqlite/tsconfig.json index 9de48930b..d4e2cfcc1 100644 --- a/packages/sqlite/tsconfig.json +++ b/packages/sqlite/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..b54bd9b88 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "lib": [ + "es2021", + "es2022.error" + ] + } +}