mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
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';
|
|
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 { Platform } from '@mikro-orm/core';
|
|
import { BasePostgreSqlPlatform, SqlitePlatform } from '@mikro-orm/sql';
|
|
|
|
import {
|
|
DummyDriver,
|
|
expressionBuilder,
|
|
Kysely,
|
|
PostgresAdapter,
|
|
PostgresIntrospector,
|
|
PostgresQueryCompiler,
|
|
} from 'kysely';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* 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/);
|
|
});
|
|
});
|