Skip to content

Commit

Permalink
feat(orm): better Error handling + UniqueConstraintFailure
Browse files Browse the repository at this point in the history
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
  • Loading branch information
marcj committed Feb 4, 2024
1 parent 5f6bd12 commit f1845ee
Show file tree
Hide file tree
Showing 28 changed files with 716 additions and 149 deletions.
5 changes: 3 additions & 2 deletions packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/reactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"strict": true,
Expand Down
4 changes: 3 additions & 1 deletion packages/filesystem/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"outDir": "./dist/cjs",
"declaration": true,
"composite": true,
"types": []
"types": [
"node"
]
},
"include": [
"src",
Expand Down
1 change: 1 addition & 0 deletions packages/framework/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"strict": true,
Expand Down
7 changes: 1 addition & 6 deletions packages/mongo/src/client/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { BaseResponse } from './command/command.js';
import { DatabaseError, UniqueConstraintFailure } from '@deepkit/orm';
import { DatabaseError } from '@deepkit/orm';


/**
Expand All @@ -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;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/mongo/src/error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 60 additions & 11 deletions packages/mongo/src/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -41,10 +52,15 @@ export class MongoPersistence extends DatabasePersistence {
return this.connection;
}

handleSpecificError(error: Error): Error {
return handleSpecificError(this.session, error);
}

async remove<T extends OrmEntity>(classSchema: ReflectionClass<T>, items: T[]): Promise<void> {
const classState = getClassState(classSchema);
const partialSerialize = getPartialSerializeFunction(classSchema.type, mongoSerializer.serializeRegistry);

let command: DeleteCommand<any>;
if (classSchema.getPrimaries().length === 1) {
const pk = classSchema.getPrimary();
const pkName = pk.name;
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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<T extends OrmEntity>(classSchema: ReflectionClass<T>, changeSets: DatabasePersistenceChangeSet<T>[]): Promise<void> {
Expand Down Expand Up @@ -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);
}
}
}
16 changes: 14 additions & 2 deletions packages/mongo/src/query.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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<T extends OrmEntity>(classSchema: ReflectionClass<T>, model: MongoQueryModel<T>): any {
return convertClassQueryToMongo(classSchema, (model.filter || {}) as FilterQuery<T>, {}, {
Expand Down Expand Up @@ -70,6 +71,10 @@ export class MongoQueryResolver<T extends OrmEntity> extends GenericQueryResolve
return await this.count(model) > 0;
}

handleSpecificError(error: Error): Error {
return handleSpecificError(this.session, error);
}

protected getPrimaryKeysProjection(classSchema: ReflectionClass<any>) {
const pk: { [name: string]: 1 | 0 } = { _id: 0 };
for (const property of classSchema.getPrimaries()) {
Expand Down Expand Up @@ -108,6 +113,10 @@ export class MongoQueryResolver<T extends OrmEntity> extends GenericQueryResolve

const query = convertClassQueryToMongo(this.classSchema, { [primaryKeyName]: { $in: primaryKeys.map(v => v[primaryKeyName]) } } as FilterQuery<T>);
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();
}
Expand Down Expand Up @@ -181,6 +190,9 @@ export class MongoQueryResolver<T extends OrmEntity> 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();
}
Expand Down
43 changes: 42 additions & 1 deletion packages/mongo/tests/mongo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from '@jest/globals';
import {
arrayBufferFrom,
AutoIncrement,
BackReference,
cast,
entity,
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
});
1 change: 1 addition & 0 deletions packages/mongo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
Expand Down
Loading

0 comments on commit f1845ee

Please sign in to comment.