feat(db): give every dialect helper a Postgres branch

The dialect layer promised that a second engine would be a branch in
src/db/dialect/ and no sweep through the repositories, but every helper
only knew SQLite and threw for anything else. Each one now has a Postgres
branch, and the helpers whose text both engines read alike (COALESCE,
LOWER, TRIM, substr, ||, the aggregates, the CASE shapes) share one
spelling behind isKnownPlatform.

The Postgres branches keep the storage model SQLite has: the clock helpers
render UTC as the same YYYY-MM-DD HH:MM:SS text (to_char over CURRENT_TIMESTAMP
AT TIME ZONE 'UTC'), GLOB becomes an anchored regular expression, the integer
cast truncates through numeric so '14.0' still reads as 14, JULIANDAY
distance comes from the epoch difference, SQLite's null-safe IS becomes IS
NOT DISTINCT FROM, and NOCASE names a nondeterministic ICU collation a
Postgres schema creates. Bound values on the Kysely side are cast to text
where pg would otherwise send an untyped parameter the planner cannot
resolve.

The SQLite branches are untouched, so nothing changes for a running
install. The Postgres text is pinned in sql-functions.postgres.test.ts.
This commit is contained in:
Maurice
2026-10-08 16:16:37 +02:00
parent 53d9ec5678
commit 4fa2f3dc8b
5 changed files with 376 additions and 50 deletions
+37 -12
View File
@@ -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<SqlBool>`, 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(<ref> AS text) ~ '^[0-9]{4}-…'`.
*/
export function startsWithIsoDateKysely<DB, TB extends keyof DB>(
platform: Platform,
@@ -53,6 +55,7 @@ export function startsWithIsoDateKysely<DB, TB extends keyof DB>(
if (isSqlite(platform)) {
return eb.fn<SqlBool>('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<string>(ref, 'text'), '~', eb.val(PG_ISO_DATE_PREFIX));
return unsupported(platform);
}
@@ -63,7 +66,8 @@ export function startsWithIsoDateKysely<DB, TB extends keyof DB>(
* 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<DB, TB extends keyof DB>(
platform: Platform,
@@ -78,7 +82,7 @@ export function substringKysely<DB, TB extends keyof DB>(
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<DB, TB>[] = length === undefined
? [ref, eb.val(start)]
: [ref, eb.val(start), eb.val(length)];
@@ -99,7 +103,9 @@ export type KyselyConcatPart<DB, TB extends keyof DB> =
* 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<DB, TB extends keyof DB>(
platform: Platform,
@@ -109,9 +115,10 @@ export function concatKysely<DB, TB extends keyof DB>(
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<string> => (isPostgres(platform) ? eb.cast<string>(eb.val(text), 'text') : eb.val(text));
const operand = (part: KyselyConcatPart<DB, TB>): Expression<string> =>
'column' in part ? eb.ref(part.column).$castTo<string>() : 'value' in part ? eb.val(part.value) : part.expression;
'column' in part ? eb.ref(part.column).$castTo<string>() : 'value' in part ? value(part.value) : part.expression;
let acc: Expression<string> = operand(parts[0]!);
for (const part of parts.slice(1)) {
acc = eb(acc, '||', operand(part));
@@ -128,7 +135,10 @@ export function concatKysely<DB, TB extends keyof DB>(
* 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(<ref> AS numeric))
* AS integer)`.
*/
export function castIntegerKysely<DB, TB extends keyof DB>(
platform: Platform,
@@ -136,6 +146,7 @@ export function castIntegerKysely<DB, TB extends keyof DB>(
ref: ReferenceExpression<DB, TB>,
): ExpressionWrapper<DB, TB, number> {
if (isSqlite(platform)) return eb.cast<number>(ref, 'integer');
if (isPostgres(platform)) return eb.cast<number>(eb.fn('trunc', [eb.cast(ref, 'numeric')]), 'integer');
return unsupported(platform);
}
@@ -169,6 +180,10 @@ export function castIntegerKysely<DB, TB extends keyof DB>(
* 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(<ref> / 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<DB, TB extends keyof DB>(
eb: ExpressionBuilder<DB, TB>,
ref: ReferenceExpression<DB, TB>,
): ExpressionWrapper<DB, TB, string> {
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<string>('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<string>('to_char', [utc, eb.cast(eb.val('YYYY-MM-DD"T"HH24:MI:SS"Z"'), 'text')]);
}
/**
@@ -194,7 +211,9 @@ export function unixEpochToIsoKysely<DB, TB extends keyof DB>(
* `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<DB, TB extends keyof DB>(
platform: Platform,
@@ -207,6 +226,10 @@ export function nowPlusSecondsKysely<DB, TB extends keyof DB>(
if (isSqlite(platform)) {
return eb.fn<string>('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<string>('to_char', [later, eb.cast(eb.val(PG_TIMESTAMP_FORMAT), 'text')]);
}
return unsupported(platform);
}
@@ -226,8 +249,10 @@ export function nowPlusSecondsKysely<DB, TB extends keyof DB>(
* 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<string> {
if (isSqlite(platform)) return sql<string>`CURRENT_TIMESTAMP`;
if (isPostgres(platform)) return sql.raw<string>(pgTimestampText(PG_UTC_NOW));
return unsupported(platform);
}
+51 -5
View File
@@ -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}';
+67 -32
View File
@@ -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 <alias>`, 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(<col>) as <alias>`. */
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(<col>) as <alias>`. */
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(<col> 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(<col> 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 `<keyRef> 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);
}