mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ProbeDB>({
|
||||
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<ProbeDB, 'users'>();
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user