diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 92fea2780..a9a9c53e1 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -9,7 +9,7 @@ "lib" ], "scripts": { - "generate": "ts-node src/gen/index.ts", + "generate": "ts-node src/gen/index.ts && xo src/db-entries --fix", "build": "yarn generate && rm -rf lib/ && tsc --p tsconfig.build.json", "lint": "xo src/" }, @@ -18,10 +18,12 @@ "yarn": "1" }, "devDependencies": { - "@logto/essentials": "^1.0.2", + "@logto/essentials": "^1.0.5", + "@types/lodash.uniq": "^4.5.6", "@types/node": "14", "@types/pluralize": "^0.0.29", "camelcase": "^6.2.0", + "lodash.uniq": "^4.5.0", "pluralize": "^8.0.0", "ts-node": "^10.0.0", "typescript": "^4.3.4", diff --git a/packages/schemas/src/db-entries/custom-types.ts b/packages/schemas/src/db-entries/custom-types.ts new file mode 100644 index 000000000..f93207b0a --- /dev/null +++ b/packages/schemas/src/db-entries/custom-types.ts @@ -0,0 +1,5 @@ +// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. + +export enum PasswordEncryptionMethod { + SaltAndPepper = 'SaltAndPepper', +} diff --git a/packages/schemas/src/db-entries/user.ts b/packages/schemas/src/db-entries/user.ts index db3838901..4afd6f685 100644 --- a/packages/schemas/src/db-entries/user.ts +++ b/packages/schemas/src/db-entries/user.ts @@ -1,12 +1,14 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +import { PasswordEncryptionMethod } from './custom-types'; + export type UserDBEntry = { id: string; userName?: string; primaryEmail?: string; primaryPhone?: string; passwordEncrypted?: string; - passwordEncryptionMethod?: string; + passwordEncryptionMethod?: PasswordEncryptionMethod; passwordEncryptionSalt?: string; }; diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index c04272703..357ada298 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -3,22 +3,47 @@ import camelcase from 'camelcase'; import fs from 'fs/promises'; import path from 'path'; import pluralize from 'pluralize'; -import { conditionalString } from '@logto/essentials'; +import uniq from 'lodash.uniq'; +import { conditional, conditionalString } from '@logto/essentials'; import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils'; type Field = { name: string; - type: string; + type?: string; + customType?: string; required: boolean; isArray: boolean; }; +// eslint-disable-next-line @typescript-eslint/ban-types +type FieldWithType = Omit & { type: string }; + +type Type = { + name: string; + type: 'enum'; + values: string[]; +}; + +type GeneratedType = Type & { + tsName: string; +}; + type Table = { name: string; fields: Field[]; }; +type TableWithType = { + name: string; + fields: FieldWithType[]; +}; + +type FileData = { + types: Type[]; + tables: Table[]; +}; + const dir = 'tables'; const generate = async () => { @@ -26,13 +51,12 @@ const generate = async () => { const generated = await Promise.all( files .filter((file) => file.endsWith('.sql')) - .map>(async (file) => [ - file, - ( - await fs.readFile(path.join(dir, file), { encoding: 'utf-8' }) - ) + .map>(async (file) => { + const statements = (await fs.readFile(path.join(dir, file), { encoding: 'utf-8' })) .split(';') - .map((value) => normalizeWhitespaces(value).toLowerCase()) + .map((value) => normalizeWhitespaces(value)); + const tables = statements + .map((value) => value.toLowerCase()) .filter((value) => value.startsWith('create table')) .map((value) => findFirstParentheses(value)) .filter((value): value is NonNullable => Boolean(value)) @@ -56,17 +80,38 @@ const generate = async () => { // CAUTION: Only works for single dimension arrays const isArray = Boolean(/\[.*]/.test(type)) || restJoined.includes('array'); const required = restJoined.includes('not null'); + const primitiveType = getType(type); return { name, - type: getType(type), + type: primitiveType, + customType: conditional(!primitiveType && type), isArray, required, }; }); return { name, fields }; - }), - ]) + }); + const types = statements + .filter((value) => value.toLowerCase().startsWith('create type')) + .map((value) => { + const breakdowns = value.split(' '); + const name = breakdowns[2]; + const data = findFirstParentheses(value); + assert( + name && + data && + breakdowns[3]?.toLowerCase() === 'as' && + breakdowns[4]?.toLowerCase() === 'enum', + 'Only support enum custom type' + ); + const values = data.body.split(',').map((value) => value.trim().slice(1, -1)); + + return { name, type: 'enum', values }; + }); + + return [file, { tables, types }]; + }) ); const generatedDir = 'src/db-entries'; @@ -75,11 +120,61 @@ const generate = async () => { await fs.rmdir(generatedDir, { recursive: true }); await fs.mkdir(generatedDir, { recursive: true }); + const allTypes = generated + .flatMap((data) => data[1].types) + .map((type) => ({ + ...type, + tsName: camelcase(type.name, { pascalCase: true }), + })); + + // Generate custom types + await fs.writeFile( + path.join(generatedDir, 'custom-types.ts'), + header + + allTypes + .map(({ tsName, values }) => + [ + `export enum ${tsName} {`, + ...values.map((value) => ` ${value} = '${value}',`), + '}', + ].join('\n') + ) + .join('\n') + + '\n' + ); + + // Generate DB entry types await Promise.all( - generated.map(async ([file, tables]) => { + generated.map(async ([file, { tables }]) => { + const customTypes: string[] = []; + const tableWithTypes = tables.map(({ fields, ...rest }) => ({ + ...rest, + fields: fields.map(({ type, customType, ...rest }) => { + const finalType = type ?? allTypes.find(({ name }) => name === customType)?.tsName; + assert(finalType, `Type ${customType ?? 'N/A'} not found`); + if (type === undefined) { + customTypes.push(finalType); + } + + return { ...rest, type: finalType }; + }), + })); + + const importTypes = + customTypes.length > 0 + ? [ + 'import {', + uniq(customTypes) + .map((value) => ` ${value}`) + .join(',\n'), + "} from './custom-types';", + ].join('\n') + '\n\n' + : ''; + const content = header + - tables + importTypes + + tableWithTypes .map(({ name, fields }) => [ `export type ${pluralize(camelcase(name, { pascalCase: true }), 1)}DBEntry = {`, diff --git a/packages/schemas/src/gen/utils.ts b/packages/schemas/src/gen/utils.ts index 223f2a2bb..df199e032 100644 --- a/packages/schemas/src/gen/utils.ts +++ b/packages/schemas/src/gen/utils.ts @@ -82,7 +82,7 @@ const getRawType = (value: string): string => { // eslint-disable-next-line complexity export const getType = ( value: string -): 'string' | 'number' | 'boolean' | 'Record' | 'Date' => { +): 'string' | 'number' | 'boolean' | 'Record' | 'Date' | undefined => { switch (getRawType(value)) { case 'bpchar': case 'char': @@ -117,6 +117,5 @@ export const getType = ( case 'timestamptz': return 'Date'; default: - throw new Error('Unable to parse type: ' + value); } }; diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index c567d8e31..16437dbef 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -1,10 +1,12 @@ +create type password_encryption_method as enum ('SaltAndPepper'); + create table users ( id varchar(24) not null, user_name varchar(128) unique, primary_email varchar(128) unique, primary_phone varchar(128) unique, password_encrypted varchar(128), - password_encryption_method varchar(32), + password_encryption_method password_encryption_method, password_encryption_salt varchar(128), primary key (id) ); diff --git a/packages/schemas/yarn.lock b/packages/schemas/yarn.lock index 03a528321..2a3be5bd5 100644 --- a/packages/schemas/yarn.lock +++ b/packages/schemas/yarn.lock @@ -232,10 +232,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@logto/essentials@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@logto/essentials/-/essentials-1.0.2.tgz#02a8c878b4079a63709c35ce8c143370f259bb13" - integrity sha512-GN6m1c5ll7EhOgJbmFwRESkeWGjdoihst8O1aVIxvEOXhLe/79bph4hejsdm59piX3jgKWakEHimpqcBT1kO+Q== +"@logto/essentials@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@logto/essentials/-/essentials-1.0.5.tgz#edc1a376cf82e8829adcf26e8b98db1235cfbfcd" + integrity sha512-zft8VodNOtkhyyQTHiOlksk6UonQBY68dON4cQaUOlpN5JS9z5/zchi4I0j+XB6EeQ7l8ZtVXct2VZBp8m03kw== dependencies: lodash.orderby "^4.6.0" lodash.pick "^4.4.0" @@ -337,6 +337,18 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash.uniq@^4.5.6": + version "4.5.6" + resolved "https://registry.yarnpkg.com/@types/lodash.uniq/-/lodash.uniq-4.5.6.tgz#adb052f6c7eeb38b920c13166e7a972dd960b4c5" + integrity sha512-XHNMXBtiwsWZstZMyxOYjr0e8YYWv0RgPlzIHblTuwBBiWo2MzWVaTBihtBpslb5BglgAWIeBv69qt1+RTRW1A== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.170" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" + integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== + "@types/minimatch@*": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21" @@ -2557,6 +2569,11 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + lodash@^4.13.1, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"