mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
refactor(core): slonik error handling (#307)
This commit is contained in:
parent
16396cf231
commit
dcd84086f6
10 changed files with 105 additions and 57 deletions
|
@ -4,14 +4,9 @@ import { DatabasePoolType, sql } from 'slonik';
|
||||||
|
|
||||||
import { isKeyOf } from '@/utils/schema';
|
import { isKeyOf } from '@/utils/schema';
|
||||||
|
|
||||||
|
import { FindManyData } from './types';
|
||||||
import { conditionalSql, convertToIdentifiers, convertToPrimitiveOrSql } from './utils';
|
import { conditionalSql, convertToIdentifiers, convertToPrimitiveOrSql } from './utils';
|
||||||
|
|
||||||
export type FindManyData<Schema extends SchemaLike> = {
|
|
||||||
where?: Partial<Schema>;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildFindMany = <Schema extends SchemaLike, ReturnType extends SchemaLike>(
|
export const buildFindMany = <Schema extends SchemaLike, ReturnType extends SchemaLike>(
|
||||||
pool: DatabasePoolType,
|
pool: DatabasePoolType,
|
||||||
schema: GeneratedSchema<Schema>
|
schema: GeneratedSchema<Schema>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { CreateUser, Users } from '@logto/schemas';
|
import { CreateUser, Users } from '@logto/schemas';
|
||||||
import decamelize from 'decamelize';
|
import decamelize from 'decamelize';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import { InsertionError } from '@/errors/SlonikError';
|
||||||
import { createTestPool } from '@/utils/test-utils';
|
import { createTestPool } from '@/utils/test-utils';
|
||||||
|
|
||||||
import { buildInsertInto } from './insert-into';
|
import { buildInsertInto } from './insert-into';
|
||||||
|
@ -59,19 +59,15 @@ describe('buildInsertInto()', () => {
|
||||||
).resolves.toStrictEqual(user);
|
).resolves.toStrictEqual(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws `entity.create_failed` error with `undefined` when `returning` is true', async () => {
|
it('throws `InsertionError` error when `returning` is true', async () => {
|
||||||
const user: CreateUser = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
const user: CreateUser = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||||
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
||||||
const pool = createTestPool([...expectInsertIntoSql, 'returning *'].join('\n'));
|
const pool = createTestPool([...expectInsertIntoSql, 'returning *'].join('\n'));
|
||||||
const insertInto = buildInsertInto(pool, Users, { returning: true });
|
const insertInto = buildInsertInto(pool, Users, { returning: true });
|
||||||
|
const dataToInsert = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||||
|
|
||||||
await expect(
|
await expect(insertInto(dataToInsert)).rejects.toMatchError(
|
||||||
insertInto({ id: 'foo', username: '123', primaryEmail: 'foo@bar.com' })
|
new InsertionError(Users, dataToInsert)
|
||||||
).rejects.toMatchError(
|
|
||||||
new RequestError({
|
|
||||||
code: 'entity.create_failed',
|
|
||||||
name: Users.tableSingular,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { SchemaLike, GeneratedSchema } from '@logto/schemas';
|
||||||
import { has } from '@silverhand/essentials';
|
import { has } from '@silverhand/essentials';
|
||||||
import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik';
|
import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik';
|
||||||
|
|
||||||
|
import { InsertionError } from '@/errors/SlonikError';
|
||||||
import assertThat from '@/utils/assert-that';
|
import assertThat from '@/utils/assert-that';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -51,9 +52,10 @@ export const buildInsertInto: BuildInsertInto = <
|
||||||
ReturnType extends SchemaLike
|
ReturnType extends SchemaLike
|
||||||
>(
|
>(
|
||||||
pool: DatabasePoolType,
|
pool: DatabasePoolType,
|
||||||
{ fieldKeys, ...rest }: GeneratedSchema<Schema>,
|
schema: GeneratedSchema<Schema>,
|
||||||
config?: InsertIntoConfig | InsertIntoConfigReturning
|
config?: InsertIntoConfig | InsertIntoConfigReturning
|
||||||
) => {
|
) => {
|
||||||
|
const { fieldKeys, ...rest } = schema;
|
||||||
const { table, fields } = convertToIdentifiers(rest);
|
const { table, fields } = convertToIdentifiers(rest);
|
||||||
const keys = excludeAutoSetFields(fieldKeys);
|
const keys = excludeAutoSetFields(fieldKeys);
|
||||||
const returning = Boolean(config?.returning);
|
const returning = Boolean(config?.returning);
|
||||||
|
@ -82,7 +84,7 @@ export const buildInsertInto: BuildInsertInto = <
|
||||||
)}
|
)}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assertThat(!returning || entry, 'entity.create_failed', { name: rest.tableSingular });
|
assertThat(!returning || entry, new InsertionError(schema, data));
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,18 @@
|
||||||
|
import { SchemaLike } from '@logto/schemas';
|
||||||
import { IdentifierSqlTokenType } from 'slonik';
|
import { IdentifierSqlTokenType } from 'slonik';
|
||||||
|
|
||||||
export type Table = { table: string; fields: Record<string, string> };
|
export type Table = { table: string; fields: Record<string, string> };
|
||||||
export type FieldIdentifiers<Key extends string | number | symbol> = {
|
export type FieldIdentifiers<Key extends string | number | symbol> = {
|
||||||
[key in Key]: IdentifierSqlTokenType;
|
[key in Key]: IdentifierSqlTokenType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FindManyData<Schema extends SchemaLike> = {
|
||||||
|
where?: Partial<Schema>;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateWhereData<Schema extends SchemaLike> = {
|
||||||
|
set: Partial<Schema>;
|
||||||
|
where: Partial<Schema>;
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { CreateUser, Users, Applications } from '@logto/schemas';
|
import { CreateUser, Users, Applications } from '@logto/schemas';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import { UpdateError } from '@/errors/SlonikError';
|
||||||
import { createTestPool } from '@/utils/test-utils';
|
import { createTestPool } from '@/utils/test-utils';
|
||||||
|
|
||||||
import { buildUpdateWhere } from './update-where';
|
import { buildUpdateWhere } from './update-where';
|
||||||
|
@ -70,34 +70,20 @@ describe('buildUpdateWhere()', () => {
|
||||||
it('throws `entity.not_exists_with_id` error with `undefined` when `returning` is true', async () => {
|
it('throws `entity.not_exists_with_id` error with `undefined` when `returning` is true', async () => {
|
||||||
const pool = createTestPool('update "users"\nset "username"=$1\nwhere "id"=$2\nreturning *');
|
const pool = createTestPool('update "users"\nset "username"=$1\nwhere "id"=$2\nreturning *');
|
||||||
const updateWhere = buildUpdateWhere(pool, Users, true);
|
const updateWhere = buildUpdateWhere(pool, Users, true);
|
||||||
|
const updateWhereData = { set: { username: '123' }, where: { id: 'foo' } };
|
||||||
|
|
||||||
await expect(
|
await expect(updateWhere(updateWhereData)).rejects.toMatchError(
|
||||||
updateWhere({ set: { username: '123' }, where: { id: 'foo' } })
|
new UpdateError(Users, updateWhereData)
|
||||||
).rejects.toMatchError(
|
|
||||||
new RequestError({
|
|
||||||
code: 'entity.not_exists_with_id',
|
|
||||||
name: Users.tableSingular,
|
|
||||||
id: 'foo',
|
|
||||||
status: 404,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws `entity.not_exists` error with `undefined` when `returning` is true and no id in where clause', async () => {
|
it('throws `UpdateError` error when `returning` is true and no id in where clause', async () => {
|
||||||
const pool = createTestPool(
|
const pool = createTestPool(
|
||||||
'update "users"\nset "username"=$1\nwhere "username"=$2\nreturning *'
|
'update "users"\nset "username"=$1\nwhere "username"=$2\nreturning *'
|
||||||
);
|
);
|
||||||
const updateWhere = buildUpdateWhere(pool, Users, true);
|
const updateWhere = buildUpdateWhere(pool, Users, true);
|
||||||
|
const updateData = { set: { username: '123' }, where: { username: 'foo' } };
|
||||||
|
|
||||||
await expect(
|
await expect(updateWhere(updateData)).rejects.toMatchError(new UpdateError(Users, updateData));
|
||||||
updateWhere({ set: { username: '123' }, where: { username: 'foo' } })
|
|
||||||
).rejects.toMatchError(
|
|
||||||
new RequestError({
|
|
||||||
code: 'entity.not_exists',
|
|
||||||
name: Users.tableSingular,
|
|
||||||
id: undefined,
|
|
||||||
status: 404,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,17 +2,13 @@ import { SchemaLike, GeneratedSchema } from '@logto/schemas';
|
||||||
import { notFalsy, Truthy } from '@silverhand/essentials';
|
import { notFalsy, Truthy } from '@silverhand/essentials';
|
||||||
import { DatabasePoolType, sql } from 'slonik';
|
import { DatabasePoolType, sql } from 'slonik';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import { UpdateError } from '@/errors/SlonikError';
|
||||||
import assertThat from '@/utils/assert-that';
|
import assertThat from '@/utils/assert-that';
|
||||||
import { isKeyOf } from '@/utils/schema';
|
import { isKeyOf } from '@/utils/schema';
|
||||||
|
|
||||||
|
import { UpdateWhereData } from './types';
|
||||||
import { conditionalSql, convertToIdentifiers, convertToPrimitiveOrSql } from './utils';
|
import { conditionalSql, convertToIdentifiers, convertToPrimitiveOrSql } from './utils';
|
||||||
|
|
||||||
export type UpdateWhereData<Schema extends SchemaLike> = {
|
|
||||||
set: Partial<Schema>;
|
|
||||||
where: Partial<Schema>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BuildUpdateWhere {
|
interface BuildUpdateWhere {
|
||||||
<Schema extends SchemaLike, ReturnType extends SchemaLike>(
|
<Schema extends SchemaLike, ReturnType extends SchemaLike>(
|
||||||
pool: DatabasePoolType,
|
pool: DatabasePoolType,
|
||||||
|
@ -69,15 +65,7 @@ export const buildUpdateWhere: BuildUpdateWhere = <
|
||||||
${conditionalSql(returning, () => sql`returning *`)}
|
${conditionalSql(returning, () => sql`returning *`)}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assertThat(
|
assertThat(!returning || data, new UpdateError(schema, { set, where }));
|
||||||
!returning || data,
|
|
||||||
new RequestError({
|
|
||||||
code: where.id ? 'entity.not_exists_with_id' : 'entity.not_exists',
|
|
||||||
name: schema.tableSingular,
|
|
||||||
id: where.id,
|
|
||||||
status: 404,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
import { SchemaLike, GeneratedSchema } from '@logto/schemas';
|
||||||
import { SlonikError } from 'slonik';
|
import { SlonikError } from 'slonik';
|
||||||
|
|
||||||
|
import { UpdateWhereData } from '@/database/types';
|
||||||
|
import { OmitAutoSetFields } from '@/database/utils';
|
||||||
|
|
||||||
export class DeletionError extends SlonikError {
|
export class DeletionError extends SlonikError {
|
||||||
table?: string;
|
table?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -11,3 +15,27 @@ export class DeletionError extends SlonikError {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateError<Schema extends SchemaLike> extends SlonikError {
|
||||||
|
schema: GeneratedSchema<Schema>;
|
||||||
|
detail: UpdateWhereData<Schema>;
|
||||||
|
|
||||||
|
public constructor(schema: GeneratedSchema<Schema>, detail: UpdateWhereData<Schema>) {
|
||||||
|
super('Resource not found.');
|
||||||
|
|
||||||
|
this.schema = schema;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsertionError<Schema extends SchemaLike> extends SlonikError {
|
||||||
|
schema: GeneratedSchema<Schema>;
|
||||||
|
detail?: OmitAutoSetFields<Schema>;
|
||||||
|
|
||||||
|
public constructor(schema: GeneratedSchema<Schema>, detail?: OmitAutoSetFields<Schema>) {
|
||||||
|
super('Create Error.');
|
||||||
|
|
||||||
|
this.schema = schema;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
|
import { Users } from '@logto/schemas';
|
||||||
import { NotFoundError, SlonikError } from 'slonik';
|
import { NotFoundError, SlonikError } from 'slonik';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { DeletionError } from '@/errors/SlonikError';
|
import { DeletionError, InsertionError, UpdateError } from '@/errors/SlonikError';
|
||||||
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
||||||
|
|
||||||
import koaSlonikErrorHandler from './koa-slonik-error-handler';
|
import koaSlonikErrorHandler from './koa-slonik-error-handler';
|
||||||
|
@ -34,6 +35,34 @@ describe('koaSlonikErrorHandler middleware', () => {
|
||||||
await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(error);
|
await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Insertion Error', async () => {
|
||||||
|
const error = new InsertionError(Users, { id: '123' });
|
||||||
|
next.mockImplementationOnce(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'entity.create_failed',
|
||||||
|
name: Users.tableSingular,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Update Error', async () => {
|
||||||
|
const error = new UpdateError(Users, { set: { name: 'punk' }, where: { id: '123' } });
|
||||||
|
next.mockImplementationOnce(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'entity.not_exists',
|
||||||
|
name: Users.tableSingular,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('Deletion Error', async () => {
|
it('Deletion Error', async () => {
|
||||||
const error = new DeletionError();
|
const error = new DeletionError();
|
||||||
next.mockImplementationOnce(() => {
|
next.mockImplementationOnce(() => {
|
||||||
|
|
|
@ -20,11 +20,12 @@
|
||||||
* (reference)[https://github.com/gajus/slonik#error-handling]
|
* (reference)[https://github.com/gajus/slonik#error-handling]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { SchemaLike } from '@logto/schemas';
|
||||||
import { Middleware } from 'koa';
|
import { Middleware } from 'koa';
|
||||||
import { SlonikError, NotFoundError } from 'slonik';
|
import { SlonikError, NotFoundError } from 'slonik';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { DeletionError } from '@/errors/SlonikError';
|
import { DeletionError, InsertionError, UpdateError } from '@/errors/SlonikError';
|
||||||
|
|
||||||
export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<StateT, ContextT> {
|
export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<StateT, ContextT> {
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
|
@ -36,6 +37,17 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (error.constructor) {
|
switch (error.constructor) {
|
||||||
|
case InsertionError:
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'entity.create_failed',
|
||||||
|
name: (error as InsertionError<SchemaLike>).schema.tableSingular,
|
||||||
|
});
|
||||||
|
case UpdateError:
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'entity.not_exists',
|
||||||
|
name: (error as InsertionError<SchemaLike>).schema.tableSingular,
|
||||||
|
});
|
||||||
|
// TODO: LOG-1665 Refactor Deletion Handle
|
||||||
case DeletionError:
|
case DeletionError:
|
||||||
case NotFoundError:
|
case NotFoundError:
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { buildInsertInto } from '@/database/insert-into';
|
||||||
import pool from '@/database/pool';
|
import pool from '@/database/pool';
|
||||||
import { buildUpdateWhere } from '@/database/update-where';
|
import { buildUpdateWhere } from '@/database/update-where';
|
||||||
import { conditionalSql, convertToIdentifiers, OmitAutoSetFields } from '@/database/utils';
|
import { conditionalSql, convertToIdentifiers, OmitAutoSetFields } from '@/database/utils';
|
||||||
import { DeletionError } from '@/errors/SlonikError';
|
import { DeletionError, UpdateError } from '@/errors/SlonikError';
|
||||||
|
|
||||||
const { table, fields } = convertToIdentifiers(Users);
|
const { table, fields } = convertToIdentifiers(Users);
|
||||||
|
|
||||||
|
@ -134,6 +134,6 @@ export const clearUserCustomDataById = async (id: string) => {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (rowCount < 1) {
|
if (rowCount < 1) {
|
||||||
throw new DeletionError(Users.table, id);
|
throw new UpdateError(Users, { set: { customData: {} }, where: { id } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue