diff --git a/packages/sql/src/modules/type-mapping.test.ts b/packages/sql/src/modules/type-mapping.test.ts index c0b1983c..cf5b5bb5 100644 --- a/packages/sql/src/modules/type-mapping.test.ts +++ b/packages/sql/src/modules/type-mapping.test.ts @@ -33,7 +33,7 @@ describe('cross-dialect type translation', () => { expect(xlate(db2, pg, 'INTEGER').sql).toBe('integer'); expect(xlate(db2, pg, 'DECIMAL(10,2)').sql).toBe('numeric(10,2)'); expect(xlate(db2, pg, 'CLOB').sql).toBe('text'); - expect(xlate(db2, pg, 'TIMESTAMP').sql).toBe('timestamp'); + expect(xlate(db2, pg, 'TIMESTAMP').sql).toBe('timestamp(6)'); // DBCLOB has no direct Postgres type → mapped to text (no warning needed, text covers it) expect(xlate(db2, pg, 'DBCLOB(1048576)').sql).toBe('text'); }); @@ -49,7 +49,8 @@ describe('cross-dialect type translation', () => { it('SQL Server → Postgres', () => { expect(xlate(mssql, pg, 'bit').sql).toBe('boolean'); expect(xlate(mssql, pg, 'nvarchar(max)').sql).toBe('text'); - expect(xlate(mssql, pg, 'datetime2').sql).toBe('timestamp'); + // Bare datetime2 ≡ datetime2(7) in SQL Server. + expect(xlate(mssql, pg, 'datetime2').sql).toBe('timestamp(7)'); expect(xlate(mssql, pg, 'uniqueidentifier').sql).toBe('uuid'); }); @@ -96,10 +97,41 @@ describe('cross-dialect type translation', () => { expect(xlate(pg, oracle, 'timestamp(6)').sql).toBe('TIMESTAMP(6)'); }); + it('fills dialect-default fsp when catalogs omit the parenthetical', () => { + // Postgres format_type omits (6) for the default typmod; Oracle TIMESTAMP + // defaults to TIMESTAMP(6). Without attaching that length, MySQL rendered + // bare datetime (= fsp 0) and silently dropped microseconds on migrate. + expect(pg.parseType('timestamp without time zone')).toMatchObject({ + base: 'timestamp', + length: 6, + }); + expect(pg.parseType('timestamp with time zone')).toMatchObject({ + base: 'timestamptz', + length: 6, + }); + expect(pg.parseType('time without time zone')).toMatchObject({ base: 'time', length: 6 }); + expect(xlate(pg, mysql, 'timestamp without time zone').sql).toBe('datetime(6)'); + expect(xlate(pg, mysql, 'time').sql).toBe('time(6)'); + expect(oracle.parseType('TIMESTAMP')).toMatchObject({ base: 'timestamp', length: 6 }); + expect(xlate(oracle, mysql, 'TIMESTAMP').sql).toBe('datetime(6)'); + // SQL Server datetime ≈ ms; smalldatetime has no fractional seconds. + expect(mssql.parseType('datetime')).toMatchObject({ base: 'timestamp', length: 3 }); + expect(mssql.parseType('smalldatetime')).toMatchObject({ base: 'timestamp', length: 0 }); + expect(xlate(mssql, mysql, 'datetime').sql).toBe('datetime(3)'); + expect(xlate(mssql, mysql, 'smalldatetime').sql).toBe('datetime(0)'); + expect(mssql.parseType('datetime2')).toMatchObject({ base: 'timestamp', length: 7 }); + // MySQL bare datetime/time really are fsp 0 — do not invent a default. + expect(mysql.parseType('datetime')).toMatchObject({ base: 'timestamp' }); + expect(mysql.parseType('datetime').length).toBeUndefined(); + expect(xlate(mysql, mysql, 'datetime').sql).toBe('datetime'); + }); + it('distinguishes temporal fsp in canonicalEquals', () => { expect(canonicalEquals(mysql.parseType('datetime(6)'), mysql.parseType('datetime'))).toBe(false); expect(canonicalEquals(pg.parseType('timestamp(6)'), pg.parseType('timestamp(3)'))).toBe(false); expect(canonicalEquals(pg.parseType('timestamp(6)'), pg.parseType('timestamp(6)'))).toBe(true); + // Bare Postgres timestamp ≡ timestamp(6). + expect(canonicalEquals(pg.parseType('timestamp'), pg.parseType('timestamp(6)'))).toBe(true); }); it('attaches a warning when the target has no exact equivalent', () => { diff --git a/packages/sql/src/modules/type-mapping.ts b/packages/sql/src/modules/type-mapping.ts index 7f49058b..304d2f46 100644 --- a/packages/sql/src/modules/type-mapping.ts +++ b/packages/sql/src/modules/type-mapping.ts @@ -148,6 +148,20 @@ export const decimalAs = (kw: string): RenderRule => (t) => /** Always renders `sql`, attaching a warning (for known inexact mappings). */ export const warn = (sql: string, message: string): RenderRule => () => ({ sql, warning: message }); +/** + * Catalogs often omit the parenthetical when a temporal type uses its dialect + * default fractional-seconds precision (Postgres `timestamp` ≡ timestamp(6), + * Oracle `TIMESTAMP` ≡ TIMESTAMP(6)). Without that length, MySQL/MariaDB render + * bare `datetime`/`time` (= fsp 0) and silently truncate sub-seconds on migrate. + */ +export function withDefaultTemporalFsp(parsed: CanonicalType, defaultFsp: number): CanonicalType { + if (parsed.length !== undefined) return parsed; + if (parsed.base !== 'time' && parsed.base !== 'timestamp' && parsed.base !== 'timestamptz') { + return parsed; + } + return { ...parsed, length: defaultFsp }; +} + /** True when two canonical types are equivalent (base + relevant size fields). */ export function canonicalEquals(a: CanonicalType, b: CanonicalType): boolean { if (a.base !== b.base) return false; diff --git a/packages/sql/src/providers/db2/db2.sql-dialect.ts b/packages/sql/src/providers/db2/db2.sql-dialect.ts index 7295fbe3..af2b10e8 100644 --- a/packages/sql/src/providers/db2/db2.sql-dialect.ts +++ b/packages/sql/src/providers/db2/db2.sql-dialect.ts @@ -1,5 +1,14 @@ import type { SqlDialect, ColumnSpec } from '../../modules/sql-dialect.interface.js'; -import { makeDialectTypeFns, plain, sized, sizedOr, decimalAs, temporalAs, warn } from '../../modules/type-mapping.js'; +import { + makeDialectTypeFns, + plain, + sized, + sizedOr, + decimalAs, + temporalAs, + warn, + withDefaultTemporalFsp, +} from '../../modules/type-mapping.js'; const types = makeDialectTypeFns({ label: 'Db2', @@ -206,4 +215,12 @@ export const db2SqlDialect: SqlDialect = { }, ...types, + // Bare TIMESTAMP ≡ TIMESTAMP(6). TIME has no fractional seconds on Db2. + parseType: (raw: string) => { + const parsed = types.parseType(raw); + if (parsed.base === 'timestamp' && parsed.length === undefined) { + return withDefaultTemporalFsp(parsed, 6); + } + return parsed; + }, }; diff --git a/packages/sql/src/providers/duckDb/duckdb.sql-dialect.ts b/packages/sql/src/providers/duckDb/duckdb.sql-dialect.ts index 0134e464..3b81f850 100644 --- a/packages/sql/src/providers/duckDb/duckdb.sql-dialect.ts +++ b/packages/sql/src/providers/duckDb/duckdb.sql-dialect.ts @@ -1,6 +1,12 @@ import type { SqlDialect, ColumnSpec } from '../../modules/sql-dialect.interface.js'; import type { TableSchema } from '../../interfaces/index.js'; -import { makeDialectTypeFns, plain, sized, decimalAs } from '../../modules/type-mapping.js'; +import { + makeDialectTypeFns, + plain, + sized, + decimalAs, + withDefaultTemporalFsp, +} from '../../modules/type-mapping.js'; // DuckDB's type system is Postgres-flavored. Key differences from Postgres: // binary is BLOB (not bytea), and it exposes HUGEINT / unsigned ints (mapped @@ -142,4 +148,6 @@ export const duckDbSqlDialect: SqlDialect = { }, ...types, + // DuckDB TIMESTAMP defaults to microsecond precision when scale is omitted. + parseType: (raw: string) => withDefaultTemporalFsp(types.parseType(raw), 6), }; diff --git a/packages/sql/src/providers/oracle/oracle.sql-dialect.ts b/packages/sql/src/providers/oracle/oracle.sql-dialect.ts index e0a199d6..a5fd2724 100644 --- a/packages/sql/src/providers/oracle/oracle.sql-dialect.ts +++ b/packages/sql/src/providers/oracle/oracle.sql-dialect.ts @@ -1,6 +1,15 @@ import type { SqlDialect, ColumnSpec } from '../../modules/sql-dialect.interface.js'; import type { TableSchema } from '../../interfaces/index.js'; -import { makeDialectTypeFns, plain, sized, sizedOr, decimalAs, temporalAs, warn } from '../../modules/type-mapping.js'; +import { + makeDialectTypeFns, + plain, + sized, + sizedOr, + decimalAs, + temporalAs, + warn, + withDefaultTemporalFsp, +} from '../../modules/type-mapping.js'; const types = makeDialectTypeFns({ label: 'Oracle', @@ -205,4 +214,6 @@ export const oracleSqlDialect: SqlDialect = { }, ...types, + // Bare TIMESTAMP ≡ TIMESTAMP(6). Catalogs sometimes omit DATA_SCALE. + parseType: (raw: string) => withDefaultTemporalFsp(types.parseType(raw), 6), }; diff --git a/packages/sql/src/providers/postgres/postgres.sql-dialect.ts b/packages/sql/src/providers/postgres/postgres.sql-dialect.ts index 7ec8fcc6..ed5efd0f 100644 --- a/packages/sql/src/providers/postgres/postgres.sql-dialect.ts +++ b/packages/sql/src/providers/postgres/postgres.sql-dialect.ts @@ -1,6 +1,13 @@ import type { SqlDialect, ColumnSpec } from '../../modules/sql-dialect.interface.js'; import type { TableSchema } from '../../interfaces/index.js'; -import { makeDialectTypeFns, plain, sized, decimalAs, temporalAs } from '../../modules/type-mapping.js'; +import { + makeDialectTypeFns, + plain, + sized, + decimalAs, + temporalAs, + withDefaultTemporalFsp, +} from '../../modules/type-mapping.js'; const types = makeDialectTypeFns({ label: 'PostgreSQL', @@ -68,6 +75,14 @@ function viewDepTag(qualifiedTable: string): string { return qualifiedTable.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48); } +/** + * Postgres `format_type` omits `(6)` when typmod is the default (-1). Bare + * `timestamp` / `timestamptz` / `time` still store 6 fractional digits. + */ +function parsePostgresType(raw: string) { + return withDefaultTemporalFsp(types.parseType(raw), 6); +} + export const postgresSqlDialect: SqlDialect = { identityClause(c: ColumnSpec): string { return c.identity ? ` GENERATED ${c.identityGeneration ?? 'ALWAYS'} AS IDENTITY` : ''; @@ -220,4 +235,5 @@ export const postgresSqlDialect: SqlDialect = { }, ...types, + parseType: parsePostgresType, }; diff --git a/packages/sql/src/providers/redshift/redshift.sql-dialect.ts b/packages/sql/src/providers/redshift/redshift.sql-dialect.ts index 22cc13fb..31071254 100644 --- a/packages/sql/src/providers/redshift/redshift.sql-dialect.ts +++ b/packages/sql/src/providers/redshift/redshift.sql-dialect.ts @@ -1,5 +1,11 @@ import type { SqlDialect, ColumnSpec } from '../../modules/sql-dialect.interface.js'; -import { makeDialectTypeFns, plain, sized, decimalAs } from '../../modules/type-mapping.js'; +import { + makeDialectTypeFns, + plain, + sized, + decimalAs, + withDefaultTemporalFsp, +} from '../../modules/type-mapping.js'; // Redshift type system is a subset of Postgres. information_schema returns // standard SQL type names, so the mapping mirrors the Postgres dialect. @@ -117,4 +123,6 @@ export const redshiftSqlDialect: SqlDialect = { dropRoutineSignature: true, ...types, + // Same as Postgres: bare timestamp/time still carry microsecond precision. + parseType: (raw: string) => withDefaultTemporalFsp(types.parseType(raw), 6), }; diff --git a/packages/sql/src/providers/sqlServer/sqlserver.sql-dialect.ts b/packages/sql/src/providers/sqlServer/sqlserver.sql-dialect.ts index 46bc91da..42649c77 100644 --- a/packages/sql/src/providers/sqlServer/sqlserver.sql-dialect.ts +++ b/packages/sql/src/providers/sqlServer/sqlserver.sql-dialect.ts @@ -71,13 +71,25 @@ const types = makeDialectTypeFns({ }, }); -/** Attach the fixed MONEY/SMALLMONEY precision the bare parse map cannot express. */ +/** + * Attach sizes the bare parse map cannot express: + * - MONEY / SMALLMONEY fixed decimal precision + * - datetime (~ms) / smalldatetime (minutes) / default datetime2|time|datetimeoffset(7) + * when the catalog omits the scale parenthetical + */ function parseSqlServerType(raw: string) { const parsed = types.parseType(raw); - if (parsed.base !== 'decimal' || parsed.precision !== undefined) return parsed; const name = tokenizeType(raw).name; - if (name === 'money') return { ...parsed, precision: 19, scale: 4 }; - if (name === 'smallmoney') return { ...parsed, precision: 10, scale: 4 }; + if (parsed.base === 'decimal' && parsed.precision === undefined) { + if (name === 'money') return { ...parsed, precision: 19, scale: 4 }; + if (name === 'smallmoney') return { ...parsed, precision: 10, scale: 4 }; + } + if (parsed.length !== undefined) return parsed; + if (name === 'datetime') return { ...parsed, length: 3 }; + if (name === 'smalldatetime') return { ...parsed, length: 0 }; + if (name === 'datetime2' || name === 'time' || name === 'datetimeoffset') { + return { ...parsed, length: 7 }; + } return parsed; }