Files
TREK/server/src/db/dialect/sql-functions.ts
T
jubnl d82395d0ae refactor(oauth): OAuth server tables through repositories
OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.
2026-09-22 21:18:08 +02:00

126 lines
6.1 KiB
TypeScript

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: `<col> + n` / `<col> - 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<string> {
if (platform instanceof SqlitePlatform) return sql<string>`CURRENT_TIMESTAMP`;
return unsupported(platform);
}