From 0cec3b77e3fd9ac8a5e0ac7efc7d7b3975a7cdcb Mon Sep 17 00:00:00 2001 From: Cody Gordon Date: Thu, 24 Jul 2025 12:57:00 -0400 Subject: [PATCH 1/3] add timestamp-parser postgres option --- README.md | 5 ++ src/cli/cli.test.ts | 6 ++ src/cli/cli.ts | 11 +++ src/cli/config.ts | 10 ++- src/cli/flags.ts | 6 ++ .../dialects/postgres/postgres-adapter.ts | 16 +++++ .../dialects/postgres/postgres-dialect.ts | 4 ++ src/generator/generator/generate.test.ts | 8 +++ .../generator/snapshots/postgres2.snapshot.ts | 2 +- src/generator/transformer/transformer.test.ts | 68 ++++++++++++++++++- .../dialects/postgres/postgres-dialect.ts | 15 ++++ .../dialects/postgres/timestamp-parser.ts | 3 + src/introspector/index.ts | 1 + 13 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/introspector/dialects/postgres/timestamp-parser.ts diff --git a/README.md b/README.md index 7a891bb9..df994a9b 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,10 @@ Singularize generated type aliases, e.g. as `BlogPost` instead of `BlogPosts`. T You can specify custom singularization rules in the [configuration file](#configuration-file). +#### --timestamp-parser + +Specify which parser to use for PostgreSQL timestamp values. (values: `js-date`/`string`, default: `js-date`) + #### --type-only-imports Generate code using the TypeScript 3.8+ `import type` syntax. (default: `true`) @@ -328,6 +332,7 @@ The default configuration: "print": false, "runtimeEnums": false, "singularize": false, + "timestampParser": "js-date", "typeOnlyImports": true, "url": "env(DATABASE_URL)", "verify": false diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index a5efe227..67f351fe 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -247,6 +247,8 @@ describe(Cli.name, () => { ); assert(['--print'], { print: true }); assert(['--singularize'], { singularize: true }); + assert(['--timestamp-parser=js-date'], { timestampParser: 'js-date' }); + assert(['--timestamp-parser=string'], { timestampParser: 'string' }); assert(['--type-only-imports'], { typeOnlyImports: true }); assert(['--type-only-imports=false'], { typeOnlyImports: false }); assert(['--type-only-imports=true'], { typeOnlyImports: true }); @@ -311,6 +313,10 @@ describe(Cli.name, () => { assert({ print: 'true' }, 'Expected boolean, received string'); assert({ runtimeEnums: 'true' }, 'Invalid input'); assert({ singularize: 'true' }, 'Invalid input'); + assert( + { timestampParser: 'date' }, + "Invalid enum value. Expected 'js-date' | 'string', received 'date'", + ); assert({ typeOnlyImports: 'true' }, 'Expected boolean, received string'); assert({ url: null }, 'Expected string, received null'); assert({ verify: 'true' }, 'Expected boolean, received string'); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 58f47fd6..c3e87b2e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -7,6 +7,7 @@ import { ConnectionStringParser } from '../generator/connection-string-parser'; import { generate } from '../generator/generator/generate'; import { DEFAULT_LOG_LEVEL } from '../generator/logger/log-level'; import { Logger } from '../generator/logger/logger'; +import type { TimestampParser } from '../introspector'; import type { DateParser } from '../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../introspector/dialects/postgres/numeric-parser'; import type { Config, DialectName } from './config'; @@ -145,6 +146,15 @@ export class Cli { return input.map(String); } + #parseTimestampParser(input: any): TimestampParser | undefined { + if (input === undefined) return undefined; + switch (input) { + case 'js-date': + case 'string': + return input; + } + } + #showHelp() { console.info( ['', 'kysely-codegen [options]', '', serializeFlags(FLAGS), ''].join( @@ -246,6 +256,7 @@ export class Cli { print: this.#parseBoolean(argv.print), runtimeEnums: this.#parseRuntimeEnums(argv['runtime-enums']), singularize: this.#parseBoolean(argv.singularize), + timestampParser: this.#parseTimestampParser(argv['timestamp-parser']), typeOnlyImports: this.#parseBoolean(argv['type-only-imports']), url: this.#parseString(argv.url), verify: this.#parseBoolean(argv.verify), diff --git a/src/cli/config.ts b/src/cli/config.ts index 48f62eff..5b3757bb 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -19,7 +19,11 @@ import { RawExpressionNode, UnionExpressionNode, } from '../generator'; -import type { DateParser, NumericParser } from '../introspector'; +import type { + DateParser, + NumericParser, + TimestampParser, +} from '../introspector'; import { DatabaseMetadata, IntrospectorDialect } from '../introspector'; export type Config = { @@ -42,6 +46,7 @@ export type Config = { serializer?: Serializer; singularize?: boolean | Record; skipAutogeneratedFileComment?: boolean; + timestampParser?: TimestampParser; typeOnlyImports?: boolean; url?: string; verify?: boolean; @@ -132,6 +137,9 @@ export const configSchema = z.object({ .union([z.boolean(), z.record(z.string(), z.string())]) .optional(), skipAutogeneratedFileComment: z.boolean().optional(), + timestampParser: z + .enum(['js-date', 'string']) + .optional(), typeOnlyImports: z.boolean().optional(), url: z.string().optional(), verify: z.boolean().optional(), diff --git a/src/cli/flags.ts b/src/cli/flags.ts index c721ccc9..6c479572 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -108,6 +108,12 @@ export const FLAGS = [ 'Singularize generated table names, e.g. `BlogPost` instead of `BlogPosts`.', longName: 'singularize', }, + { + default: 'js-date', + description: 'Specify which parser to use for PostgreSQL timestamp values.', + longName: 'timestamp-parser', + values: ['js-date', 'string'], + }, { default: 'true', description: diff --git a/src/generator/dialects/postgres/postgres-adapter.ts b/src/generator/dialects/postgres/postgres-adapter.ts index 84546c7f..1b28f2d5 100644 --- a/src/generator/dialects/postgres/postgres-adapter.ts +++ b/src/generator/dialects/postgres/postgres-adapter.ts @@ -1,5 +1,6 @@ import type { DateParser } from '../../../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../../../introspector/dialects/postgres/numeric-parser'; +import type { TimestampParser } from '../../../introspector/dialects/postgres/timestamp-parser'; import { Adapter } from '../../adapter'; import { ColumnTypeNode } from '../../ast/column-type-node'; import { IdentifierNode } from '../../ast/identifier-node'; @@ -18,6 +19,7 @@ import { type PostgresAdapterOptions = { dateParser?: DateParser; numericParser?: NumericParser; + timestampParser?: TimestampParser; }; export class PostgresAdapter extends Adapter { @@ -165,5 +167,19 @@ export class PostgresAdapter extends Adapter { ]), ); } + + if (options?.timestampParser === 'string') { + this.definitions.Timestamp = new ColumnTypeNode( + new IdentifierNode('string'), + new UnionExpressionNode([ + new IdentifierNode('Date'), + new IdentifierNode('string'), + ]), + new UnionExpressionNode([ + new IdentifierNode('Date'), + new IdentifierNode('string'), + ]), + ); + } } } diff --git a/src/generator/dialects/postgres/postgres-dialect.ts b/src/generator/dialects/postgres/postgres-dialect.ts index ece9951f..31ce5302 100644 --- a/src/generator/dialects/postgres/postgres-dialect.ts +++ b/src/generator/dialects/postgres/postgres-dialect.ts @@ -1,6 +1,7 @@ import type { DateParser } from '../../../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../../../introspector/dialects/postgres/numeric-parser'; import { PostgresIntrospectorDialect } from '../../../introspector/dialects/postgres/postgres-dialect'; +import type { TimestampParser } from '../../../introspector/dialects/postgres/timestamp-parser'; import type { GeneratorDialect } from '../../dialect'; import { PostgresAdapter } from './postgres-adapter'; @@ -10,6 +11,7 @@ export type PostgresDialectOptions = { domains?: boolean; numericParser?: NumericParser; partitions?: boolean; + timestampParser?: TimestampParser; }; export class PostgresDialect @@ -25,11 +27,13 @@ export class PostgresDialect domains: options?.domains, numericParser: options?.numericParser, partitions: options?.partitions, + timestampParser: options?.timestampParser, }); this.adapter = new PostgresAdapter({ dateParser: this.options.dateParser, numericParser: this.options.numericParser, + timestampParser: this.options?.timestampParser, }); } } diff --git a/src/generator/generator/generate.test.ts b/src/generator/generator/generate.test.ts index 191f7d62..d95ca172 100644 --- a/src/generator/generator/generate.test.ts +++ b/src/generator/generator/generate.test.ts @@ -57,6 +57,7 @@ const TESTS: Test[] = [ dialect: new PostgresDialect({ dateParser: 'string', numericParser: 'number-or-string', + timestampParser: 'string', }), generateOptions: { runtimeEnums: 'screaming-snake-case' }, name: 'postgres2', @@ -360,6 +361,7 @@ describe(serializeFromMetadata.name, () => { dialect: new PostgresDialect({ dateParser: 'string', numericParser: 'number', + timestampParser: 'string', }), metadata: { tables: [ @@ -367,6 +369,8 @@ describe(serializeFromMetadata.name, () => { columns: [ { dataType: 'date', name: 'date' }, { dataType: 'numeric', name: 'numeric' }, + { dataType: 'timestamp', name: 'timestamp' }, + { dataType: 'timestamptz', name: 'timestamptz' }, ], name: 'table', }, @@ -379,9 +383,13 @@ describe(serializeFromMetadata.name, () => { export type Numeric = ColumnType; + export type Timestamp = ColumnType; + export interface Table { date: string; numeric: Numeric; + timestamp: Timestamp; + timestamptz: Timestamp; } export interface DB { diff --git a/src/generator/generator/snapshots/postgres2.snapshot.ts b/src/generator/generator/snapshots/postgres2.snapshot.ts index 20d9fe16..47d4a4fd 100644 --- a/src/generator/generator/snapshots/postgres2.snapshot.ts +++ b/src/generator/generator/snapshots/postgres2.snapshot.ts @@ -44,7 +44,7 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive; export type Numeric = ColumnType; -export type Timestamp = ColumnType; +export type Timestamp = ColumnType; export interface Enum { name: string; diff --git a/src/generator/transformer/transformer.test.ts b/src/generator/transformer/transformer.test.ts index f8741ceb..441ace96 100644 --- a/src/generator/transformer/transformer.test.ts +++ b/src/generator/transformer/transformer.test.ts @@ -1,12 +1,14 @@ import { deepStrictEqual } from 'node:assert'; import type { DateParser } from '../../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../../introspector/dialects/postgres/numeric-parser'; +import type { TimestampParser } from '../../introspector/dialects/postgres/timestamp-parser'; import { EnumCollection } from '../../introspector/enum-collection'; import { ColumnMetadata } from '../../introspector/metadata/column-metadata'; import { DatabaseMetadata } from '../../introspector/metadata/database-metadata'; import { TableMetadata } from '../../introspector/metadata/table-metadata'; import { AliasDeclarationNode } from '../ast/alias-declaration-node'; import { ArrayExpressionNode } from '../ast/array-expression-node'; +import { ColumnTypeNode } from '../ast/column-type-node'; import { ExportStatementNode } from '../ast/export-statement-node'; import { GenericExpressionNode } from '../ast/generic-expression-node'; import { IdentifierNode, TableIdentifierNode } from '../ast/identifier-node'; @@ -38,16 +40,22 @@ describe(transform.name, () => { numericParser, runtimeEnums, tables, + timestampParser, }: { camelCase?: boolean; dateParser?: DateParser; numericParser?: NumericParser; runtimeEnums?: boolean | RuntimeEnumsStyle; tables: TableMetadata[]; + timestampParser?: TimestampParser; }) => { return transform({ camelCase, - dialect: new PostgresDialect({ dateParser, numericParser }), + dialect: new PostgresDialect({ + dateParser, + numericParser, + timestampParser, + }), metadata: new DatabaseMetadata({ enums, tables }), overrides: { columns: { @@ -316,6 +324,64 @@ describe(transform.name, () => { deepStrictEqual((nodes[1] as any).argument.body.args[0].name, 'number'); }); + it('should be able to transform using an alternative Postgres timestamp parser', () => { + const nodes = transformWithDefaults({ + timestampParser: 'string', + tables: [ + new TableMetadata({ + columns: [ + new ColumnMetadata({ + dataType: 'timestamp', + name: 'timestamp', + }), + new ColumnMetadata({ + dataType: 'timestamptz', + name: 'timestamptz', + }), + ], + name: 'table', + }), + ], + }); + + deepStrictEqual(nodes, [ + new ImportStatementNode('kysely', [new ImportClauseNode('ColumnType')]), + new ExportStatementNode( + new AliasDeclarationNode( + 'Timestamp', + new ColumnTypeNode( + new IdentifierNode('string'), + new UnionExpressionNode([ + new IdentifierNode('Date'), + new IdentifierNode('string'), + ]), + new UnionExpressionNode([ + new IdentifierNode('Date'), + new IdentifierNode('string'), + ]), + ), + ), + ), + new ExportStatementNode( + new InterfaceDeclarationNode( + new TableIdentifierNode('Table'), + new ObjectExpressionNode([ + new PropertyNode('timestamp', new IdentifierNode('Timestamp')), + new PropertyNode('timestamptz', new IdentifierNode('Timestamp')), + ]), + ), + ), + new ExportStatementNode( + new InterfaceDeclarationNode( + new IdentifierNode('DB'), + new ObjectExpressionNode([ + new PropertyNode('table', new TableIdentifierNode('Table')), + ]), + ), + ), + ]); + }); + it('should transform Postgres enums correctly', () => { const nodes = transformWithDefaults({ tables: [ diff --git a/src/introspector/dialects/postgres/postgres-dialect.ts b/src/introspector/dialects/postgres/postgres-dialect.ts index 12b281f0..dcc6763f 100644 --- a/src/introspector/dialects/postgres/postgres-dialect.ts +++ b/src/introspector/dialects/postgres/postgres-dialect.ts @@ -6,6 +6,8 @@ import { DEFAULT_DATE_PARSER } from './date-parser'; import type { NumericParser } from './numeric-parser'; import { DEFAULT_NUMERIC_PARSER } from './numeric-parser'; import { PostgresIntrospector } from './postgres-introspector'; +import type { TimestampParser } from './timestamp-parser'; +import { DEFAULT_TIMESTAMP_PARSER } from './timestamp-parser'; type PostgresDialectOptions = { dateParser?: DateParser; @@ -13,6 +15,7 @@ type PostgresDialectOptions = { domains?: boolean; numericParser?: NumericParser; partitions?: boolean; + timestampParser?: TimestampParser; }; export class PostgresIntrospectorDialect extends IntrospectorDialect { @@ -32,6 +35,7 @@ export class PostgresIntrospectorDialect extends IntrospectorDialect { defaultSchemas: options?.defaultSchemas, domains: options?.domains ?? true, numericParser: options?.numericParser ?? DEFAULT_NUMERIC_PARSER, + timestampParser: options?.timestampParser ?? DEFAULT_TIMESTAMP_PARSER, }; } @@ -54,6 +58,17 @@ export class PostgresIntrospectorDialect extends IntrospectorDialect { }); } + if (this.options.timestampParser === 'string') { + /** + * 13234: time_stamp + * 1114: timestamp + * 1184: timestamptz + */ + pg.types.setTypeParser(13234, (ts) => ts); + pg.types.setTypeParser(1114, (ts) => ts); + pg.types.setTypeParser(1184, (ts) => ts); + } + return new KyselyPostgresDialect({ pool: new pg.Pool({ connectionString: options.connectionString, diff --git a/src/introspector/dialects/postgres/timestamp-parser.ts b/src/introspector/dialects/postgres/timestamp-parser.ts new file mode 100644 index 00000000..518fc1dd --- /dev/null +++ b/src/introspector/dialects/postgres/timestamp-parser.ts @@ -0,0 +1,3 @@ +export type TimestampParser = 'js-date' | 'string'; + +export const DEFAULT_TIMESTAMP_PARSER: TimestampParser = 'js-date'; diff --git a/src/introspector/index.ts b/src/introspector/index.ts index 235aea9d..73b6b379 100644 --- a/src/introspector/index.ts +++ b/src/introspector/index.ts @@ -14,6 +14,7 @@ export * from './dialects/postgres/numeric-parser'; export * from './dialects/postgres/postgres-db'; export * from './dialects/postgres/postgres-dialect'; export * from './dialects/postgres/postgres-introspector'; +export * from './dialects/postgres/timestamp-parser'; export * from './dialects/sqlite/sqlite-dialect'; export * from './dialects/sqlite/sqlite-introspector'; export * from './enum-collection'; From 568dc5961b41b5e4f19a3a9bf13765c422f00e4b Mon Sep 17 00:00:00 2001 From: Cody Gordon Date: Thu, 24 Jul 2025 15:20:38 -0400 Subject: [PATCH 2/3] fix lint error --- src/introspector/dialects/postgres/postgres-dialect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/introspector/dialects/postgres/postgres-dialect.ts b/src/introspector/dialects/postgres/postgres-dialect.ts index dcc6763f..cad996c9 100644 --- a/src/introspector/dialects/postgres/postgres-dialect.ts +++ b/src/introspector/dialects/postgres/postgres-dialect.ts @@ -64,7 +64,7 @@ export class PostgresIntrospectorDialect extends IntrospectorDialect { * 1114: timestamp * 1184: timestamptz */ - pg.types.setTypeParser(13234, (ts) => ts); + pg.types.setTypeParser(13_234, (ts) => ts); pg.types.setTypeParser(1114, (ts) => ts); pg.types.setTypeParser(1184, (ts) => ts); } From 8a26bbaca82e909057bd97767c3cb9d8ce7f8d1b Mon Sep 17 00:00:00 2001 From: Cody Gordon Date: Thu, 24 Jul 2025 15:43:41 -0400 Subject: [PATCH 3/3] fix missing timestampParser CLI dialect entry --- src/cli/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index c3e87b2e..0ca87474 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -55,6 +55,7 @@ export class Cli { domains: options.domains, numericParser: options.numericParser, partitions: options.partitions, + timestampParser: options.timestampParser, }); const db = await dialect.introspector.connect({