From bd2325eda4da10c669890cf6284cba0cb5643765 Mon Sep 17 00:00:00 2001 From: Robin Blomberg Date: Wed, 10 Aug 2022 18:41:50 +0200 Subject: [PATCH] feat: add camel case support --- CHANGELOG.md | 6 ++ package.json | 7 +- src/case-converter.ts | 51 ++++++++++++ src/cli.ts | 35 +++++--- src/dialect.ts | 8 +- src/dialects/postgres/postgres-dialect.ts | 17 ++-- src/generator.ts | 5 +- src/logger.ts | 24 ++++-- src/nodes/alias-declaration-node.ts | 4 +- src/nodes/object-expression-node.ts | 4 +- src/tests/connection-string-parser.test.ts | 14 ++-- src/tests/index.test.ts | 1 + src/tests/test.utils.ts | 33 ++------ src/tests/transformer.test.ts | 97 ++++++++++++++++++++++ src/transformer.ts | 74 ++++++++++++----- src/util.ts | 17 ---- 16 files changed, 293 insertions(+), 104 deletions(-) create mode 100644 src/case-converter.ts create mode 100644 src/tests/transformer.test.ts delete mode 100644 src/util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c06ba6..0c12bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # kysely-codegen changelog +## 0.5.0 + +### Notable changes + +- feat: add camel case support + ## 0.4.0 ### Notable changes diff --git a/package.json b/package.json index bbdfeed..aefaf47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kysely-codegen", - "version": "0.4.0-beta.3", + "version": "0.5.0", "author": "Robin Blomberg", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,9 +18,12 @@ "scripts": { "build": "tsc", "dev": "ts-node-dev --quiet --respawn ./src/bin/index.ts", + "docker:get-mysql-ip": "docker inspect -f {{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}} kysely_codegen_mysql", + "docker:get-postgres-ip": "docker inspect -f {{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}} kysely_codegen_postgres", "docker:up": "docker-compose up -d", "start": "node ./dist/bin/index.js", - "test": "ts-node-dev ./src/tests/index.test.ts" + "test": "ts-node-dev ./src/tests/index.test.ts", + "test:watch": "ts-node-dev --quiet --respawn ./src/tests/index.test.ts" }, "dependencies": { "chalk": "^4.1.2", diff --git a/src/case-converter.ts b/src/case-converter.ts new file mode 100644 index 0000000..7450634 --- /dev/null +++ b/src/case-converter.ts @@ -0,0 +1,51 @@ +import { CamelCasePlugin } from 'kysely'; + +class CaseConverter extends CamelCasePlugin { + toCamelCase(string: string) { + return this.camelCase(string); + } + + toSnakeCase(string: string) { + return this.snakeCase(string); + } +} + +/** + * Returns a camelCased string. + * + * @example + * ```ts + * camelCase('foo_bar') + * // fooBar + * ``` + */ +export const toCamelCase = (string: string) => { + return new CaseConverter().toCamelCase(string); +}; + +/** + * Returns a PascalCased string. + * + * @example + * ```ts + * pascalCase('foo_bar') + * // FooBar + * ``` + */ +export const toPascalCase = (string: string) => { + const camelCased = toCamelCase(string); + return camelCased.slice(0, 1).toUpperCase() + camelCased.slice(1); +}; + +/** + * Returns a snake_cased string. + * + * @example + * ```ts + * snakeCase('FooBar') + * // foo_bar + * ``` + */ +export const toSnakeCase = (string: string) => { + return new CaseConverter().toSnakeCase(string); +}; diff --git a/src/cli.ts b/src/cli.ts index f973fe5..2df4969 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ const DEFAULT_OUT_FILE = './node_modules/kysely-codegen/dist/db.d.ts'; const VALID_DIALECTS = ['mysql', 'postgres', 'sqlite']; const VALID_FLAGS = new Set([ '_', + 'camel-case', 'dialect', 'h', 'help', @@ -19,6 +20,7 @@ const VALID_FLAGS = new Set([ ]); export type CliOptions = { + camelCase: boolean; dialectName: DialectName | undefined; logLevel: LogLevel; outFile: string; @@ -31,6 +33,8 @@ export type CliOptions = { */ export class Cli { async #generate(options: CliOptions) { + const { camelCase } = options; + const logger = new Logger(options.logLevel); const connectionStringParser = new ConnectionStringParser(); @@ -52,7 +56,12 @@ export class Cli { options.dialectName ?? inferredDialectName, ); - const generator = new Generator({ connectionString, dialect, logger }); + const generator = new Generator({ + camelCase, + connectionString, + dialect, + logger, + }); await generator.generate(options); } @@ -75,6 +84,7 @@ export class Cli { const argv = minimist(args); const _: string[] = argv._; + const camelCase = !!argv['camel-case']; const dialectName = argv.dialect as DialectName | undefined; const help = !!argv.h || !!argv.help || _.includes('-h') || _.includes('--help'); @@ -96,15 +106,18 @@ export class Cli { if (help) { logger.log( - '\n' + - 'kysely-codegen [options]\n' + - '\n' + - ` --dialect Set the SQL dialect. (values: [${dialectValues}])\n` + - ' --help, -h Print this message.\n' + - ' --log-level Set the terminal log level. (values: [debug, info, warn, error, silent], default: warn)\n' + - ` --out-file Set the file build path. (default: ${DEFAULT_OUT_FILE})\n` + - ' --print Print the generated output to the terminal.\n' + - ' --url Set the database connection string URL. This may point to an environment variable. (default: env(DATABASE_URL))\n', + '', + 'kysely-codegen [options]', + '', + ' --all Display all options.', + ' --camel-case Use the Kysely CamelCasePlugin.', + ` --dialect Set the SQL dialect. (values: [${dialectValues}])`, + ' --help, -h Print this message.', + ' --log-level Set the terminal log level. (values: [debug, info, warn, error, silent], default: warn)', + ` --out-file Set the file build path. (default: ${DEFAULT_OUT_FILE})`, + ' --print Print the generated output to the terminal.', + ' --url Set the database connection string URL. This may point to an environment variable. (default: env(DATABASE_URL))', + '', ); process.exit(0); @@ -140,7 +153,7 @@ export class Cli { } } - return { dialectName, logLevel, outFile, print, url }; + return { camelCase, dialectName, logLevel, outFile, print, url }; } async run(argv: string[]) { diff --git a/src/dialect.ts b/src/dialect.ts index 921cebd..1b6a1ce 100644 --- a/src/dialect.ts +++ b/src/dialect.ts @@ -1,6 +1,6 @@ import { Dialect as KyselyDialect, TableMetadata } from 'kysely'; import { Adapter } from './adapter'; -import { pascalCase } from './util'; +import { toCamelCase, toPascalCase } from './case-converter'; export type DriverInstantiateOptions = { connectionString: string; @@ -26,14 +26,14 @@ export abstract class Dialect { /** * Returns the name of the table in the exported `DB` interface. */ - getExportedTableName(table: TableMetadata): string { - return table.name; + getExportedTableName(table: TableMetadata, camelCase: boolean): string { + return camelCase ? toCamelCase(table.name) : table.name; } /** * Returns the TypeScript symbol name for the given table. */ getSymbolName(table: TableMetadata): string { - return pascalCase(table.name); + return toPascalCase(table.name); } } diff --git a/src/dialects/postgres/postgres-dialect.ts b/src/dialects/postgres/postgres-dialect.ts index ccdeef7..e665cdf 100644 --- a/src/dialects/postgres/postgres-dialect.ts +++ b/src/dialects/postgres/postgres-dialect.ts @@ -2,8 +2,8 @@ import { PostgresDialect as KyselyPostgresDialect, TableMetadata, } from 'kysely'; +import { toPascalCase } from '../../case-converter'; import { Dialect, DriverInstantiateOptions } from '../../dialect'; -import { pascalCase } from '../../util'; import { PostgresAdapter } from './postgres-adapter'; export class PostgresDialect extends Dialect { @@ -20,15 +20,20 @@ export class PostgresDialect extends Dialect { }); } - override getExportedTableName(table: TableMetadata): string { + override getExportedTableName( + table: TableMetadata, + camelCase: boolean, + ): string { + const tableName = super.getExportedTableName(table, camelCase); return table.schema === 'public' - ? super.getExportedTableName(table) - : `${table.schema}.${table.name}`; + ? tableName + : `${table.schema}.${tableName}`; } override getSymbolName(table: TableMetadata): string { + const symbolName = super.getSymbolName(table); return table.schema === 'public' - ? super.getSymbolName(table) - : pascalCase(`${table.schema}_${table.name}`); + ? symbolName + : toPascalCase(`${table.schema}_${symbolName}`); } } diff --git a/src/generator.ts b/src/generator.ts index ae0a934..bd06519 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -7,6 +7,7 @@ import { Serializer } from './serializer'; import { Transformer } from './transformer'; export type GeneratorOptions = { + camelCase: boolean; connectionString: string; dialect: Dialect; introspector?: Introspector; @@ -37,7 +38,9 @@ export class Generator { this.introspector = options.introspector ?? new Introspector(); this.logger = options.logger; this.serializer = options.serializer ?? new Serializer(); - this.transformer = options.transformer ?? new Transformer(options.dialect); + this.transformer = + options.transformer ?? + new Transformer(options.dialect, options.camelCase); } async generate(options: GenerateOptions) { diff --git a/src/logger.ts b/src/logger.ts index dc89159..c5c7dee 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -24,25 +24,33 @@ export class Logger { debug(...values: unknown[]) { if (this.logLevel >= LogLevel.DEBUG) { - console.debug(this.serializeDebug(...values)); + for (const value of values) { + console.debug(this.serializeDebug(value)); + } } } error(...values: unknown[]) { if (this.logLevel >= LogLevel.ERROR) { - console.error(this.serializeError(...values)); + for (const value of values) { + console.error(this.serializeError(value)); + } } } info(...values: unknown[]) { if (this.logLevel >= LogLevel.INFO) { - console.info(this.serializeInfo(...values)); + for (const value of values) { + console.info(this.serializeInfo(value)); + } } } log(...values: unknown[]) { if (this.logLevel >= LogLevel.INFO) { - console.log(...values); + for (const value of values) { + console.log(value); + } } } @@ -68,13 +76,17 @@ export class Logger { success(...values: unknown[]) { if (this.logLevel >= LogLevel.INFO) { - console.log(this.serializeSuccess(...values)); + for (const value of values) { + console.log(this.serializeSuccess(value)); + } } } warn(...values: unknown[]) { if (this.logLevel >= LogLevel.WARN) { - console.warn(this.serializeWarn(...values)); + for (const value of values) { + console.warn(this.serializeWarn(value)); + } } } } diff --git a/src/nodes/alias-declaration-node.ts b/src/nodes/alias-declaration-node.ts index a42dfdf..5fbf2bc 100644 --- a/src/nodes/alias-declaration-node.ts +++ b/src/nodes/alias-declaration-node.ts @@ -7,9 +7,9 @@ export class AliasDeclarationNode { readonly name: string; readonly type = NodeType.ALIAS_DECLARATION; - constructor(name: string, generics: string[], body: ExpressionNode) { + constructor(name: string, args: string[], body: ExpressionNode) { this.name = name; - this.args = generics; + this.args = args; this.body = body; } } diff --git a/src/nodes/object-expression-node.ts b/src/nodes/object-expression-node.ts index 030f2bf..6957820 100644 --- a/src/nodes/object-expression-node.ts +++ b/src/nodes/object-expression-node.ts @@ -5,7 +5,7 @@ export class ObjectExpressionNode { readonly properties: PropertyNode[]; readonly type = NodeType.OBJECT_EXPRESSION; - constructor(body: PropertyNode[]) { - this.properties = body; + constructor(properties: PropertyNode[]) { + this.properties = properties; } } diff --git a/src/tests/connection-string-parser.test.ts b/src/tests/connection-string-parser.test.ts index 1122219..c41c14f 100644 --- a/src/tests/connection-string-parser.test.ts +++ b/src/tests/connection-string-parser.test.ts @@ -4,9 +4,9 @@ import { describe, it } from './test.utils'; const parser = new ConnectionStringParser(); -void describe('connection-string-parser', async () => { - await describe('postgres', async () => { - await it('should infer the correct dialect name', () => { +void describe('connection-string-parser', () => { + void describe('postgres', () => { + void it('should infer the correct dialect name', () => { deepStrictEqual( parser.parse({ connectionString: 'postgres://username:password@hostname/database', @@ -19,8 +19,8 @@ void describe('connection-string-parser', async () => { }); }); - await describe('mysql', async () => { - await it('should infer the correct dialect name', () => { + void describe('mysql', () => { + void it('should infer the correct dialect name', () => { deepStrictEqual( parser.parse({ connectionString: 'mysql://username:password@hostname/database', @@ -42,8 +42,8 @@ void describe('connection-string-parser', async () => { }); }); - await describe('sqlite', async () => { - await it('should infer the correct dialect name', () => { + void describe('sqlite', () => { + void it('should infer the correct dialect name', () => { deepStrictEqual( parser.parse({ connectionString: 'C:/Program Files/sqlite3/db', diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index e195a65..8767e56 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1 +1,2 @@ import './connection-string-parser.test'; +import './transformer.test'; diff --git a/src/tests/test.utils.ts b/src/tests/test.utils.ts index b5ba945..8412b3c 100644 --- a/src/tests/test.utils.ts +++ b/src/tests/test.utils.ts @@ -1,36 +1,19 @@ -import chalk from 'chalk'; +let depth = 0; -type Test = () => Promise | void; - -const nameStack: string[] = []; -let count = 0; -let startTime = 0; - -const log = (string: string) => { - console.info(`[${chalk.cyan('TEST')}] ${string}`); -}; +export type Test = () => Promise | void; export const describe = async (name: string, test: Test) => { - if (!nameStack.length) { - log(chalk.blue('• Running tests...')); - count = 0; - startTime = performance.now(); - } - - nameStack.push(name); + depth++; await test(); - nameStack.pop(); + depth--; - if (!nameStack.length) { - const elapsedTime = `${Math.round(performance.now() - startTime)} ms`; - log(chalk.green(`✓ Finished ${count} tests in ${elapsedTime}.`)); + if (!depth) { + console.log('All tests passed.'); } }; export const it = async (name: string, test: Test) => { - nameStack.push(name); - log(` ${nameStack.map((n) => chalk.gray(n)).join(chalk.cyan(' → '))}`); + depth++; await test(); - nameStack.pop(); - count++; + depth--; }; diff --git a/src/tests/transformer.test.ts b/src/tests/transformer.test.ts new file mode 100644 index 0000000..ca18c25 --- /dev/null +++ b/src/tests/transformer.test.ts @@ -0,0 +1,97 @@ +import { deepStrictEqual } from 'assert'; +import { PostgresDialect } from '../dialects'; +import { + AliasDeclarationNode, + ExportStatementNode, + ExtendsClauseNode, + GenericExpressionNode, + IdentifierNode, + ImportStatementNode, + InferClauseNode, + InterfaceDeclarationNode, + ObjectExpressionNode, + PropertyNode, + UnionExpressionNode, +} from '../nodes'; +import { Transformer } from '../transformer'; +import { describe, it } from './test.utils'; + +void describe('transformer', () => { + void it('should be able to transform to camelCase', () => { + const dialect = new PostgresDialect(); + const transformer = new Transformer(dialect, true); + + const actualAst = transformer.transform([ + { + columns: [ + { + dataType: '', + hasDefaultValue: true, + isAutoIncrementing: false, + isNullable: false, + name: 'baz_qux', + }, + ], + name: 'foo_bar', + schema: 'public', + }, + ]); + + const expectedAst = [ + new ImportStatementNode('kysely', ['ColumnType']), + new ExportStatementNode( + new AliasDeclarationNode( + 'Generated', + ['T'], + new ExtendsClauseNode( + 'T', + new GenericExpressionNode('ColumnType', [ + new InferClauseNode('S'), + new InferClauseNode('I'), + new InferClauseNode('U'), + ]), + new GenericExpressionNode('ColumnType', [ + new IdentifierNode('S'), + new UnionExpressionNode([ + new IdentifierNode('I'), + new IdentifierNode('undefined'), + ]), + new IdentifierNode('U'), + ]), + new GenericExpressionNode('ColumnType', [ + new IdentifierNode('T'), + new UnionExpressionNode([ + new IdentifierNode('T'), + new IdentifierNode('undefined'), + ]), + new IdentifierNode('T'), + ]), + ), + ), + ), + new ExportStatementNode( + new InterfaceDeclarationNode( + 'FooBar', + new ObjectExpressionNode([ + new PropertyNode( + 'bazQux', + new GenericExpressionNode('Generated', [ + new IdentifierNode('string'), + ]), + ), + ]), + ), + ), + new ExportStatementNode( + new InterfaceDeclarationNode( + 'DB', + new ObjectExpressionNode([ + new PropertyNode('fooBar', new IdentifierNode('FooBar')), + ]), + ), + ), + ]; + + deepStrictEqual(actualAst, expectedAst); + }); +}); diff --git a/src/transformer.ts b/src/transformer.ts index c027c01..f70af51 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -1,5 +1,6 @@ import { ColumnMetadata, TableMetadata } from 'kysely'; import { AdapterDefinitions, AdapterImports, AdapterTypes } from './adapter'; +import { toCamelCase } from './case-converter'; import { Definition, DEFINITIONS } from './constants/definitions'; import { Dialect } from './dialect'; import { NodeType } from './enums/node-type'; @@ -20,31 +21,49 @@ const SYMBOLS: { [K in string]?: boolean } = Object.fromEntries( Object.keys(DEFINITIONS).map((key) => [key, false]), ); +const initialize = (dialect: Dialect) => { + return { + declarationNodes: [], + defaultType: dialect.adapter.defaultType ?? new IdentifierNode('unknown'), + definitions: { ...DEFINITIONS, ...dialect.adapter.definitions }, + exportedProperties: [], + imported: {}, + imports: { ColumnType: 'kysely', ...dialect.adapter.imports }, + symbols: { ...SYMBOLS }, + types: dialect.adapter.types ?? {}, + }; +}; + /** * Converts table metadata to a codegen AST. */ export class Transformer { - readonly #declarationNodes: DeclarationNode[]; - readonly #defaultType: ExpressionNode; - readonly #definitions: AdapterDefinitions; + readonly #camelCase: boolean; readonly #dialect: Dialect; - readonly #exportedProperties: PropertyNode[]; - readonly #imported: Record>; - readonly #imports: AdapterImports; - readonly #symbols = SYMBOLS; - readonly #types: AdapterTypes; - - constructor(dialect: Dialect) { - this.#declarationNodes = []; - this.#defaultType = - dialect.adapter.defaultType ?? new IdentifierNode('unknown'); - this.#definitions = { ...DEFINITIONS, ...dialect.adapter.definitions }; + + #declarationNodes: DeclarationNode[]; + #defaultType: ExpressionNode; + #definitions: AdapterDefinitions; + #exportedProperties: PropertyNode[]; + #imported: Record>; + #imports: AdapterImports; + #symbols: typeof SYMBOLS; + #types: AdapterTypes; + + constructor(dialect: Dialect, camelCase: boolean) { + this.#camelCase = camelCase; this.#dialect = dialect; - this.#exportedProperties = []; - this.#imported = {}; - this.#imports = { ColumnType: 'kysely', ...dialect.adapter.imports }; - this.#symbols = { ...SYMBOLS }; - this.#types = dialect.adapter.types ?? {}; + + const options = initialize(dialect); + + this.#declarationNodes = options.declarationNodes; + this.#defaultType = options.defaultType; + this.#definitions = options.definitions; + this.#exportedProperties = options.exportedProperties; + this.#imported = options.imported; + this.#imports = options.imports; + this.#symbols = options.symbols; + this.#types = options.types; } #createSymbolName(table: TableMetadata) { @@ -151,6 +170,8 @@ export class Transformer { args.push(new IdentifierNode('null')); } + const key = this.#camelCase ? toCamelCase(column.name) : column.name; + let value = args.length === 1 ? args[0]! : new UnionExpressionNode(args); if (column.hasDefaultValue || column.isAutoIncrementing) { @@ -159,7 +180,7 @@ export class Transformer { this.#instantiateReferencedSymbols(value); - return new PropertyNode(column.name, value); + return new PropertyNode(key, value); } #transformDatabaseExport() { @@ -197,7 +218,7 @@ export class Transformer { this.#declareSymbol(tableSymbolName); const valueNode = new IdentifierNode(tableSymbolName); - const key = this.#dialect.getExportedTableName(table); + const key = this.#dialect.getExportedTableName(table, this.#camelCase); const exportedPropertyNode = new PropertyNode(key, valueNode); this.#exportedProperties.push(exportedPropertyNode); @@ -221,6 +242,17 @@ export class Transformer { } transform(tables: TableMetadata[]): StatementNode[] { + const options = initialize(this.#dialect); + + this.#declarationNodes = options.declarationNodes; + this.#defaultType = options.defaultType; + this.#definitions = options.definitions; + this.#exportedProperties = options.exportedProperties; + this.#imported = options.imported; + this.#imports = options.imports; + this.#symbols = options.symbols; + this.#types = options.types; + const tableExportNodes = this.#transformTables(tables); const importNodes = this.#transformImports(); const definitionExportNodes = this.#transformDeclarations(); diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index d22d13c..0000000 --- a/src/util.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Returns a PascalCased string. - * - * @example - * ```ts - * pascalCase('foo_bar') - * // FooBar - * ``` - */ -export const pascalCase = (string: string) => { - return string - .split('_') - .map((word) => { - return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase(); - }) - .join(''); -};