From d8982c051070ee7e654d386f7142af54094e1e56 Mon Sep 17 00:00:00 2001 From: Brian McDaniel Date: Mon, 14 Oct 2024 14:28:41 -0400 Subject: [PATCH] feat: Add --date-parser flag to control DATE type Many people prefer to parse DATE columns as `strings` due to timezone issues with JS `Date`s. This adds a new flag, --date-parser, that allows the user to choose between using `Date` or `string` as the type for DATE columns. --- src/cli/cli.test.ts | 4 ++ src/cli/cli.ts | 22 +++++++++- src/cli/flags.ts | 6 +++ src/generator/dialect-manager.ts | 2 + .../dialects/postgres/postgres-adapter.ts | 8 ++++ .../dialects/postgres/postgres-dialect.ts | 6 ++- src/generator/generator/generate.test.ts | 2 + src/generator/generator/generate.ts | 2 + .../postgres-runtime-enums.snapshot.ts | 1 + .../generator/snapshots/postgres.snapshot.ts | 1 + src/generator/transformer/transform.test.ts | 44 ++++++++++++++++++- .../dialects/postgres/date-parser.ts | 6 +++ .../dialects/postgres/postgres-dialect.ts | 7 +++ src/introspector/index.ts | 1 + src/introspector/introspector.fixtures.ts | 1 + src/introspector/introspector.test.ts | 10 +++++ 16 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/introspector/dialects/postgres/date-parser.ts diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 65efa2d..328018e 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { describe, it } from 'vitest'; import packageJson from '../../package.json'; import { LogLevel } from '../generator/logger/log-level'; +import { DateParser, DEFAULT_DATE_PARSER } from '../introspector/dialects/postgres/date-parser'; import { DEFAULT_NUMERIC_PARSER } from '../introspector/dialects/postgres/numeric-parser'; import type { CliOptions } from './cli'; import { Cli } from './cli'; @@ -14,6 +15,7 @@ describe(Cli.name, () => { const DEFAULT_CLI_OPTIONS: CliOptions = { camelCase: false, + dateParser: DEFAULT_DATE_PARSER, dialectName: undefined, domains: false, envFile: undefined, @@ -52,6 +54,8 @@ describe(Cli.name, () => { }; assert(['--camel-case'], { camelCase: true }); + assert(['--date-parser=timestamp'], { dateParser: DateParser.TIMESTAMP }); + assert(['--date-parser=string'], { dateParser: DateParser.STRING }); assert(['--dialect=mysql'], { dialectName: 'mysql' }); assert(['--domains'], { domains: true }); assert(['--exclude-pattern=public._*'], { excludePattern: 'public._*' }); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index aa97638..afa2dfb 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -7,6 +7,7 @@ import { RuntimeEnumsStyle } from '../generator/generator/runtime-enums-style'; import { LogLevel } from '../generator/logger/log-level'; import { Logger } from '../generator/logger/logger'; import type { Overrides } from '../generator/transformer/transform'; +import { DateParser, DEFAULT_DATE_PARSER } from '../introspector/dialects/postgres/date-parser'; import { DEFAULT_NUMERIC_PARSER, NumericParser, @@ -21,6 +22,7 @@ import { FLAGS, serializeFlags } from './flags'; export type CliOptions = { camelCase?: boolean; + dateParser?: DateParser; dialectName?: DialectName; domains?: boolean; envFile?: string; @@ -49,6 +51,7 @@ export class Cli { async generate(options: CliOptions) { const camelCase = !!options.camelCase; + const dateParser = options.dateParser; const excludePattern = options.excludePattern; const includePattern = options.includePattern; const numericParser = options.numericParser; @@ -81,6 +84,7 @@ export class Cli { } const dialectManager = new DialectManager({ + dateParser: options.dateParser ?? DEFAULT_DATE_PARSER, domains: !!options.domains, numericParser: options.numericParser ?? DEFAULT_NUMERIC_PARSER, partitions: !!options.partitions, @@ -96,6 +100,7 @@ export class Cli { await generate({ camelCase, + dateParser, db, dialect, excludePattern, @@ -121,6 +126,17 @@ export class Cli { return !!input && input !== 'false'; } + #parseDateParser(input: any) { + switch (input) { + case 'string': + return DateParser.STRING; + case 'timestamp': + return DateParser.TIMESTAMP; + default: + return DEFAULT_DATE_PARSER; + } + } + #parseLogLevel(input: any) { switch (input) { case 'silent': @@ -185,6 +201,7 @@ export class Cli { const _: string[] = argv._; const camelCase = this.#parseBoolean(argv['camel-case']); + const dateParser = this.#parseDateParser(argv['date-parser']); const dialectName = this.#parseString(argv.dialect) as DialectName; const domains = this.#parseBoolean(argv.domains); const envFile = this.#parseString(argv['env-file']); @@ -242,13 +259,14 @@ export class Cli { if (!url) { throw new TypeError( "Parameter '--url' must be a valid connection string. Examples:\n\n" + - ' --url=postgres://username:password@mydomain.com/database\n' + - ' --url=env(DATABASE_URL)', + ' --url=postgres://username:password@mydomain.com/database\n' + + ' --url=env(DATABASE_URL)', ); } return { camelCase, + dateParser, dialectName, domains, envFile, diff --git a/src/cli/flags.ts b/src/cli/flags.ts index a3b69d4..f1c31c5 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -21,6 +21,12 @@ export const FLAGS = [ description: 'Use the Kysely CamelCasePlugin.', longName: 'camel-case', }, + { + default: 'timestamp', + description: 'Specify which parser to use for PostgreSQL date values.', + longName: 'date-parser', + values: ['string', 'timestamp'], + }, { description: 'Set the SQL dialect.', longName: 'dialect', diff --git a/src/generator/dialect-manager.ts b/src/generator/dialect-manager.ts index 75f7a51..80cbb6b 100644 --- a/src/generator/dialect-manager.ts +++ b/src/generator/dialect-manager.ts @@ -1,3 +1,4 @@ +import type { DateParser } from '../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../introspector/dialects/postgres/numeric-parser'; import type { GeneratorDialect } from './dialect'; import { KyselyBunSqliteDialect } from './dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect'; @@ -19,6 +20,7 @@ export type DialectName = | 'worker-bun-sqlite'; type DialectManagerOptions = { + dateParser?: DateParser; domains?: boolean; numericParser?: NumericParser; partitions?: boolean; diff --git a/src/generator/dialects/postgres/postgres-adapter.ts b/src/generator/dialects/postgres/postgres-adapter.ts index 4fa58a5..ccf33d0 100644 --- a/src/generator/dialects/postgres/postgres-adapter.ts +++ b/src/generator/dialects/postgres/postgres-adapter.ts @@ -1,3 +1,4 @@ +import { DateParser } from '../../../introspector/dialects/postgres/date-parser'; import { NumericParser } from '../../../introspector/dialects/postgres/numeric-parser'; import { Adapter } from '../../adapter'; import { ColumnTypeNode } from '../../ast/column-type-node'; @@ -15,6 +16,7 @@ import { } from '../../transformer/definitions'; type PostgresAdapterOptions = { + dateParser?: DateParser; numericParser?: NumericParser; }; @@ -137,6 +139,12 @@ export class PostgresAdapter extends Adapter { constructor(options?: PostgresAdapterOptions) { super(); + if (options?.dateParser === DateParser.STRING) { + this.scalars.date = new IdentifierNode('string'); + } else { + this.scalars.date = new IdentifierNode('Timestamp'); + } + if (options?.numericParser === NumericParser.NUMBER) { this.definitions.Numeric = new ColumnTypeNode( new IdentifierNode('number'), diff --git a/src/generator/dialects/postgres/postgres-dialect.ts b/src/generator/dialects/postgres/postgres-dialect.ts index 40ecb19..23e4a70 100644 --- a/src/generator/dialects/postgres/postgres-dialect.ts +++ b/src/generator/dialects/postgres/postgres-dialect.ts @@ -1,9 +1,11 @@ +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 { GeneratorDialect } from '../../dialect'; import { PostgresAdapter } from './postgres-adapter'; type PostgresDialectOptions = { + dateParser?: DateParser; defaultSchemas?: string[]; domains?: boolean; numericParser?: NumericParser; @@ -12,14 +14,14 @@ type PostgresDialectOptions = { export class PostgresDialect extends PostgresIntrospectorDialect - implements GeneratorDialect -{ + implements GeneratorDialect { readonly adapter: PostgresAdapter; constructor(options?: PostgresDialectOptions) { super(options); this.adapter = new PostgresAdapter({ + dateParser: this.options.dateParser, numericParser: this.options.numericParser, }); } diff --git a/src/generator/generator/generate.test.ts b/src/generator/generator/generate.test.ts index 0b45b52..721d75f 100644 --- a/src/generator/generator/generate.test.ts +++ b/src/generator/generator/generate.test.ts @@ -2,6 +2,7 @@ import { strictEqual } from 'assert'; import { readFile } from 'fs/promises'; import { join } from 'path'; import { describe, test } from 'vitest'; +import { DateParser } from '../../introspector/dialects/postgres/date-parser'; import { NumericParser } from '../../introspector/dialects/postgres/numeric-parser'; import { addExtraColumn, @@ -33,6 +34,7 @@ const TESTS: Test[] = [ { connectionString: 'postgres://user:password@localhost:5433/database', dialect: new PostgresDialect({ + dateParser: DateParser.TIMESTAMP, numericParser: NumericParser.NUMBER_OR_STRING, }), }, diff --git a/src/generator/generator/generate.ts b/src/generator/generator/generate.ts index c98ace9..004b04c 100644 --- a/src/generator/generator/generate.ts +++ b/src/generator/generator/generate.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'fs'; import type { Kysely } from 'kysely'; import { parse, relative, sep } from 'path'; import { performance } from 'perf_hooks'; +import type { DateParser } from '../../introspector/dialects/postgres/date-parser'; import type { NumericParser } from '../../introspector/dialects/postgres/numeric-parser'; import type { GeneratorDialect } from '../dialect'; import type { Logger } from '../logger/logger'; @@ -12,6 +13,7 @@ import { Serializer } from './serializer'; export type GenerateOptions = { camelCase?: boolean; + dateParser?: DateParser; db: Kysely; dialect: GeneratorDialect; excludePattern?: string; diff --git a/src/generator/generator/snapshots/postgres-runtime-enums.snapshot.ts b/src/generator/generator/snapshots/postgres-runtime-enums.snapshot.ts index 4d84857..1883630 100644 --- a/src/generator/generator/snapshots/postgres-runtime-enums.snapshot.ts +++ b/src/generator/generator/snapshots/postgres-runtime-enums.snapshot.ts @@ -49,6 +49,7 @@ export type Timestamp = ColumnType; export interface FooBar { array: string[] | null; childDomain: number | null; + date: string | null; defaultedNullablePosInt: Generated; defaultedRequiredPosInt: Generated; /** diff --git a/src/generator/generator/snapshots/postgres.snapshot.ts b/src/generator/generator/snapshots/postgres.snapshot.ts index 7927419..3c6dc39 100644 --- a/src/generator/generator/snapshots/postgres.snapshot.ts +++ b/src/generator/generator/snapshots/postgres.snapshot.ts @@ -43,6 +43,7 @@ export type Timestamp = ColumnType; export interface FooBar { array: string[] | null; childDomain: number | null; + date: string | null; defaultedNullablePosInt: Generated; defaultedRequiredPosInt: Generated; /** diff --git a/src/generator/transformer/transform.test.ts b/src/generator/transformer/transform.test.ts index efded67..def647f 100644 --- a/src/generator/transformer/transform.test.ts +++ b/src/generator/transformer/transform.test.ts @@ -1,5 +1,6 @@ import { deepStrictEqual } from 'assert'; import { describe, it } from 'vitest'; +import { DateParser } from '../../introspector/dialects/postgres/date-parser'; import { NumericParser } from '../../introspector/dialects/postgres/numeric-parser'; import { EnumCollection } from '../../introspector/enum-collection'; import { ColumnMetadata } from '../../introspector/metadata/column-metadata'; @@ -34,11 +35,13 @@ describe(transform.name, () => { const transformWithDefaults = ({ camelCase, + dateParser, numericParser, runtimeEnums, tables, }: { camelCase?: boolean; + dateParser?: DateParser; numericParser?: NumericParser; runtimeEnums?: boolean; runtimeEnumsStyle?: RuntimeEnumsStyle; @@ -46,7 +49,7 @@ describe(transform.name, () => { }) => { return transform({ camelCase, - dialect: new PostgresDialect({ numericParser }), + dialect: new PostgresDialect({ dateParser, numericParser }), metadata: new DatabaseMetadata({ enums, tables }), overrides: { columns: { @@ -260,6 +263,45 @@ describe(transform.name, () => { ]); }); + it('should be able to transform using an alternative Postgres date parser', () => { + const nodes = transformWithDefaults({ + dateParser: DateParser.STRING, + tables: [ + new TableMetadata({ + columns: [ + new ColumnMetadata({ + dataType: 'date', + name: 'date', + }), + ], + name: 'table', + }), + ], + }); + + deepStrictEqual(nodes, [ + new ExportStatementNode( + new InterfaceDeclarationNode( + 'Table', + new ObjectExpressionNode([ + new PropertyNode( + 'date', + new IdentifierNode('string'), + ), + ]), + ), + ), + new ExportStatementNode( + new InterfaceDeclarationNode( + 'DB', + new ObjectExpressionNode([ + new PropertyNode('table', new IdentifierNode('Table')), + ]), + ), + ), + ]); + }); + it('should be able to transform using an alternative Postgres numeric parser', () => { const nodes = transformWithDefaults({ numericParser: NumericParser.NUMBER, diff --git a/src/introspector/dialects/postgres/date-parser.ts b/src/introspector/dialects/postgres/date-parser.ts new file mode 100644 index 0000000..661eb0c --- /dev/null +++ b/src/introspector/dialects/postgres/date-parser.ts @@ -0,0 +1,6 @@ +export const enum DateParser { + STRING = 'string', + TIMESTAMP = 'timestamp' +} + +export const DEFAULT_DATE_PARSER = DateParser.TIMESTAMP; diff --git a/src/introspector/dialects/postgres/postgres-dialect.ts b/src/introspector/dialects/postgres/postgres-dialect.ts index 16c435d..d5f6485 100644 --- a/src/introspector/dialects/postgres/postgres-dialect.ts +++ b/src/introspector/dialects/postgres/postgres-dialect.ts @@ -3,8 +3,10 @@ import type { CreateKyselyDialectOptions } from '../../dialect'; import { IntrospectorDialect } from '../../dialect'; import { DEFAULT_NUMERIC_PARSER, NumericParser } from './numeric-parser'; import { PostgresIntrospector } from './postgres-introspector'; +import { DateParser, DEFAULT_DATE_PARSER } from './date-parser'; type PostgresDialectOptions = { + dateParser?: DateParser; defaultSchemas?: string[]; domains?: boolean; numericParser?: NumericParser; @@ -24,6 +26,7 @@ export class PostgresIntrospectorDialect extends IntrospectorDialect { partitions: options?.partitions, }); this.options = { + dateParser: options?.dateParser ?? DEFAULT_DATE_PARSER, defaultSchemas: options?.defaultSchemas, domains: options?.domains ?? true, numericParser: options?.numericParser ?? DEFAULT_NUMERIC_PARSER, @@ -45,6 +48,10 @@ export class PostgresIntrospectorDialect extends IntrospectorDialect { }); } + if (this.options.dateParser === DateParser.STRING) { + pg.types.setTypeParser(1082, (date) => date); + } + return new KyselyPostgresDialect({ pool: new pg.Pool({ connectionString: options.connectionString, diff --git a/src/introspector/index.ts b/src/introspector/index.ts index c7447da..d746d53 100644 --- a/src/introspector/index.ts +++ b/src/introspector/index.ts @@ -7,6 +7,7 @@ export * from './dialects/mysql/mysql-db'; export * from './dialects/mysql/mysql-dialect'; export * from './dialects/mysql/mysql-introspector'; export * from './dialects/mysql/mysql-parser'; +export * from './dialects/postgres/date-parser'; export * from './dialects/postgres/numeric-parser'; export * from './dialects/postgres/postgres-db'; export * from './dialects/postgres/postgres-dialect'; diff --git a/src/introspector/introspector.fixtures.ts b/src/introspector/introspector.fixtures.ts index 1e33f40..79827da 100644 --- a/src/introspector/introspector.fixtures.ts +++ b/src/introspector/introspector.fixtures.ts @@ -68,6 +68,7 @@ const up = async (db: Kysely, dialect: IntrospectorDialect) => { } else if (dialect instanceof PostgresIntrospectorDialect) { builder = builder .addColumn('id', 'serial') + .addColumn('date', 'date') .addColumn('user_status', sql`status`) .addColumn('user_status_2', sql`test.status`) .addColumn('array', sql`text[]`) diff --git a/src/introspector/introspector.test.ts b/src/introspector/introspector.test.ts index 39696ff..cd69ea3 100644 --- a/src/introspector/introspector.test.ts +++ b/src/introspector/introspector.test.ts @@ -2,6 +2,7 @@ import { deepStrictEqual } from 'assert'; import { type Kysely } from 'kysely'; import parsePostgresInterval from 'postgres-interval'; import { describe, test } from 'vitest'; +import { DateParser } from './dialects/postgres/date-parser'; import { NumericParser } from '../introspector/dialects/postgres/numeric-parser'; import { migrate } from '../introspector/introspector.fixtures'; import type { IntrospectorDialect } from './dialect'; @@ -32,10 +33,12 @@ const TESTS: Test[] = [ { connectionString: 'postgres://user:password@localhost:5433/database', dialect: new PostgresIntrospectorDialect({ + dateParser: DateParser.STRING, numericParser: NumericParser.NUMBER_OR_STRING, }), inputValues: { false: false, + date: '2024-10-14', id: 1, interval1: parsePostgresInterval('1 day'), interval2: '24 months', @@ -46,6 +49,7 @@ const TESTS: Test[] = [ }, outputValues: { false: false, + date: '2024-10-14', id: 1, interval1: { days: 1 }, interval2: { years: 2 }, @@ -182,6 +186,12 @@ describe(Introspector.name, () => { isAutoIncrementing: true, name: 'id', }), + new ColumnMetadata({ + dataType: 'date', + dataTypeSchema: 'pg_catalog', + isNullable: true, + name: 'date', + }), new ColumnMetadata({ dataType: 'status', dataTypeSchema: 'public',