From 5691f966af6b996b7aeede8bf233d8a73ef9b203 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 21 Sep 2026 09:19:47 +0200 Subject: [PATCH] feat(db): dialect helper is the one place SQL functions are spelled --- server/src/db/dialect/sql-functions.ts | 35 +++++++++++++++ .../unit/db/dialect/sql-functions.test.ts | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 server/src/db/dialect/sql-functions.ts create mode 100644 server/tests/unit/db/dialect/sql-functions.test.ts diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts new file mode 100644 index 000000000..f3bf0b5c1 --- /dev/null +++ b/server/src/db/dialect/sql-functions.ts @@ -0,0 +1,35 @@ +import { raw, type RawQueryFragment } from '@mikro-orm/core'; + +/** + * The only place a repository may spell a database function. + * + * Every helper takes an already-qualified column reference (`'t.created_at'`) + * and returns a fragment for the *current* dialect. The ORM exists so a + * Postgres driver can follow; when it does, this file gets a second + * implementation and no repository changes. Values are never interpolated — + * the day count in `dateAdd` is checked to be an integer before it is spelled. + */ + +function column(ref: string): string { + if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i.test(ref)) { + throw new Error(`sql-functions: not a column reference: ${ref}`); + } + return ref; +} + +/** The calendar date (`YYYY-MM-DD`) of a timestamp column. */ +export function dateOf(ref: string): RawQueryFragment { + return raw(`date(${column(ref)})`); +} + +/** The timestamp column shifted by whole days, as a calendar date. */ +export function dateAdd(ref: string, days: number): RawQueryFragment { + if (!Number.isInteger(days)) throw new Error(`sql-functions: dateAdd needs an integer day count, got ${days}`); + const sign = days < 0 ? '-' : '+'; + return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`); +} + +/** The database clock, in the same text the column defaults produce. */ +export function currentTimestamp(): RawQueryFragment { + return raw('CURRENT_TIMESTAMP'); +} diff --git a/server/tests/unit/db/dialect/sql-functions.test.ts b/server/tests/unit/db/dialect/sql-functions.test.ts new file mode 100644 index 000000000..6899c1aaa --- /dev/null +++ b/server/tests/unit/db/dialect/sql-functions.test.ts @@ -0,0 +1,44 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createTestDb, resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createUser } from '../../../helpers/factories'; +import { Users } from '../../../../src/db/entities/Users.entity'; +import { currentTimestamp, dateAdd, dateOf } from '../../../../src/db/dialect/sql-functions'; + +const testDb = createTestDb(); +let t: TestOrm; + +beforeAll(async () => { t = await createTestOrm(testDb); }); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +describe('sql-functions (sqlite)', () => { + it('SQLF-001: dateOf yields the calendar date of a stored timestamp', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id); + const row = await t.em.createQueryBuilder(Users, 'u') + .select([dateOf('u.created_at').as('d')]) + .where({ id: user.id }) + .execute('get', false); + expect(row).toEqual({ d: '2026-09-21' }); + }); + + it('SQLF-002: dateAdd shifts by whole days', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id); + const row = await t.em.createQueryBuilder(Users, 'u') + .select([dateAdd('u.created_at', 10).as('d')]) + .where({ id: user.id }) + .execute('get', false); + expect(row).toEqual({ d: '2026-10-01' }); + }); + + it('SQLF-003: currentTimestamp is the DB clock in the wire format', async () => { + createUser(testDb); + const row = await t.em.createQueryBuilder(Users, 'u') + .select([currentTimestamp().as('now')]) + .limit(1) + .execute('get', false); + expect(String((row as { now: string }).now)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); +});