0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00
logto/packages/schemas/src/gen/index.ts

214 lines
6.8 KiB
TypeScript

import assert from 'assert';
import camelcase from 'camelcase';
import fs from 'fs/promises';
import path from 'path';
import pluralize from 'pluralize';
import uniq from 'lodash.uniq';
import { conditional, conditionalString } from '@logto/essentials';
import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils';
type Field = {
name: string;
type?: string;
customType?: string;
required: boolean;
isArray: boolean;
};
// eslint-disable-next-line @typescript-eslint/ban-types
type FieldWithType = Omit<Field, 'type' | 'customType'> & { 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 () => {
const files = await fs.readdir(dir);
const generated = await Promise.all(
files
.filter((file) => file.endsWith('.sql'))
.map<Promise<[string, FileData]>>(async (file) => {
const statements = (await fs.readFile(path.join(dir, file), { encoding: 'utf-8' }))
.split(';')
.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<typeof value> => Boolean(value))
.map<Table>(({ prefix, body }) => {
const name = normalizeWhitespaces(prefix).split(' ')[2];
assert(name, 'Missing table name: ' + prefix);
const fields = removeParentheses(body)
.split(',')
.map((value) => normalizeWhitespaces(value))
.filter((value) =>
['primary', 'foreign', 'unique', 'exclude', 'check'].every(
(constraint) => !value.startsWith(constraint + ' ')
)
)
.map<Field>((value) => {
const [name, type, ...rest] = value.split(' ');
assert(name && type, 'Missing column name or type: ' + value);
const restJoined = rest.join(' ');
// 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: primitiveType,
customType: conditional(!primitiveType && type),
isArray,
required,
};
});
return { name, fields };
});
const types = statements
.filter((value) => value.toLowerCase().startsWith('create type'))
.map<Type>((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';
const generatedTypesFilename = 'custom-types';
const header = '// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\n';
const getOutputFileName = (file: string) => pluralize(file.slice(0, -4).replace(/_/g, '-'), 1);
await fs.rmdir(generatedDir, { recursive: true });
await fs.mkdir(generatedDir, { recursive: true });
const allTypes = generated
.flatMap((data) => data[1].types)
.map<GeneratedType>((type) => ({
...type,
tsName: camelcase(type.name, { pascalCase: true }),
}));
if (allTypes.length > 0) {
// Generate custom types
await fs.writeFile(
path.join(generatedDir, `${generatedTypesFilename}.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 }]) => {
const customTypes: string[] = [];
const tableWithTypes = tables.map<TableWithType>(({ 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 = conditionalString(
customTypes.length > 0 &&
[
'import {',
uniq(customTypes)
.map((value) => ` ${value}`)
.join(',\n'),
`} from './${generatedTypesFilename}';`,
].join('\n') + '\n\n'
);
const content =
header +
importTypes +
tableWithTypes
.map(({ name, fields }) =>
[
`export type ${pluralize(camelcase(name, { pascalCase: true }), 1)}DBEntry = {`,
...fields.map(
({ name, type, isArray, required }) =>
` ${camelcase(name)}${conditionalString(
!required && '?'
)}: ${type}${conditionalString(isArray && '[]')};`
),
'};',
'',
`export const ${camelcase(name, { pascalCase: true })} = Object.freeze({`,
` table: '${name}',`,
' fields: {',
...fields.map(({ name }) => ` ${camelcase(name)}: '${name}',`),
' },',
'} as const);',
].join('\n')
)
.join('\n') +
'\n';
await fs.writeFile(path.join(generatedDir, getOutputFileName(file) + '.ts'), content);
})
);
await fs.writeFile(
path.join(generatedDir, 'index.ts'),
header +
conditionalString(allTypes.length > 0 && `export * from './${generatedTypesFilename}';`) +
generated.map(([file]) => `export * from './${getOutputFileName(file)}';`).join('\n') +
'\n'
);
};
void generate();