diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 89c6bdf03..cab6c7899 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -65,7 +65,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { } }; -export const insertInto = (object: T, table: string) => { +export const insertInto = >(object: T, table: string) => { const keys = Object.keys(object); return sql` diff --git a/packages/core/src/database/find-all-entities.ts b/packages/core/src/database/find-all-entities.ts index a88698a0c..bdf0e1af3 100644 --- a/packages/core/src/database/find-all-entities.ts +++ b/packages/core/src/database/find-all-entities.ts @@ -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) => - ( - schema: GeneratedSchema, + < + Keys extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + schema: GeneratedSchema, orderBy?: Array<{ - field: keyof FieldIdentifiers['fields']>; + field: Keys; order: 'asc' | 'desc'; }> ) => { diff --git a/packages/core/src/database/find-entity-by-id.ts b/packages/core/src/database/find-entity-by-id.ts index 16001227d..89a8720d0 100644 --- a/packages/core/src/database/find-entity-by-id.ts +++ b/packages/core/src/database/find-entity-by-id.ts @@ -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 | 'id'; + export const buildFindEntityByIdWithPool = (pool: CommonQueryMethods) => - ( - schema: GeneratedSchema + < + Key extends string, + CreateSchema extends Partial>>, + Schema extends SchemaLike>, + >( + schema: GeneratedSchema, CreateSchema, Schema> ) => { const { table, fields } = convertToIdentifiers(schema); const isKeyOfSchema = isKeyOf(schema); diff --git a/packages/core/src/database/insert-into.ts b/packages/core/src/database/insert-into.ts index 092c08a68..eb76499d7 100644 --- a/packages/core/src/database/insert-into.ts +++ b/packages/core/src/database/insert-into.ts @@ -40,20 +40,32 @@ type InsertIntoConfig = { }; type BuildInsertInto = { - ( - { fieldKeys, ...rest }: GeneratedSchema, + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + { fieldKeys, ...rest }: GeneratedSchema, config: InsertIntoConfigReturning ): (data: OmitAutoSetFields) => Promise; - ( - { fieldKeys, ...rest }: GeneratedSchema, + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + { fieldKeys, ...rest }: GeneratedSchema, config?: InsertIntoConfig ): (data: OmitAutoSetFields) => Promise; }; export const buildInsertIntoWithPool = (pool: CommonQueryMethods): BuildInsertInto => - ( - schema: GeneratedSchema, + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + schema: GeneratedSchema, config?: InsertIntoConfig | InsertIntoConfigReturning ) => { const { fieldKeys, ...rest } = schema; @@ -88,7 +100,7 @@ export const buildInsertIntoWithPool = ${conditionalSql(returning, () => sql`returning *`)} `); - assertThat(!returning || entry, new InsertionError(schema, data)); + assertThat(!returning || entry, new InsertionError(schema, data)); return entry; }; diff --git a/packages/core/src/database/update-where.test.ts b/packages/core/src/database/update-where.test.ts index 4cfbdb379..c830981a1 100644 --- a/packages/core/src/database/update-where.test.ts +++ b/packages/core/src/database/update-where.test.ts @@ -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 = { + const updateWhereData: UpdateWhereData = { set: { username: '123' }, where: { id: 'foo' }, jsonbMode: 'merge', @@ -114,7 +114,7 @@ describe('buildUpdateWhere()', () => { ); const updateWhere = buildUpdateWhereWithPool(pool)(Users, true); - const updateWhereData: UpdateWhereData = { + const updateWhereData: UpdateWhereData = { set: { username: '123' }, where: { username: 'foo' }, jsonbMode: 'merge', diff --git a/packages/core/src/database/update-where.ts b/packages/core/src/database/update-where.ts index 8f3ddfc0a..32e559871 100644 --- a/packages/core/src/database/update-where.ts +++ b/packages/core/src/database/update-where.ts @@ -11,25 +11,44 @@ import assertThat from '#src/utils/assert-that.js'; import { isKeyOf } from '#src/utils/schema.js'; type BuildUpdateWhere = { - ( - schema: GeneratedSchema, + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + schema: GeneratedSchema, returning: true - ): (data: UpdateWhereData) => Promise; - ( - schema: GeneratedSchema, + ): ( + data: UpdateWhereData + ) => Promise; + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + schema: GeneratedSchema, returning?: false - ): (data: UpdateWhereData) => Promise; + ): ( + data: UpdateWhereData + ) => Promise; }; export const buildUpdateWhereWithPool = (pool: CommonQueryMethods): BuildUpdateWhere => - ( - schema: GeneratedSchema, + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + schema: GeneratedSchema, returning = false ) => { const { table, fields } = convertToIdentifiers(schema); const isKeyOfSchema = isKeyOf(schema); - const connectKeyValueWithEqualSign = (data: Partial, jsonbMode: 'replace' | 'merge') => + const connectKeyValueWithEqualSign = ( + data: Partial>, + jsonbMode: 'replace' | 'merge' + ) => Object.entries(data) .map(([key, value]) => { if (!isKeyOfSchema(key) || value === undefined) { @@ -57,7 +76,11 @@ export const buildUpdateWhereWithPool = }) .filter((value): value is Truthy => notFalsy(value)); - return async ({ set, where, jsonbMode }: UpdateWhereData) => { + return async ({ + set, + where, + jsonbMode, + }: UpdateWhereData) => { const { rows: [data], } = await pool.query(sql` diff --git a/packages/core/src/errors/SlonikError/index.ts b/packages/core/src/errors/SlonikError/index.ts index 8560ab51a..1f8028464 100644 --- a/packages/core/src/errors/SlonikError/index.ts +++ b/packages/core/src/errors/SlonikError/index.ts @@ -12,23 +12,27 @@ export class DeletionError extends SlonikError { } export class UpdateError< - CreateSchema extends SchemaLike, - Schema extends CreateSchema, + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + SetKey extends Key, + WhereKey extends Key, > extends SlonikError { public constructor( - public readonly schema: GeneratedSchema, - public readonly detail: Partial> + public readonly schema: GeneratedSchema, + public readonly detail: Partial> ) { super('Resource not found.'); } } export class InsertionError< - CreateSchema extends SchemaLike, - Schema extends CreateSchema, + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, > extends SlonikError { public constructor( - public readonly schema: GeneratedSchema, + public readonly schema: GeneratedSchema, public readonly detail?: OmitAutoSetFields ) { super('Create Error.'); diff --git a/packages/core/src/middleware/koa-slonik-error-handler.ts b/packages/core/src/middleware/koa-slonik-error-handler.ts index 4d16a01e9..809d8d2a2 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.ts @@ -37,9 +37,9 @@ export default function koaSlonikErrorHandler(): Middleware).schema.tableSingular, + // eslint-disable-next-line no-restricted-syntax -- assert generic type of the Class instance + name: (error as InsertionError, SchemaLike>).schema + .tableSingular, }); } @@ -47,9 +47,10 @@ export default function koaSlonikErrorHandler(): Middleware).schema.tableSingular, + name: + // eslint-disable-next-line no-restricted-syntax -- assert generic type of the Class instance + (error as UpdateError, SchemaLike, string, string>) + .schema.tableSingular, }); } diff --git a/packages/core/src/routes/organizations.ts b/packages/core/src/routes/organizations.ts index 343199196..32844da38 100644 --- a/packages/core/src/routes/organizations.ts +++ b/packages/core/src/routes/organizations.ts @@ -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, 'id'>; type PatchSchema = Partial, 'id'>>; class OrganizationActions - implements SchemaActions + implements SchemaActions { postGuard = Organizations.createGuard.omit({ id: true, createdAt: true }); patchGuard = Organizations.guard.omit({ id: true, createdAt: true }).partial(); diff --git a/packages/core/src/utils/SchemaRouter.test.ts b/packages/core/src/utils/SchemaRouter.test.ts index 5b0cf0386..94305e235 100644 --- a/packages/core/src/utils/SchemaRouter.test.ts +++ b/packages/core/src/utils/SchemaRouter.test.ts @@ -17,7 +17,7 @@ type Schema = { }; describe('SchemaRouter', () => { - const schema: GeneratedSchema = { + 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 = { + 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); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index fc867e69f..c62c7bf04 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -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, PostSchema extends Partial, PatchSchema extends Partial, > { @@ -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>, + Schema extends SchemaLike, PostSchema extends Partial = Partial, PatchSchema extends Partial = Partial, StateT = unknown, CustomT extends IRouterParamContext = IRouterParamContext, > extends Router { constructor( - public readonly schema: GeneratedSchema, - public readonly actions: SchemaActions + public readonly schema: GeneratedSchema, + public readonly actions: SchemaActions ) { super({ prefix: '/' + schema.table.replaceAll('_', '-') }); diff --git a/packages/core/src/utils/schema.ts b/packages/core/src/utils/schema.ts index da105d62c..334c6e7df 100644 --- a/packages/core/src/utils/schema.ts +++ b/packages/core/src/utils/schema.ts @@ -1,8 +1,13 @@ import type { GeneratedSchema, SchemaLike } from '@logto/schemas'; export const isKeyOf = - ({ + < + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >({ fieldKeys, - }: GeneratedSchema) => - (key: string): key is keyof Schema extends string ? keyof Schema : never => - fieldKeys.includes(key); + }: GeneratedSchema) => + (key: string): key is Key => + // eslint-disable-next-line no-restricted-syntax -- the quickest way to check + fieldKeys.includes(key as Key); diff --git a/packages/schemas/src/foundations/schemas.ts b/packages/schemas/src/foundations/schemas.ts index 874b957af..d90d34b82 100644 --- a/packages/schemas/src/foundations/schemas.ts +++ b/packages/schemas/src/foundations/schemas.ts @@ -26,17 +26,16 @@ export type Guard> = ZodObject< >; export type GeneratedSchema< - CreateSchema extends SchemaLike, - Schema extends CreateSchema, -> = keyof Schema extends string - ? Readonly<{ - table: string; - tableSingular: string; - fields: { - [key in keyof Required]: string; - }; - fieldKeys: ReadonlyArray; - createGuard: CreateGuard; - guard: Guard; - }> - : never; + Key extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, +> = Readonly<{ + table: string; + tableSingular: string; + fields: { + [key in Key]: string; + }; + fieldKeys: readonly Key[]; + createGuard: CreateGuard; + guard: Guard; +}>; diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts index 4723eff90..400b88c79 100644 --- a/packages/schemas/src/gen/schema.ts +++ b/packages/schemas/src/gen/schema.ts @@ -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: {', diff --git a/packages/shared/src/database/sql.test.ts b/packages/shared/src/database/sql.test.ts index 78dba6e8b..6fb0b88f2 100644 --- a/packages/shared/src/database/sql.test.ts +++ b/packages/shared/src/database/sql.test.ts @@ -81,7 +81,7 @@ describe('convertToIdentifiers()', () => { fooBar: 'foo_bar', baz: 'baz', }; - const data: Table = { table, fields }; + const data: Table = { table, fields }; it('converts table to correct identifiers', () => { expect(convertToIdentifiers(data)).toEqual({ diff --git a/packages/shared/src/database/sql.ts b/packages/shared/src/database/sql.ts index fef3a6046..028bd4980 100644 --- a/packages/shared/src/database/sql.ts +++ b/packages/shared/src/database/sql.ts @@ -68,16 +68,20 @@ export const convertToPrimitiveOrSql = ( throw new Error(`Cannot convert ${key} to primitive`); }; -export const convertToIdentifiers = ({ table, fields }: T, withPrefix = false) => { - const fieldsIdentifiers = Object.entries(fields).map< - [keyof T['fields'], IdentifierSqlToken] - >(([key, value]) => [key, sql.identifier(withPrefix ? [table, value] : [value])]); +export const convertToIdentifiers = ( + { table, fields }: Table, + withPrefix = false +) => { + const fieldsIdentifiers = Object.entries(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, + fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers, }; }; diff --git a/packages/shared/src/database/types.ts b/packages/shared/src/database/types.ts index 7cfc79003..b9f6dde6a 100644 --- a/packages/shared/src/database/types.ts +++ b/packages/shared/src/database/types.ts @@ -2,28 +2,19 @@ import type { IdentifierSqlToken } from 'slonik'; export type SchemaValuePrimitive = string | number | boolean | undefined; export type SchemaValue = SchemaValuePrimitive | Record | unknown[] | null; -export type SchemaLike = { +export type SchemaLike = { [key in Key]: SchemaValue; }; -export type Table = { table: string; fields: Record }; -export type FieldIdentifiers = { +export type Table = { table: string; fields: Record }; +export type FieldIdentifiers = { [key in Key]: IdentifierSqlToken; }; export type OrderDirection = 'asc' | 'desc'; -export type OrderBy = Partial>; - -export type FindManyData = { - where?: Partial; - orderBy?: OrderBy; - limit?: number; - offset?: number; -}; - -export type UpdateWhereData = { - set: Partial; - where: Partial; +export type UpdateWhereData = { + set: Partial>; + where: Partial>; jsonbMode: 'replace' | 'merge'; };