0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

Merge pull request #4636 from logto-io/gao-optimize-schema-types

refactor(schemas,shared)!: optimize schema types
This commit is contained in:
Gao Sun 2023-10-12 01:07:59 -05:00 committed by GitHub
commit b4655b4e0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 149 additions and 95 deletions

View file

@ -65,7 +65,7 @@ export const createPoolAndDatabaseIfNeeded = async () => {
}
};
export const insertInto = <T extends SchemaLike>(object: T, table: string) => {
export const insertInto = <T extends SchemaLike<string>>(object: T, table: string) => {
const keys = Object.keys(object);
return sql`

View file

@ -1,18 +1,17 @@
import { type GeneratedSchema, type SchemaLike } from '@logto/schemas';
import {
type FieldIdentifiers,
conditionalSql,
convertToIdentifiers,
manyRows,
} from '@logto/shared';
import { conditionalSql, convertToIdentifiers, manyRows } from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
export const buildFindAllEntitiesWithPool =
(pool: CommonQueryMethods) =>
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
<
Keys extends string,
CreateSchema extends Partial<SchemaLike<Keys>>,
Schema extends SchemaLike<Keys>,
>(
schema: GeneratedSchema<Keys, CreateSchema, Schema>,
orderBy?: Array<{
field: keyof FieldIdentifiers<keyof GeneratedSchema<CreateSchema, Schema>['fields']>;
field: Keys;
order: 'asc' | 'desc';
}>
) => {

View file

@ -7,10 +7,16 @@ import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
import { isKeyOf } from '#src/utils/schema.js';
type WithId<Key> = Key | 'id';
export const buildFindEntityByIdWithPool =
(pool: CommonQueryMethods) =>
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema & { id: string }>
<
Key extends string,
CreateSchema extends Partial<SchemaLike<WithId<Key>>>,
Schema extends SchemaLike<WithId<Key>>,
>(
schema: GeneratedSchema<WithId<Key>, CreateSchema, Schema>
) => {
const { table, fields } = convertToIdentifiers(schema);
const isKeyOfSchema = isKeyOf(schema);

View file

@ -40,20 +40,32 @@ type InsertIntoConfig = {
};
type BuildInsertInto = {
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
{ fieldKeys, ...rest }: GeneratedSchema<CreateSchema, Schema>,
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
{ fieldKeys, ...rest }: GeneratedSchema<Key, CreateSchema, Schema>,
config: InsertIntoConfigReturning
): (data: OmitAutoSetFields<CreateSchema>) => Promise<Schema>;
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
{ fieldKeys, ...rest }: GeneratedSchema<CreateSchema, Schema>,
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
{ fieldKeys, ...rest }: GeneratedSchema<Key, CreateSchema, Schema>,
config?: InsertIntoConfig
): (data: OmitAutoSetFields<CreateSchema>) => Promise<void>;
};
export const buildInsertIntoWithPool =
(pool: CommonQueryMethods): BuildInsertInto =>
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
schema: GeneratedSchema<Key, CreateSchema, Schema>,
config?: InsertIntoConfig | InsertIntoConfigReturning
) => {
const { fieldKeys, ...rest } = schema;
@ -88,7 +100,7 @@ export const buildInsertIntoWithPool =
${conditionalSql(returning, () => sql`returning *`)}
`);
assertThat(!returning || entry, new InsertionError<CreateSchema, Schema>(schema, data));
assertThat(!returning || entry, new InsertionError<Key, CreateSchema, Schema>(schema, data));
return entry;
};

View file

@ -1,4 +1,4 @@
import type { CreateUser, User } from '@logto/schemas';
import type { CreateUser, UserKeys } from '@logto/schemas';
import { Users, Applications } from '@logto/schemas';
import type { UpdateWhereData } from '@logto/shared';
@ -97,7 +97,7 @@ describe('buildUpdateWhere()', () => {
const pool = createTestPool('update "users"\nset "username"=$1\nwhere "id"=$2\nreturning *');
const updateWhere = buildUpdateWhereWithPool(pool)(Users, true);
const updateWhereData: UpdateWhereData<User> = {
const updateWhereData: UpdateWhereData<UserKeys, UserKeys> = {
set: { username: '123' },
where: { id: 'foo' },
jsonbMode: 'merge',
@ -114,7 +114,7 @@ describe('buildUpdateWhere()', () => {
);
const updateWhere = buildUpdateWhereWithPool(pool)(Users, true);
const updateWhereData: UpdateWhereData<User> = {
const updateWhereData: UpdateWhereData<UserKeys, UserKeys> = {
set: { username: '123' },
where: { username: 'foo' },
jsonbMode: 'merge',

View file

@ -11,25 +11,44 @@ import assertThat from '#src/utils/assert-that.js';
import { isKeyOf } from '#src/utils/schema.js';
type BuildUpdateWhere = {
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
schema: GeneratedSchema<Key, CreateSchema, Schema>,
returning: true
): (data: UpdateWhereData<Schema>) => Promise<Schema>;
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
): <SetKey extends Key, WhereKey extends Key>(
data: UpdateWhereData<SetKey, WhereKey>
) => Promise<Schema>;
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
schema: GeneratedSchema<Key, CreateSchema, Schema>,
returning?: false
): (data: UpdateWhereData<Schema>) => Promise<void>;
): <SetKey extends Key, WhereKey extends Key>(
data: UpdateWhereData<SetKey, WhereKey>
) => Promise<void>;
};
export const buildUpdateWhereWithPool =
(pool: CommonQueryMethods): BuildUpdateWhere =>
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>(
schema: GeneratedSchema<Key, CreateSchema, Schema>,
returning = false
) => {
const { table, fields } = convertToIdentifiers(schema);
const isKeyOfSchema = isKeyOf(schema);
const connectKeyValueWithEqualSign = (data: Partial<Schema>, jsonbMode: 'replace' | 'merge') =>
const connectKeyValueWithEqualSign = <ConnectKey extends Key>(
data: Partial<SchemaLike<ConnectKey>>,
jsonbMode: 'replace' | 'merge'
) =>
Object.entries<SchemaValue>(data)
.map(([key, value]) => {
if (!isKeyOfSchema(key) || value === undefined) {
@ -57,7 +76,11 @@ export const buildUpdateWhereWithPool =
})
.filter((value): value is Truthy<typeof value> => notFalsy(value));
return async ({ set, where, jsonbMode }: UpdateWhereData<Schema>) => {
return async <SetKey extends Key, WhereKey extends Key>({
set,
where,
jsonbMode,
}: UpdateWhereData<SetKey, WhereKey>) => {
const {
rows: [data],
} = await pool.query<Schema>(sql`

View file

@ -12,23 +12,27 @@ export class DeletionError extends SlonikError {
}
export class UpdateError<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
SetKey extends Key,
WhereKey extends Key,
> extends SlonikError {
public constructor(
public readonly schema: GeneratedSchema<CreateSchema, Schema>,
public readonly detail: Partial<UpdateWhereData<Schema>>
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
public readonly detail: Partial<UpdateWhereData<SetKey, WhereKey>>
) {
super('Resource not found.');
}
}
export class InsertionError<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
> extends SlonikError {
public constructor(
public readonly schema: GeneratedSchema<CreateSchema, Schema>,
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
public readonly detail?: OmitAutoSetFields<CreateSchema>
) {
super('Create Error.');

View file

@ -37,9 +37,9 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
throw new RequestError({
code: 'entity.create_failed',
status: 422,
// Assert generic type of the Class instance
// eslint-disable-next-line no-restricted-syntax
name: (error as InsertionError<SchemaLike, SchemaLike>).schema.tableSingular,
// eslint-disable-next-line no-restricted-syntax -- assert generic type of the Class instance
name: (error as InsertionError<string, SchemaLike<string>, SchemaLike<string>>).schema
.tableSingular,
});
}
@ -47,9 +47,10 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
throw new RequestError({
code: 'entity.not_exists',
status: 404,
// Assert generic type of the Class instance
// eslint-disable-next-line no-restricted-syntax
name: (error as UpdateError<SchemaLike, SchemaLike>).schema.tableSingular,
name:
// eslint-disable-next-line no-restricted-syntax -- assert generic type of the Class instance
(error as UpdateError<string, SchemaLike<string>, SchemaLike<string>, string, string>)
.schema.tableSingular,
});
}

View file

@ -1,4 +1,9 @@
import { type CreateOrganization, type Organization, Organizations } from '@logto/schemas';
import {
type CreateOrganization,
type Organization,
Organizations,
type OrganizationKeys,
} from '@logto/schemas';
import { type OmitAutoSetFields } from '@logto/shared';
import { type Pagination } from '#src/middleware/koa-pagination.js';
@ -11,7 +16,7 @@ type PostSchema = Omit<OmitAutoSetFields<CreateOrganization>, 'id'>;
type PatchSchema = Partial<Omit<OmitAutoSetFields<Organization>, 'id'>>;
class OrganizationActions
implements SchemaActions<CreateOrganization, Organization, PostSchema, PatchSchema>
implements SchemaActions<OrganizationKeys, Organization, PostSchema, PatchSchema>
{
postGuard = Organizations.createGuard.omit({ id: true, createdAt: true });
patchGuard = Organizations.guard.omit({ id: true, createdAt: true }).partial();

View file

@ -17,7 +17,7 @@ type Schema = {
};
describe('SchemaRouter', () => {
const schema: GeneratedSchema<CreateSchema, Schema> = {
const schema: GeneratedSchema<'id', CreateSchema, Schema> = {
table: 'test_table',
tableSingular: 'test_table',
fields: {
@ -28,7 +28,7 @@ describe('SchemaRouter', () => {
guard: z.object({ id: z.string() }),
};
const entities = [{ id: 'test' }, { id: 'test2' }] as const satisfies readonly Schema[];
const actions: SchemaActions<CreateSchema, Schema, CreateSchema, CreateSchema> = {
const actions: SchemaActions<'id', Schema, CreateSchema, CreateSchema> = {
get: jest.fn().mockResolvedValue([entities.length, entities]),
getById: jest.fn(async (id) => {
const entity = entities.find((entity) => entity.id === id);

View file

@ -10,8 +10,8 @@ import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.j
* necessary functions to handle the CRUD operations for a schema.
*/
export abstract class SchemaActions<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
Key extends string,
Schema extends SchemaLike<Key>,
PostSchema extends Partial<Schema>,
PatchSchema extends Partial<Schema>,
> {
@ -91,16 +91,17 @@ export abstract class SchemaActions<
* @see {@link SchemaActions} for the `actions` configuration.
*/
export default class SchemaRouter<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
PostSchema extends Partial<Schema> = Partial<Schema>,
PatchSchema extends Partial<Schema> = Partial<Schema>,
StateT = unknown,
CustomT extends IRouterParamContext = IRouterParamContext,
> extends Router<StateT, CustomT> {
constructor(
public readonly schema: GeneratedSchema<CreateSchema, Schema>,
public readonly actions: SchemaActions<CreateSchema, Schema, PostSchema, PatchSchema>
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
public readonly actions: SchemaActions<Key, Schema, PostSchema, PatchSchema>
) {
super({ prefix: '/' + schema.table.replaceAll('_', '-') });

View file

@ -1,8 +1,13 @@
import type { GeneratedSchema, SchemaLike } from '@logto/schemas';
export const isKeyOf =
<CreateSchema extends SchemaLike, Schema extends CreateSchema>({
<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
>({
fieldKeys,
}: GeneratedSchema<CreateSchema, Schema>) =>
(key: string): key is keyof Schema extends string ? keyof Schema : never =>
fieldKeys.includes(key);
}: GeneratedSchema<Key, CreateSchema, Schema>) =>
(key: string): key is Key =>
// eslint-disable-next-line no-restricted-syntax -- the quickest way to check
fieldKeys.includes(key as Key);

View file

@ -26,17 +26,16 @@ export type Guard<T extends Record<string, unknown>> = ZodObject<
>;
export type GeneratedSchema<
CreateSchema extends SchemaLike,
Schema extends CreateSchema,
> = keyof Schema extends string
? Readonly<{
table: string;
tableSingular: string;
fields: {
[key in keyof Required<Schema>]: string;
};
fieldKeys: ReadonlyArray<keyof Schema>;
createGuard: CreateGuard<CreateSchema>;
guard: Guard<Schema>;
}>
: never;
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
> = Readonly<{
table: string;
tableSingular: string;
fields: {
[key in Key]: string;
};
fieldKeys: readonly Key[];
createGuard: CreateGuard<CreateSchema>;
guard: Guard<Schema>;
}>;

View file

@ -68,6 +68,10 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => {
),
'};',
'',
`export type ${modelName}Keys = ${fields
.map(({ name }) => `'${camelcase(name)}'`)
.join(' | ')};`,
'',
`const createGuard: CreateGuard<${databaseEntryType}> = z.object({`,
...fields.map(
@ -122,7 +126,7 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => {
'',
`export const ${camelcase(name, {
pascalCase: true,
})}: GeneratedSchema<${databaseEntryType}, ${modelName}> = Object.freeze({`,
})}: GeneratedSchema<${modelName}Keys, ${databaseEntryType}, ${modelName}> = Object.freeze({`,
` table: '${name}',`,
` tableSingular: '${pluralize(name, 1)}',`,
' fields: {',

View file

@ -81,7 +81,7 @@ describe('convertToIdentifiers()', () => {
fooBar: 'foo_bar',
baz: 'baz',
};
const data: Table = { table, fields };
const data: Table<string> = { table, fields };
it('converts table to correct identifiers', () => {
expect(convertToIdentifiers(data)).toEqual({

View file

@ -68,16 +68,20 @@ export const convertToPrimitiveOrSql = (
throw new Error(`Cannot convert ${key} to primitive`);
};
export const convertToIdentifiers = <T extends Table>({ table, fields }: T, withPrefix = false) => {
const fieldsIdentifiers = Object.entries<string>(fields).map<
[keyof T['fields'], IdentifierSqlToken]
>(([key, value]) => [key, sql.identifier(withPrefix ? [table, value] : [value])]);
export const convertToIdentifiers = <Key extends string>(
{ table, fields }: Table<Key>,
withPrefix = false
) => {
const fieldsIdentifiers = Object.entries<string>(fields).map<[Key, IdentifierSqlToken]>(
// eslint-disable-next-line no-restricted-syntax -- Object.entries can only return string keys
([key, value]) => [key as Key, sql.identifier(withPrefix ? [table, value] : [value])]
);
return {
table: sql.identifier([table]),
// Key value inferred from the original fields directly
// eslint-disable-next-line no-restricted-syntax
fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers<keyof T['fields']>,
fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers<Key>,
};
};

View file

@ -2,28 +2,19 @@ import type { IdentifierSqlToken } from 'slonik';
export type SchemaValuePrimitive = string | number | boolean | undefined;
export type SchemaValue = SchemaValuePrimitive | Record<string, unknown> | unknown[] | null;
export type SchemaLike<Key extends string = string> = {
export type SchemaLike<Key extends string> = {
[key in Key]: SchemaValue;
};
export type Table = { table: string; fields: Record<string, string> };
export type FieldIdentifiers<Key extends string | number | symbol> = {
export type Table<Keys extends string> = { table: string; fields: Record<Keys, string> };
export type FieldIdentifiers<Key extends string> = {
[key in Key]: IdentifierSqlToken;
};
export type OrderDirection = 'asc' | 'desc';
export type OrderBy<Schema extends SchemaLike> = Partial<Record<keyof Schema, OrderDirection>>;
export type FindManyData<Schema extends SchemaLike> = {
where?: Partial<Schema>;
orderBy?: OrderBy<Schema>;
limit?: number;
offset?: number;
};
export type UpdateWhereData<Schema extends SchemaLike> = {
set: Partial<Schema>;
where: Partial<Schema>;
export type UpdateWhereData<SetKey extends string, WhereKey extends string> = {
set: Partial<SchemaLike<SetKey>>;
where: Partial<SchemaLike<WhereKey>>;
jsonbMode: 'replace' | 'merge';
};