import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core'; import { SqlitePlatform } from '@mikro-orm/sql'; import { sql, type Expression } from 'kysely'; /** * The only place a repository may spell a database function. * * Every helper takes the MikroORM `Platform` the caller's EntityManager is * 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. * * 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; } function unsupported(platform: Platform): never { throw new Error(`sql-functions: no implementation for platform ${platform.constructor.name}`); } /** The calendar date (`YYYY-MM-DD`) of a timestamp column. */ export function dateOf(platform: Platform, ref: string): RawQueryFragment { if (platform instanceof SqlitePlatform) return raw(`date(${column(ref)})`); return unsupported(platform); } /** The timestamp column shifted by whole days, as a calendar date. */ export function dateAdd(platform: Platform, ref: string, days: number): RawQueryFragment { if (!Number.isInteger(days)) throw new Error(`sql-functions: dateAdd needs an integer day count, got ${days}`); if (platform instanceof SqlitePlatform) { const sign = days < 0 ? '-' : '+'; return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`); } return unsupported(platform); } /** The database clock, in the same text the column defaults produce. */ export function currentTimestamp(platform: Platform): RawQueryFragment { if (platform instanceof SqlitePlatform) return raw('CURRENT_TIMESTAMP'); return unsupported(platform); } /** * A bare reference to another column of the same row, for a filter or update * value that must compare against or combine with a sibling column instead * of a bound parameter (e.g. `used_count < max_uses` — `InviteTokensRepository * .incrementUsedCount`'s capacity guard). Portable across dialects on its own, * but still routed through this file (not spelled with `raw()` in a * repository) so every escape hatch stays in one place ESLint's * `no-restricted-syntax` rule for `src/db/repositories/**` can enforce. */ export function columnRef(platform: Platform, ref: string): RawQueryFragment { if (platform instanceof SqlitePlatform) return raw(column(ref)); return unsupported(platform); } /** A column shifted by a signed integer amount: ` + n` / ` - n`. */ export function columnIncrementedBy(platform: Platform, ref: string, amount: number): RawQueryFragment { if (!Number.isInteger(amount)) { throw new Error(`sql-functions: columnIncrementedBy needs an integer amount, got ${amount}`); } if (platform instanceof SqlitePlatform) { const sign = amount < 0 ? '-' : '+'; return raw(`${column(ref)} ${sign} ${Math.abs(amount)}`); } return unsupported(platform); } /** * A case-insensitive column reference, for use as a filter's object key: * `{ [lower(platform, 'email')]: value.toLowerCase() }` produces * `WHERE LOWER(email) = ?`. This is the parity-preserving equivalent of the * legacy `LOWER(col) = LOWER(?)` sites (`AuthService`/`OidcService`/ * `UserProfileService`'s username/email CI lookups): the caller lowercases * the bound value itself (every one of this migration's call sites already * does, via `String.prototype.toLowerCase`), so the comparison is between * two already-lowercased values either way — SQLite's `LOWER()` is ASCII-only * exactly like `toLowerCase()` is for the identifier characters these * columns hold (usernames/emails), so the two spellings agree on every * input this codebase stores. * * Typed `RawQueryFragment & symbol` (not the bare `RawQueryFragment` the * other helpers in this file return): TypeScript only accepts a * `string | number | symbol` as a computed property name, and a raw * 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. */ export function lower(platform: Platform, ref: string): RawQueryFragment & symbol { if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`); return unsupported(platform); } /** * `CURRENT_TIMESTAMP`, for a query built through `em.getKysely()` — the * escape hatch for a recursive CTE (`OauthTokensRepository.collectChainIds`) * or a join the QueryBuilder's relation metadata cannot express correctly * (`OauthTokensRepository.listActiveByUser`/`listAllActiveWithClientAndUser` * — `oauth_tokens.client_id` references `oauth_clients.client_id`, not its * primary key, and the generated entity's `client` relation has no * `referencedColumnNames` override, so a QueryBuilder `.innerJoin('ot.client', * …)` silently joins on the wrong column; see those methods' docstrings). * Kysely has its own expression system, entirely separate from MikroORM's * `raw()` — `currentTimestamp` above returns a value this file's OTHER * consumers (the QueryBuilder, `nativeUpdate`) accept, but a Kysely query * cannot use a `RawQueryFragment` as an operand. This is the one place a * repository may reach for the Kysely spelling of the same literal, kept * here rather than in a repository file so `no-restricted-syntax`'s ban on * spelling raw SQL directly in `src/db/repositories/**` still holds — this * file is exempt from that rule by design (it IS the one approved place). */ export function currentTimestampKysely(platform: Platform): Expression { if (platform instanceof SqlitePlatform) return sql`CURRENT_TIMESTAMP`; return unsupported(platform); }