diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 40ee0758f..67ead37ea 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -72,7 +72,7 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s - **Strings and casts**: `concat`/`concatKysely` instead of `||`, `substring`/`substringKysely`, `lower`/`lowerTrim`, `collateNoCase`, `castInteger`/`castIntegerKysely`, and `startsWithIsoDate`/`startsWithIsoDateKysely` instead of `GLOB`. - **JSON columns** are read whole and parsed in the service; no `json_extract` in SQL. - **PRAGMA** belongs to the `src/db/` lifecycle files (`database.ts`, `connection.ts`, `durability.ts`, the migrations). `MaintenanceRepository` is the baselined SQLite maintenance adapter (`wal_checkpoint`, `VACUUM INTO`). -- **A missing helper** goes into `sql-functions.ts` (or `kysely-functions.ts` for a Kysely expression), dispatching on the live platform through `src/db/dialect/platform.ts`: a SQLite branch now, `unsupported(platform)` for the rest until the second engine lands. `src/db/dialect/` is the only place allowed to spell an engine's own SQL. +- **A missing helper** goes into `sql-functions.ts` (or `kysely-functions.ts` for a Kysely expression), dispatching on the live platform through `src/db/dialect/platform.ts`: a SQLite branch and a Postgres branch (one spelling under `isKnownPlatform` where both engines read it alike), `unsupported(platform)` for anything else. The Postgres branch keeps the SQLite storage model, so a clock helper renders the same `YYYY-MM-DD HH:MM:SS` UTC text. Pin the Postgres text in `tests/unit/db/dialect/sql-functions.postgres.test.ts`. `src/db/dialect/` is the only place allowed to spell an engine's own SQL. - **Migrations** that have shipped are frozen as they are. A new migration's data statements follow the rules above; its DDL is written for the engine it runs on. - **Storage model**: the generated Kysely types describe what SQLite stores (0/1 integers for flags, TEXT timestamps, JSON in TEXT). Convert at the service boundary, as the contracts already require. - **MySQL/MariaDB are out of scope.** The repositories rely on `INSERT ... ON CONFLICT` (Kysely's `onConflict`, often a target-less `doNothing()`) and on `RETURNING`, and MySQL has neither: it would need an insert-ignore/upsert wrapper in the dialect layer and a different way to read new ids before it could be considered. diff --git a/server/src/db/dialect/kysely-functions.ts b/server/src/db/dialect/kysely-functions.ts index e2689116c..8b5339e90 100644 --- a/server/src/db/dialect/kysely-functions.ts +++ b/server/src/db/dialect/kysely-functions.ts @@ -1,6 +1,6 @@ import type { Platform } from '@mikro-orm/core'; import { sql, type Expression, type ExpressionBuilder, type ExpressionWrapper, type RawBuilder, type ReferenceExpression, type SqlBool, type StringReference } from 'kysely'; -import { isSqlite, unsupported } from './platform'; +import { isKnownPlatform, isPostgres, isSqlite, PG_ISO_DATE_PREFIX, PG_TIMESTAMP_FORMAT, PG_UTC_NOW, pgTimestampText, unsupported } from './platform'; /** * The Kysely-expression half of the dialect layer: the twins of the MikroORM @@ -32,7 +32,8 @@ import { isSqlite, unsupported } from './platform'; // shape). Platform-dispatched and fail-closed like every helper in // `sql-functions.ts`; each has an SQLF-0xx test pinning its compiled // `{sql, parameters}` AND a raw-statement equivalence on seeded rows -// (`tests/unit/db/dialect/sql-functions.test.ts`). +// (`tests/unit/db/dialect/sql-functions.test.ts`), and its Postgres branch is +// pinned in `sql-functions.postgres.test.ts` and run by the CI Postgres probe. // --------------------------------------------------------------------------- /** @@ -43,7 +44,8 @@ import { isSqlite, unsupported } from './platform'; * entry). Returns a boolean `Expression`, usable in a `.where()` * or inside a `CASE WHEN` condition. Same digit-class pattern as the * MikroORM version — NOT the wider `'????-??-??*'` shorthand (a `?` in - * SQLite GLOB matches any character, letters included). + * SQLite GLOB matches any character, letters included). On Postgres it is + * the anchored regular expression `CAST( AS text) ~ '^[0-9]{4}-…'`. */ export function startsWithIsoDateKysely( platform: Platform, @@ -53,6 +55,7 @@ export function startsWithIsoDateKysely( if (isSqlite(platform)) { return eb.fn('glob', [eb.val('[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'), ref]); } + if (isPostgres(platform)) return eb(eb.cast(ref, 'text'), '~', eb.val(PG_ISO_DATE_PREFIX)); return unsupported(platform); } @@ -63,7 +66,8 @@ export function startsWithIsoDateKysely( * as genuine parameters here (`eb.val`) rather than spelled into the SQL * text — Kysely's `fn()` takes `ReferenceExpression`s, not a raw text * fragment, so there is no equivalent "spell a constant into the SQL" - * shape to match; a bound integer literal renders identically. + * shape to match; a bound integer literal renders identically. Postgres has + * the same `substr` with the same 1-based arguments. */ export function substringKysely( platform: Platform, @@ -78,7 +82,7 @@ export function substringKysely( if (length !== undefined && (!Number.isInteger(length) || length < 0)) { throw new Error(`sql-functions: substringKysely needs a non-negative integer length, got ${length}`); } - if (isSqlite(platform)) { + if (isKnownPlatform(platform)) { const args: ReferenceExpression[] = length === undefined ? [ref, eb.val(start)] : [ref, eb.val(start), eb.val(length)]; @@ -99,7 +103,9 @@ export type KyselyConcatPart = * expression builder's own binary-operator call (`eb(lhs, '||', rhs)`) so * it composes with {@link substringKysely}/{@link castIntegerKysely} — * exactly the composition `concat()`'s MikroORM form cannot do (its - * docstring explains why). + * docstring explains why). On Postgres a bound value part is cast to `text`: + * `pg` sends parameters untyped, and `$1 || $2` alone gives the planner no + * type to resolve the operator with. */ export function concatKysely( platform: Platform, @@ -109,9 +115,10 @@ export function concatKysely( if (parts.length < 2) { throw new Error(`sql-functions: concatKysely needs at least two parts, got ${parts.length}`); } - if (!isSqlite(platform)) return unsupported(platform); + if (!isKnownPlatform(platform)) return unsupported(platform); + const value = (text: string): Expression => (isPostgres(platform) ? eb.cast(eb.val(text), 'text') : eb.val(text)); const operand = (part: KyselyConcatPart): Expression => - 'column' in part ? eb.ref(part.column).$castTo() : 'value' in part ? eb.val(part.value) : part.expression; + 'column' in part ? eb.ref(part.column).$castTo() : 'value' in part ? value(part.value) : part.expression; let acc: Expression = operand(parts[0]!); for (const part of parts.slice(1)) { acc = eb(acc, '||', operand(part)); @@ -128,7 +135,10 @@ export function concatKysely( * INTEGER)`, for `reservations.accommodation_id` (TEXT) compared against an * INTEGER column inside a Kysely statement (RS20/RV2's join shape — a * correlated `EXISTS`/scalar-subquery context the MikroORM QueryBuilder - * cannot express, per the inventory's own T6 ruling). + * cannot express, per the inventory's own T6 ruling). Postgres refuses to + * cast the text `'14.0'` to an integer, so its branch goes through `numeric` + * and truncates, as SQLite's cast does: `CAST(trunc(CAST( AS numeric)) + * AS integer)`. */ export function castIntegerKysely( platform: Platform, @@ -136,6 +146,7 @@ export function castIntegerKysely( ref: ReferenceExpression, ): ExpressionWrapper { if (isSqlite(platform)) return eb.cast(ref, 'integer'); + if (isPostgres(platform)) return eb.cast(eb.fn('trunc', [eb.cast(ref, 'numeric')]), 'integer'); return unsupported(platform); } @@ -169,6 +180,10 @@ export function castIntegerKysely( * rather than "fixed" into a float divide that would render a different ISO * string for any timestamp not an exact multiple of 1000ms. * + * On Postgres: `to_char(timezone('UTC', to_timestamp( / 1000)), + * 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`. An integer column divided by an integer + * truncates there too, so both engines drop the same sub-second part. + * * Named for what it computes, not the SQL function it spells — matching * every other `*Kysely` twin in this file (`castIntegerKysely`, not * `castKysely`; `startsWithIsoDateKysely`, not `globKysely`). @@ -178,11 +193,13 @@ export function unixEpochToIsoKysely( eb: ExpressionBuilder, ref: ReferenceExpression, ): ExpressionWrapper { + if (!isKnownPlatform(platform)) return unsupported(platform); + const seconds = eb(ref, '/', eb.val(1000)); if (isSqlite(platform)) { - const seconds = eb(ref, '/', eb.val(1000)); return eb.fn('strftime', [eb.val('%Y-%m-%dT%H:%M:%SZ'), seconds, eb.val('unixepoch')]); } - return unsupported(platform); + const utc = eb.fn('timezone', [eb.cast(eb.val('UTC'), 'text'), eb.fn('to_timestamp', [seconds])]); + return eb.fn('to_char', [utc, eb.cast(eb.val('YYYY-MM-DD"T"HH24:MI:SS"Z"'), 'text')]); } /** @@ -194,7 +211,9 @@ export function unixEpochToIsoKysely( * `Expression`, not a MikroORM `RawQueryFragment` (SQLF-048: a * `RawQueryFragment` throws when handed into a Kysely statement). Same * validation, same always-non-negative-integer trust boundary as the - * MikroORM form in `sql-functions.ts`. + * MikroORM form in `sql-functions.ts`. On Postgres the validated count is + * inlined as a literal into `make_interval(secs => n)` and the sum rendered + * as SQLite's timestamp text. */ export function nowPlusSecondsKysely( platform: Platform, @@ -207,6 +226,10 @@ export function nowPlusSecondsKysely( if (isSqlite(platform)) { return eb.fn('datetime', [eb.val('now'), eb.val(`+${seconds} seconds`)]); } + if (isPostgres(platform)) { + const later = sql`${sql.raw(PG_UTC_NOW)} + make_interval(secs => ${sql.lit(seconds)})`; + return eb.fn('to_char', [later, eb.cast(eb.val(PG_TIMESTAMP_FORMAT), 'text')]); + } return unsupported(platform); } @@ -226,8 +249,10 @@ export function nowPlusSecondsKysely( * codebase (the MikroORM helpers in `sql-functions.ts` use `raw()` for the * identical reason), never inline in a repository (the TRAP list's "Kysely * `sql` banned under repositories" is about repository FILES, not this one). + * On Postgres it renders the UTC clock as SQLite's timestamp text. */ export function currentTimestampKysely(platform: Platform): RawBuilder { if (isSqlite(platform)) return sql`CURRENT_TIMESTAMP`; + if (isPostgres(platform)) return sql.raw(pgTimestampText(PG_UTC_NOW)); return unsupported(platform); } diff --git a/server/src/db/dialect/platform.ts b/server/src/db/dialect/platform.ts index 4ddc361ce..1c6a9505b 100644 --- a/server/src/db/dialect/platform.ts +++ b/server/src/db/dialect/platform.ts @@ -1,18 +1,64 @@ import type { Platform } from '@mikro-orm/core'; -import { SqlitePlatform } from '@mikro-orm/sql'; +import { BasePostgreSqlPlatform, SqlitePlatform } from '@mikro-orm/sql'; /** * Which engine a dialect helper is spelling for. * - * Every helper in `sql-functions.ts` and `kysely-functions.ts` dispatches on - * the live MikroORM platform (`em.getPlatform()`). Anything the dialect layer - * has no spelling for fails closed through {@link unsupported} rather than - * guessing. + * Every helper in `sql-functions.ts` and `kysely-functions.ts` dispatches + * on the live MikroORM platform (`em.getPlatform()`): SQLite is the engine + * TREK runs on, Postgres is the second one the helpers are written for and + * the CI Postgres probe (`scripts/pg-probe.ts`) runs them against. Anything + * else fails closed through {@link unsupported} rather than guessing. + * + * `BasePostgreSqlPlatform` is the class every MikroORM Postgres platform + * extends (the `@mikro-orm/postgresql` driver's own `PostgreSqlPlatform` + * among them), so the check does not need the driver package at runtime. */ export function isSqlite(platform: Platform): boolean { return platform instanceof SqlitePlatform; } +export function isPostgres(platform: Platform): boolean { + return platform instanceof BasePostgreSqlPlatform; +} + +/** True when one spelling serves every engine the dialect layer knows. */ +export function isKnownPlatform(platform: Platform): boolean { + return isSqlite(platform) || isPostgres(platform); +} + export function unsupported(platform: Platform): never { throw new Error(`sql-functions: no implementation for platform ${platform.constructor.name}`); } + +// Postgres spellings shared by both helper files. The storage model stays the +// one SQLite has (server/CLAUDE.md, "Storage model"): a timestamp is the TEXT +// `YYYY-MM-DD HH:MM:SS` in UTC and a date is `YYYY-MM-DD`, so every Postgres +// branch that reads the clock renders that same text instead of handing back +// a `timestamptz`, and a text column compares against it exactly as on SQLite. + +/** The database clock as UTC wall time, what SQLite's `CURRENT_TIMESTAMP` reads. */ +export const PG_UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"; + +/** `to_char` pattern for SQLite's timestamp text. */ +export const PG_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS'; + +/** `to_char` pattern for SQLite's date text. */ +export const PG_DATE_FORMAT = 'YYYY-MM-DD'; + +/** A timestamp expression rendered as SQLite's timestamp text. */ +export function pgTimestampText(expression: string): string { + return `to_char(${expression}, '${PG_TIMESTAMP_FORMAT}')`; +} + +/** A timestamp expression rendered as SQLite's date text. */ +export function pgDateText(expression: string): string { + return `to_char(${expression}, '${PG_DATE_FORMAT}')`; +} + +/** + * The POSIX regular expression for "starts with an ISO calendar date", the + * same digit classes SQLite's `GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` + * spells, anchored at the start because GLOB always matches the whole value. + */ +export const PG_ISO_DATE_PREFIX = '^[0-9]{4}-[0-9]{2}-[0-9]{2}'; diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index 500a603f2..778b1d45e 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -1,5 +1,5 @@ import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core'; -import { isSqlite, unsupported } from './platform'; +import { isKnownPlatform, isPostgres, isSqlite, PG_ISO_DATE_PREFIX, PG_UTC_NOW, pgDateText, pgTimestampText, unsupported } from './platform'; /** * The only place a repository may spell a database function. @@ -8,11 +8,13 @@ import { isSqlite, unsupported } from './platform'; * running on (`em.getPlatform()`) plus an already-qualified column reference * (`'t.created_at'`), and dispatches on that platform at runtime. TREK ships * one binary and the database is chosen by configuration, so the dialect is a - * runtime property of the live connection, not a build-time swap: when the - * Postgres driver arrives this file grows a second branch and no repository - * changes. An unknown platform fails closed rather than guessing a spelling. - * The Kysely-expression twins live in `kysely-functions.ts`, the platform - * checks in `platform.ts`. + * runtime property of the live connection, not a build-time swap. Every + * helper has a SQLite branch (the engine TREK runs on) and a Postgres branch + * (the second engine; `scripts/pg-probe.ts` runs each one against a real + * Postgres in CI), and a spelling both engines read the same way serves both + * through `isKnownPlatform`. An unknown platform fails closed rather than + * guessing a spelling. The Kysely-expression twins live in + * `kysely-functions.ts`, the platform checks in `platform.ts`. * * Values are never interpolated — the day count in `dateAdd` is checked to be * an integer before it is spelled. @@ -28,6 +30,7 @@ function column(ref: string): string { /** The calendar date (`YYYY-MM-DD`) of a timestamp column. */ export function dateOf(platform: Platform, ref: string): RawQueryFragment { if (isSqlite(platform)) return raw(`date(${column(ref)})`); + if (isPostgres(platform)) return raw(pgDateText(`CAST(${column(ref)} AS timestamp)`)); return unsupported(platform); } @@ -38,12 +41,14 @@ export function dateAdd(platform: Platform, ref: string, days: number): RawQuery const sign = days < 0 ? '-' : '+'; return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`); } + if (isPostgres(platform)) return raw(pgDateText(`CAST(${column(ref)} AS timestamp) + INTERVAL '${days} days'`)); return unsupported(platform); } -/** The database clock, in the same text the column defaults produce. */ +/** The database clock, in the same text the column defaults produce (UTC `YYYY-MM-DD HH:MM:SS`). */ export function currentTimestamp(platform: Platform): RawQueryFragment { if (isSqlite(platform)) return raw('CURRENT_TIMESTAMP'); + if (isPostgres(platform)) return raw(pgTimestampText(PG_UTC_NOW)); return unsupported(platform); } @@ -57,7 +62,7 @@ export function currentTimestamp(platform: Platform): RawQueryFragment { * `no-restricted-syntax` rule for `src/db/repositories/**` can enforce. */ export function columnRef(platform: Platform, ref: string): RawQueryFragment { - if (isSqlite(platform)) return raw(column(ref)); + if (isKnownPlatform(platform)) return raw(column(ref)); return unsupported(platform); } @@ -66,7 +71,7 @@ export function columnIncrementedBy(platform: Platform, ref: string, amount: num if (!Number.isInteger(amount)) { throw new Error(`sql-functions: columnIncrementedBy needs an integer amount, got ${amount}`); } - if (isSqlite(platform)) { + if (isKnownPlatform(platform)) { const sign = amount < 0 ? '-' : '+'; return raw(`${column(ref)} ${sign} ${Math.abs(amount)}`); } @@ -102,9 +107,13 @@ export function columnIncrementedBy(platform: Platform, ref: string, amount: num * fragment is only usable as an object key because `raw()`'s default * generic already brands it with a `[Symbol.toPrimitive]`. The other * helpers here are only ever used as VALUES, where that brand isn't needed. + * + * Postgres spells it the same, but its `LOWER()` folds every letter, not + * only ASCII. Pair it with `lowerParam()` there too and both sides still + * fold through one engine, so a lookup agrees with itself on either engine. */ export function lower(platform: Platform, ref: string): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw(`LOWER(${column(ref)})`); + if (isKnownPlatform(platform)) return raw(`LOWER(${column(ref)})`); return unsupported(platform); } @@ -121,7 +130,7 @@ export function lower(platform: Platform, ref: string): RawQueryFragment & symbo * the mixed-engine bug this helper exists to close. */ export function lowerParam(platform: Platform, value: string): RawQueryFragment { - if (isSqlite(platform)) return raw('LOWER(?)', [value]); + if (isKnownPlatform(platform)) return raw('LOWER(?)', [value]); return unsupported(platform); } @@ -152,6 +161,7 @@ export function nowMinusDays(platform: Platform, days: number): RawQueryFragment throw new Error(`sql-functions: nowMinusDays needs a non-negative integer day count, got ${days}`); } if (isSqlite(platform)) return raw(`datetime('now', '-${days} days')`); + if (isPostgres(platform)) return raw(pgTimestampText(`${PG_UTC_NOW} - INTERVAL '${days} days'`)); return unsupported(platform); } @@ -167,7 +177,7 @@ export function nowMinusDays(platform: Platform, days: number): RawQueryFragment * `lowerTrim()` below for the composed shape. */ export function trim(platform: Platform, ref: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`TRIM(${column(ref)})`); + if (isKnownPlatform(platform)) return raw(`TRIM(${column(ref)})`); return unsupported(platform); } @@ -181,7 +191,7 @@ export function trim(platform: Platform, ref: string): RawQueryFragment { * `TRIM()`. */ export function lowerTrim(platform: Platform, ref: string): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw(`LOWER(TRIM(${column(ref)}))`); + if (isKnownPlatform(platform)) return raw(`LOWER(TRIM(${column(ref)}))`); return unsupported(platform); } @@ -202,7 +212,7 @@ export function lowerTrim(platform: Platform, ref: string): RawQueryFragment & s * are different shapes). */ export function coalesce(platform: Platform, ref: string, fallbackRef: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`COALESCE(${column(ref)}, ${column(fallbackRef)})`); + if (isKnownPlatform(platform)) return raw(`COALESCE(${column(ref)}, ${column(fallbackRef)})`); return unsupported(platform); } @@ -223,7 +233,7 @@ export function coalesce(platform: Platform, ref: string, fallbackRef: string): * behaviour change (the function body is untouched). */ export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw(`COALESCE(${column(ref)}, ?)`, [value]); + if (isKnownPlatform(platform)) return raw(`COALESCE(${column(ref)}, ?)`, [value]); return unsupported(platform); } @@ -247,7 +257,7 @@ export function coalesceParam(platform: Platform, ref: string, value: string | n * ?)` always preferred the non-null existing value). */ export function coalesceOverride(platform: Platform, value: string | number | null, ref: string): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw(`COALESCE(?, ${column(ref)})`, [value]); + if (isKnownPlatform(platform)) return raw(`COALESCE(?, ${column(ref)})`, [value]); return unsupported(platform); } @@ -262,7 +272,7 @@ export function coalesceOverride(platform: Platform, value: string | number | nu * brand only lives on that typing for values meant to be used as a key. */ export function absDifference(platform: Platform, ref: string, value: number): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw(`ABS(${column(ref)} - ?)`, [value]); + if (isKnownPlatform(platform)) return raw(`ABS(${column(ref)} - ?)`, [value]); return unsupported(platform); } @@ -283,19 +293,19 @@ function alias(name: string): string { /** `COUNT(*) as `, for a single-row totals read or a `GROUP BY` count column. */ export function countAll(platform: Platform, aliasName: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`COUNT(*) as ${alias(aliasName)}`); + if (isKnownPlatform(platform)) return raw(`COUNT(*) as ${alias(aliasName)}`); return unsupported(platform); } /** `MIN() as `. */ export function minOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`MIN(${column(ref)}) as ${alias(aliasName)}`); + if (isKnownPlatform(platform)) return raw(`MIN(${column(ref)}) as ${alias(aliasName)}`); return unsupported(platform); } /** `MAX() as `. */ export function maxOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`MAX(${column(ref)}) as ${alias(aliasName)}`); + if (isKnownPlatform(platform)) return raw(`MAX(${column(ref)}) as ${alias(aliasName)}`); return unsupported(platform); } @@ -325,7 +335,7 @@ export function maxOf(platform: Platform, ref: string, aliasName: string): RawQu * non-finite `value` before calling.) */ export function caseWhenEquals(platform: Platform, ref: string, value: number, whenTrue: string, whenFalse: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]); + if (isKnownPlatform(platform)) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]); return unsupported(platform); } @@ -349,7 +359,7 @@ export function caseWhenEquals(platform: Platform, ref: string, value: number, w * `.orderBy({ [countAllRef(platform)]: 'desc' })`. */ export function countAllRef(platform: Platform): RawQueryFragment & symbol { - if (isSqlite(platform)) return raw('COUNT(*)'); + if (isKnownPlatform(platform)) return raw('COUNT(*)'); return unsupported(platform); } @@ -380,14 +390,16 @@ export function countAllRef(platform: Platform): RawQueryFragment & symbol { * fragment (see `coalesce()`'s docstring on why nesting doesn't work). * Kysely has no `GLOB` comparison operator (`operator-node.d.ts`'s * `ComparisonOperator` union omits it — verified against the installed - * 0.29.6 typings, per the inventory's §19 MikroORM/Kysely capability table) - * — this helper's SQLite-only reach is therefore the only portable spelling - * for this shape until a second platform is added. + * 0.29.6 typings, per the inventory's §19 MikroORM/Kysely capability table), + * so this helper is the only portable spelling for this shape. + * Postgres reads it as the anchored regular expression + * `CAST( AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'`. */ export function startsWithIsoDate(platform: Platform, ref: string): RawQueryFragment { if (isSqlite(platform)) { return raw(`${column(ref)} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`); } + if (isPostgres(platform)) return raw(`CAST(${column(ref)} AS text) ~ '${PG_ISO_DATE_PREFIX}'`); return unsupported(platform); } @@ -413,7 +425,7 @@ export function substring(platform: Platform, ref: string, start: number, length if (length !== undefined && (!Number.isInteger(length) || length < 0)) { throw new Error(`sql-functions: substring needs a non-negative integer length, got ${length}`); } - if (isSqlite(platform)) { + if (isKnownPlatform(platform)) { if (length === undefined) return raw(`substr(${column(ref)}, ${start})`); return raw(`substr(${column(ref)}, ${start}, ${length})`); } @@ -459,7 +471,7 @@ export function concat(platform: Platform, ...parts: readonly ConcatPart[]): Raw if (parts.length < 2) { throw new Error(`sql-functions: concat needs at least two parts, got ${parts.length}`); } - if (isSqlite(platform)) { + if (isKnownPlatform(platform)) { const bindings: string[] = []; const sql = parts .map((part) => { @@ -483,7 +495,9 @@ export function concat(platform: Platform, ...parts: readonly ConcatPart[]): Raw * with no FK (§18.1 of the inventory — some rows read back as `"14.0"`): * casting it to INTEGER before comparing against a genuine INTEGER column * (`day_accommodations.id`) applies numeric affinity to BOTH sides, matching - * `"14"` and `"14.0"` alike, the same way the legacy statement does. + * `"14"` and `"14.0"` alike, the same way the legacy statement does. Postgres + * refuses `CAST('14.0' AS integer)`, so its branch casts through `numeric` + * and truncates: `CAST(trunc(CAST( AS numeric)) AS integer)`. * * Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()` * carry: both known legacy call sites compare the cast against ANOTHER @@ -496,6 +510,7 @@ export function concat(platform: Platform, ...parts: readonly ConcatPart[]): Raw */ export function castInteger(platform: Platform, ref: string): RawQueryFragment & symbol { if (isSqlite(platform)) return raw(`CAST(${column(ref)} AS INTEGER)`); + if (isPostgres(platform)) return raw(`CAST(trunc(CAST(${column(ref)} AS numeric)) AS integer)`); return unsupported(platform); } @@ -516,9 +531,14 @@ export function castInteger(platform: Platform, ref: string): RawQueryFragment & * shape exactly (an aliased column cannot be ordered by, the same * `ExtractRawAliases` limitation `countAllRef`'s own docstring / SQLF-024's * comment documents; ordering by the EXPRESSION itself is what works). + * Postgres measures the same distance in days from the epoch difference of + * the two values cast to `timestamp`. */ export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol { if (isSqlite(platform)) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]); + if (isPostgres(platform)) { + return raw(`ABS(EXTRACT(EPOCH FROM (CAST(${column(ref)} AS timestamp) - CAST(? AS timestamp))) / 86400)`, [isoDate]); + } return unsupported(platform); } @@ -561,9 +581,16 @@ export function dayDistance(platform: Platform, ref: string, isoDate: string): R * `COLLATE`-based comparison lets SQLite use an index on the un-wrapped * column in a way `LOWER(col) = ?` cannot — a real, if not row-visible, * difference between the two shapes. + * + * Postgres has no built-in `NOCASE`. Its branch names a collation `nocase` + * that a Postgres schema creates as a nondeterministic ICU collation + * (`CREATE COLLATION nocase (provider = icu, locale = 'und-u-ks-level2', + * deterministic = false)`, which `scripts/pg-probe.ts` does). That one folds + * case for every letter, not only ASCII, and supports `=` but not `LIKE`. */ export function collateNoCase(platform: Platform, ref: string): RawQueryFragment & symbol { if (isSqlite(platform)) return raw(`${column(ref)} COLLATE NOCASE`); + if (isPostgres(platform)) return raw(`${column(ref)} COLLATE "nocase"`); return unsupported(platform); } @@ -582,6 +609,7 @@ export function nowMinusHours(platform: Platform, hours: number): RawQueryFragme throw new Error(`sql-functions: nowMinusHours needs a non-negative integer hour count, got ${hours}`); } if (isSqlite(platform)) return raw(`datetime('now', '-${hours} hours')`); + if (isPostgres(platform)) return raw(pgTimestampText(`${PG_UTC_NOW} - INTERVAL '${hours} hours'`)); return unsupported(platform); } @@ -598,7 +626,7 @@ export function nowMinusHours(platform: Platform, hours: number): RawQueryFragme * rule 18: mixing engines is the bug this pairing exists to avoid). */ export function lowerTrimParam(platform: Platform, value: string): RawQueryFragment { - if (isSqlite(platform)) return raw('LOWER(TRIM(?))', [value]); + if (isKnownPlatform(platform)) return raw('LOWER(TRIM(?))', [value]); return unsupported(platform); } @@ -664,6 +692,7 @@ export function nowDateOffset(platform: Platform, days: number): RawQueryFragmen const sign = days < 0 ? '-' : '+'; return raw(`date('now', '${sign}${Math.abs(days)} days')`); } + if (isPostgres(platform)) return raw(pgDateText(`${PG_UTC_NOW} + INTERVAL '${days} days'`)); return unsupported(platform); } @@ -690,6 +719,7 @@ export function nowPlusSeconds(platform: Platform, seconds: number): RawQueryFra throw new Error(`sql-functions: nowPlusSeconds needs a non-negative integer second count, got ${seconds}`); } if (isSqlite(platform)) return raw(`datetime('now', '+${seconds} seconds')`); + if (isPostgres(platform)) return raw(pgTimestampText(`${PG_UTC_NOW} + INTERVAL '${seconds} seconds'`)); return unsupported(platform); } @@ -720,7 +750,7 @@ export function nowPlusSeconds(platform: Platform, seconds: number): RawQueryFra * part of this fragment — the caller sets it alongside). */ export function foundAgainState(platform: Platform, stateRef: string, fileIdRef: string): RawQueryFragment { - if (isSqlite(platform)) { + if (isKnownPlatform(platform)) { return raw( `CASE WHEN ${column(stateRef)} = 'remote_missing' AND ${column(fileIdRef)} IS NOT NULL THEN 'synced' ELSE ${column(stateRef)} END`, ); @@ -746,12 +776,17 @@ export function foundAgainState(platform: Platform, stateRef: string, fileIdRef: * made for a different key. SET expressions see the row as it was before the * UPDATE, so ` IS ?` compares the STORED key with the new one even * though the same statement overwrites it. `value` is bound twice on purpose: - * the fragment has two value slots and `raw()` binds positionally. + * the fragment has two value slots and `raw()` binds positionally. SQLite's + * null-safe `IS` is `IS NOT DISTINCT FROM` on Postgres. */ export function coalesceOverrideWhileSame(platform: Platform, value: number | null, ref: string, keyRef: string, keyValue: string): RawQueryFragment { if (isSqlite(platform)) { return raw(`CASE WHEN ${column(keyRef)} IS ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`, [keyValue, value, value]); } + if (isPostgres(platform)) { + const sql = `CASE WHEN ${column(keyRef)} IS NOT DISTINCT FROM ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`; + return raw(sql, [keyValue, value, value]); + } return unsupported(platform); } @@ -768,6 +803,6 @@ export function coalesceOverrideWhileSame(platform: Platform, value: number | nu * ELSE is deliberate: a null test column yields SQL NULL. */ export function caseWhenNotNull(platform: Platform, testRef: string, thenRef: string): RawQueryFragment { - if (isSqlite(platform)) return raw(`CASE WHEN ${column(testRef)} IS NOT NULL THEN ${column(thenRef)} END`); + if (isKnownPlatform(platform)) return raw(`CASE WHEN ${column(testRef)} IS NOT NULL THEN ${column(thenRef)} END`); return unsupported(platform); } diff --git a/server/tests/unit/db/dialect/sql-functions.postgres.test.ts b/server/tests/unit/db/dialect/sql-functions.postgres.test.ts new file mode 100644 index 000000000..b4f85de56 --- /dev/null +++ b/server/tests/unit/db/dialect/sql-functions.postgres.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { Platform } from '@mikro-orm/core'; +import { BasePostgreSqlPlatform, SqlitePlatform } from '@mikro-orm/sql'; +import { DummyDriver, expressionBuilder, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely'; +import { + absDifference, + caseWhenEquals, + caseWhenNotNull, + castInteger, + coalesce, + coalesceOverride, + coalesceOverrideWhileSame, + coalesceParam, + collateNoCase, + columnIncrementedBy, + columnRef, + concat, + countAll, + countAllRef, + currentTimestamp, + dateAdd, + dateOf, + dayDistance, + foundAgainState, + lower, + lowerParam, + lowerTrim, + lowerTrimParam, + maxOf, + minOf, + nowDateOffset, + nowMinusDays, + nowMinusHours, + nowPlusSeconds, + startsWithIsoDate, + substring, + trim, +} from '../../../../src/db/dialect/sql-functions'; +import { + castIntegerKysely, + concatKysely, + currentTimestampKysely, + nowPlusSecondsKysely, + startsWithIsoDateKysely, + substringKysely, + unixEpochToIsoKysely, +} from '../../../../src/db/dialect/kysely-functions'; +import { isKnownPlatform, isPostgres, isSqlite, pgDateText, pgTimestampText } from '../../../../src/db/dialect/platform'; + +/** + * The Postgres branches of the dialect layer, pinned as compiled text. Nothing + * here talks to a Postgres server: the CI `postgres-probe` job + * (`scripts/pg-probe.ts`) runs every one of these spellings against a real + * one and compares the values with what SQLite returns. This file holds the + * text, so a change to a spelling is a visible diff in review. + */ + +const pg = new BasePostgreSqlPlatform(); +const NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"; + +interface ProbeDB { + users: { id: number; username: string; display_name: string | null; created_at: number }; +} + +const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (kysely) => new PostgresIntrospector(kysely), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, +}); + +describe('dialect platform checks', () => { + it('SQLFPG-001: tells SQLite, Postgres and anything else apart', () => { + class FakePlatform extends Platform {} + expect(isSqlite(new SqlitePlatform())).toBe(true); + expect(isPostgres(new SqlitePlatform())).toBe(false); + expect(isPostgres(pg)).toBe(true); + expect(isSqlite(pg)).toBe(false); + expect(isKnownPlatform(pg)).toBe(true); + expect(isKnownPlatform(new SqlitePlatform())).toBe(true); + expect(isKnownPlatform(new FakePlatform())).toBe(false); + }); + + it('SQLFPG-002: renders a timestamp expression as SQLite\'s timestamp and date text', () => { + expect(pgTimestampText('x')).toBe("to_char(x, 'YYYY-MM-DD HH24:MI:SS')"); + expect(pgDateText('x')).toBe("to_char(x, 'YYYY-MM-DD')"); + }); +}); + +describe('sql-functions (postgres)', () => { + it('SQLFPG-010: the clock helpers render SQLite\'s UTC text', () => { + expect(currentTimestamp(pg).sql).toBe(`to_char(${NOW}, 'YYYY-MM-DD HH24:MI:SS')`); + expect(nowMinusDays(pg, 30).sql).toBe(`to_char(${NOW} - INTERVAL '30 days', 'YYYY-MM-DD HH24:MI:SS')`); + expect(nowMinusHours(pg, 20).sql).toBe(`to_char(${NOW} - INTERVAL '20 hours', 'YYYY-MM-DD HH24:MI:SS')`); + expect(nowPlusSeconds(pg, 90).sql).toBe(`to_char(${NOW} + INTERVAL '90 seconds', 'YYYY-MM-DD HH24:MI:SS')`); + expect(nowDateOffset(pg, -400).sql).toBe(`to_char(${NOW} + INTERVAL '-400 days', 'YYYY-MM-DD')`); + expect(nowDateOffset(pg, 1).sql).toBe(`to_char(${NOW} + INTERVAL '1 days', 'YYYY-MM-DD')`); + }); + + it('SQLFPG-011: the column date helpers cast the stored text to a timestamp', () => { + expect(dateOf(pg, 'u.created_at').sql).toBe("to_char(CAST(u.created_at AS timestamp), 'YYYY-MM-DD')"); + expect(dateAdd(pg, 'u.created_at', 10).sql).toBe("to_char(CAST(u.created_at AS timestamp) + INTERVAL '10 days', 'YYYY-MM-DD')"); + expect(dateAdd(pg, 'u.created_at', -3).sql).toBe("to_char(CAST(u.created_at AS timestamp) + INTERVAL '-3 days', 'YYYY-MM-DD')"); + const distance = dayDistance(pg, 'date', '2026-01-01'); + expect(distance.sql).toBe('ABS(EXTRACT(EPOCH FROM (CAST(date AS timestamp) - CAST(? AS timestamp))) / 86400)'); + expect(distance.params).toEqual(['2026-01-01']); + }); + + it('SQLFPG-012: the validation guards hold on Postgres as they do on SQLite', () => { + expect(() => dateOf(pg, 'u.created_at); DROP TABLE users; --')).toThrow(/not a column reference/); + expect(() => dateAdd(pg, 'u.created_at', 1.5)).toThrow(/integer day count/); + expect(() => nowMinusDays(pg, -1)).toThrow(/non-negative integer day count/); + expect(() => nowMinusHours(pg, 0.5)).toThrow(/non-negative integer hour count/); + expect(() => nowPlusSeconds(pg, -1)).toThrow(/non-negative integer second count/); + expect(() => nowDateOffset(pg, 0.5)).toThrow(/integer day count/); + expect(() => substring(pg, 'r.t', 0)).toThrow(/1-based integer start/); + expect(() => concat(pg, { column: 'a.b' })).toThrow(/at least two parts/); + }); + + it('SQLFPG-013: the spellings both engines read alike stay byte-identical', () => { + expect(columnRef(pg, 'u.max_uses').sql).toBe('u.max_uses'); + expect(columnIncrementedBy(pg, 'u.used_count', -2).sql).toBe('u.used_count - 2'); + expect(columnIncrementedBy(pg, 'u.used_count', 1).sql).toBe('u.used_count + 1'); + expect(lower(pg, 'u.email').sql).toBe('LOWER(u.email)'); + expect(lowerParam(pg, 'JOSÉ@x.com').params).toEqual(['JOSÉ@x.com']); + expect(lowerParam(pg, 'x').sql).toBe('LOWER(?)'); + expect(trim(pg, 'p.name').sql).toBe('TRIM(p.name)'); + expect(lowerTrim(pg, 'p.name').sql).toBe('LOWER(TRIM(p.name))'); + expect(lowerTrimParam(pg, ' X ').sql).toBe('LOWER(TRIM(?))'); + expect(coalesce(pg, 'a.x', 'a.y').sql).toBe('COALESCE(a.x, a.y)'); + expect(coalesceParam(pg, 'a.x', 3).sql).toBe('COALESCE(a.x, ?)'); + expect(coalesceOverride(pg, 'v', 'a.x').sql).toBe('COALESCE(?, a.x)'); + expect(absDifference(pg, 'p.lat', 1.5).sql).toBe('ABS(p.lat - ?)'); + expect(countAll(pg, 'total').sql).toBe('COUNT(*) as total'); + expect(minOf(pg, 'p.at', 'first').sql).toBe('MIN(p.at) as first'); + expect(maxOf(pg, 'p.at', 'last').sql).toBe('MAX(p.at) as last'); + expect(countAllRef(pg).sql).toBe('COUNT(*)'); + expect(caseWhenEquals(pg, 'u.id', 1, 'owner', 'member').sql).toBe('CASE WHEN u.id = ? THEN ? ELSE ? END'); + expect(caseWhenNotNull(pg, 'p.tour_id', 'p.route').sql).toBe('CASE WHEN p.tour_id IS NOT NULL THEN p.route END'); + expect(foundAgainState(pg, 's.state', 's.file_id').sql).toBe( + "CASE WHEN s.state = 'remote_missing' AND s.file_id IS NOT NULL THEN 'synced' ELSE s.state END", + ); + expect(substring(pg, 'r.t', 1, 10).sql).toBe('substr(r.t, 1, 10)'); + expect(substring(pg, 'r.t', 12).sql).toBe('substr(r.t, 12)'); + const joined = concat(pg, { column: 'd.date' }, { value: 'T' }, { column: 'a.check_in' }); + expect(joined.sql).toBe('d.date || ? || a.check_in'); + expect(joined.params).toEqual(['T']); + }); + + it('SQLFPG-014: GLOB, the integer cast, NOCASE and the null-safe IS get their Postgres forms', () => { + expect(startsWithIsoDate(pg, 'r.reservation_time').sql).toBe("CAST(r.reservation_time AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'"); + expect(castInteger(pg, 'vr.accommodation_id').sql).toBe('CAST(trunc(CAST(vr.accommodation_id AS numeric)) AS integer)'); + expect(collateNoCase(pg, 'name').sql).toBe('name COLLATE "nocase"'); + const whileSame = coalesceOverrideWhileSame(pg, null, 'u.tls', 'u.url', 'https://a'); + expect(whileSame.sql).toBe('CASE WHEN u.url IS NOT DISTINCT FROM ? THEN COALESCE(?, u.tls) ELSE COALESCE(?, 0) END'); + expect(whileSame.params).toEqual(['https://a', null, null]); + }); +}); + +describe('kysely-functions (postgres)', () => { + it('SQLFPG-020: startsWithIsoDateKysely is an anchored regular expression over the text', () => { + const compiled = db.selectFrom('users').select((eb) => startsWithIsoDateKysely(pg, eb, 'username').as('v')).compile(); + expect(compiled.sql).toBe('select cast("username" as text) ~ $1 as "v" from "users"'); + expect(compiled.parameters).toEqual(['^[0-9]{4}-[0-9]{2}-[0-9]{2}']); + }); + + it('SQLFPG-021: substringKysely and concatKysely compose, a bound part cast to text', () => { + const compiled = db + .selectFrom('users') + .select((eb) => + concatKysely(pg, eb, { column: 'username' }, { value: 'T' }, { expression: substringKysely(pg, eb, 'username', 12) }).as('v'), + ) + .compile(); + expect(compiled.sql).toBe('select "username" || cast($1 as text) || substr("username", $2) as "v" from "users"'); + expect(compiled.parameters).toEqual(['T', 12]); + + const sliced = db.selectFrom('users').select((eb) => substringKysely(pg, eb, 'username', 1, 10).as('v')).compile(); + expect(sliced.sql).toBe('select substr("username", $1, $2) as "v" from "users"'); + expect(sliced.parameters).toEqual([1, 10]); + }); + + it('SQLFPG-022: castIntegerKysely truncates through numeric', () => { + const compiled = db.selectFrom('users').select((eb) => castIntegerKysely(pg, eb, 'display_name').as('v')).compile(); + expect(compiled.sql).toBe('select cast(trunc(cast("display_name" as numeric)) as integer) as "v" from "users"'); + expect(compiled.parameters).toEqual([]); + }); + + it('SQLFPG-023: unixEpochToIsoKysely renders UTC ISO text from epoch milliseconds', () => { + const compiled = db.selectFrom('users').select((eb) => unixEpochToIsoKysely(pg, eb, 'created_at').as('v')).compile(); + expect(compiled.sql).toBe( + 'select to_char(timezone(cast($1 as text), to_timestamp("created_at" / $2)), cast($3 as text)) as "v" from "users"', + ); + expect(compiled.parameters).toEqual(['UTC', 1000, 'YYYY-MM-DD"T"HH24:MI:SS"Z"']); + }); + + it('SQLFPG-024: the Kysely clock helpers render SQLite\'s timestamp text', () => { + const later = db.selectFrom('users').select((eb) => nowPlusSecondsKysely(pg, eb, 45).as('v')).compile(); + expect(later.sql).toBe(`select to_char(${NOW} + make_interval(secs => 45), cast($1 as text)) as "v" from "users"`); + expect(later.parameters).toEqual(['YYYY-MM-DD HH24:MI:SS']); + + const now = db.selectFrom('users').select(() => currentTimestampKysely(pg).as('v')).compile(); + expect(now.sql).toBe(`select to_char(${NOW}, 'YYYY-MM-DD HH24:MI:SS') as "v" from "users"`); + expect(now.parameters).toEqual([]); + }); + + it('SQLFPG-025: an unknown platform still fails closed in the Kysely twins', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + const eb = expressionBuilder(); + expect(() => startsWithIsoDateKysely(foreign, eb, 'username')).toThrow(/no implementation for platform FakePlatform/); + expect(() => substringKysely(foreign, eb, 'username', 1)).toThrow(/no implementation for platform FakePlatform/); + expect(() => concatKysely(foreign, eb, { value: 'a' }, { value: 'b' })).toThrow(/no implementation for platform FakePlatform/); + expect(() => castIntegerKysely(foreign, eb, 'display_name')).toThrow(/no implementation for platform FakePlatform/); + expect(() => unixEpochToIsoKysely(foreign, eb, 'created_at')).toThrow(/no implementation for platform FakePlatform/); + expect(() => nowPlusSecondsKysely(foreign, eb, 1)).toThrow(/no implementation for platform FakePlatform/); + expect(() => currentTimestampKysely(foreign)).toThrow(/no implementation for platform FakePlatform/); + }); +});