Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist
node_modules
.vscode
.idea
.DS_Store
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty common macOS file that gets generated by the OS.

6 changes: 6 additions & 0 deletions src/dialect/postgres/postgres-dialect-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface PostgresDialectConfig {
* Called every time a connection is acquired from the pool.
*/
onReserveConnection?: (connection: DatabaseConnection) => Promise<void>

/**
* @todo: docs
*/
types?: Record<number, (value: string) => any>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming is taken from the equivalent option in pg: https://node-postgres.com/apis/client#new-client

}

/**
Expand All @@ -61,6 +66,7 @@ export interface PostgresPoolClient {
): Promise<PostgresQueryResult<R>>
query<R>(cursor: PostgresCursor<R>): PostgresCursor<R>
release(): void
setTypeParser(typeOrOid: number, parser: (val: string) => any): void
}

export interface PostgresCursor<T> {
Expand Down
8 changes: 8 additions & 0 deletions src/dialect/postgres/postgres-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export class PostgresDriver implements Driver {

async acquireConnection(): Promise<DatabaseConnection> {
const client = await this.#pool!.connect()

if (!!this.#config.types) {
for (const [typeId, parserFn] of Object.entries(this.#config.types)) {
const id = Number(typeId)
client.setTypeParser(id, parserFn)
}
}

let connection = this.#connections.get(client)

if (!connection) {
Expand Down
61 changes: 61 additions & 0 deletions src/helpers/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { types } from 'pg'
import { Expression } from '../expression/expression.js'
import { RawBuilder } from '../raw-builder/raw-builder.js'
import { sql } from '../raw-builder/sql.js'
Expand Down Expand Up @@ -227,3 +228,63 @@ export type MergeAction = 'INSERT' | 'UPDATE' | 'DELETE'
export function mergeAction(): RawBuilder<MergeAction> {
return sql`merge_action()`
}

/**
* We can't use `new Date(v).toISOString()` because `Date` has less precision (milliseconds)
* compared to Postgres' (microseconds). @todo: check, it seems that it truncates fractionals to 3 digits (from 6)
*
* @private
*/
function postgresTimestamptzToIsoString(value: string): string {
/*
Anatomy:
(\d{4}-\d{2}-\d{2}) = Date
\s+ = Space between date and time
(\d{2}:\d{2}:\d{2}\.\d+) = Time (HH:mm:ss.ZZZZZZ)
([+-]\d{2})(:\d{2})? = Timezone offset (+HH[:MM] or -HH[:MM])
Example: 2025-08-10 14:44:40.687342+02
*/
const match = value.match(
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d+)?([+-]\d{2})(:\d{2})?$/,
)

if (!match) {
throw new Error(`Invalid timestamptz format: ${value}`)
}

const [, date, time, offsetHour, offsetMinute = ':00'] = match
return `${date}T${time}${offsetHour}${offsetMinute}`
}

/**
* We can't use `new Date(v).toISOString()` because `Date` has less precision (milliseconds)
* compared to Postgres' (microseconds).
*
* @private
*/
function postgresTimestampToIsoString(value: string): string {
/*
Anatomy:
(\d{4}-\d{2}-\d{2}) = Date
\s+ = Space between date and time
(\d{2}:\d{2}:\d{2}\.\d+) = Time (HH:mm:ss.ZZZZZZ)
Example: 2025-08-10 14:44:40.687342
*/
const match = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d+)?$/)

if (!match) {
throw new Error(`Invalid timestamptz format: ${value}`)
}

const [, date, time] = match
return `${date}T${time}`
}

/**
* @todo: document
*/
export const SENSIBLE_TYPES: Record<number, (value: string) => any> = {
[types.builtins.TIMESTAMPTZ]: (v) => postgresTimestamptzToIsoString(v),
[types.builtins.TIMESTAMP]: (v) => postgresTimestampToIsoString(v),
[types.builtins.DATE]: (v) => v,
}
132 changes: 132 additions & 0 deletions test/node/src/sensible-pg-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Pool } from 'pg'
import { Generated, Kysely, PostgresDialect, sql } from '../../../'
import { jsonObjectFrom, SENSIBLE_TYPES } from '../../../helpers/postgres'

import {
destroyTest,
initTest,
TestContext,
expect,
Database,
insertDefaultDataSet,
clearDatabase,
DIALECTS,
DIALECT_CONFIGS,
PLUGINS,
} from './test-setup.js'

interface Values {
id: Generated<number>
bigint: string
timestamptz: string
timestamp: string
date: string
time: string
timetz: string
array: number[]
// @todo: bytea
// @todo: money
// @todo: range types
}

if (DIALECTS.includes('postgres')) {
const dialect = 'postgres'

describe(`${dialect} sensible defaults`, () => {
let ctx: TestContext
let db: Kysely<Database & { values: Values }>

before(async function () {
ctx = await initTest(this, dialect, {})

await ctx.db.schema
.createTable('values')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('bigint', 'bigint', (col) => col.notNull())
.addColumn('timestamptz', 'timestamptz', (col) => col.notNull())
.addColumn('timestamp', 'timestamp', (col) => col.notNull())
.addColumn('date', 'date', (col) => col.notNull())
.addColumn('time', 'time', (col) => col.notNull())
.addColumn('timetz', 'timetz', (col) => col.notNull())
.addColumn('array', sql`integer[]`, (col) => col.notNull())
.execute()

db = new Kysely<Database & { values: Values }>({
dialect: new PostgresDialect({
pool: async () => new Pool(DIALECT_CONFIGS.postgres),
types: SENSIBLE_TYPES,
}),
plugins: PLUGINS,
})
})

beforeEach(async () => {
await insertDefaultDataSet(ctx)

await db
.insertInto('values')
.values({
bigint: '9223372036854775807',
timestamptz: '2025-08-10 14:44:40.687342+02',
timestamp: '2025-08-10 14:44:40.687342Z',
date: '2025-08-10',
time: '14:44:40.687342',
timetz: '14:44:40.687342+02',
array: [1, 2, 3],
})
.execute()
})

afterEach(async () => {
await db.deleteFrom('values').execute()
await clearDatabase(ctx)
})

after(async () => {
await ctx.db.schema.dropTable('values').ifExists().execute()
await destroyTest(ctx)
})

it('regular selects should return the same values as JSON serialized values', async () => {
const columns: (keyof Values)[] = [
'timestamptz',
'timestamp',
'date',
'timetz',
'time',
'array',
]
const rawValues = await db
.selectFrom('values')
.select(columns)
.executeTakeFirstOrThrow()
const { value: jsonValues } = await db
.selectNoFrom((eb) =>
jsonObjectFrom(eb.selectFrom('values').select(columns))
.$notNull()
.as('value'),
)
.executeTakeFirstOrThrow()

expect(rawValues).to.eql(jsonValues)
})

it('to prevent data loss some types should not have the same value as their JSON serialized equivalent', async () => {
const columns: (keyof Values)[] = ['bigint']
const rawValues = await db
.selectFrom('values')
.select(columns)
.executeTakeFirstOrThrow()
const { value: jsonValues } = await db
.selectNoFrom((eb) =>
jsonObjectFrom(eb.selectFrom('values').select(columns))
.$notNull()
.as('value'),
)
.executeTakeFirstOrThrow()

expect(rawValues.bigint).to.eql('9223372036854775807')
expect(jsonValues.bigint).to.eql(9223372036854776000)
})
})
}