mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets
This commit is contained in:
@@ -212,8 +212,19 @@ export function coalesce(platform: Platform, ref: string, fallbackRef: string):
|
||||
* `COALESCE(<col>, ?)` — the value-side twin of `coalesce()` above, for the
|
||||
* `COALESCE(col, ?)` UPDATE-SET shape (`places.service.ts:344,351,363,…`).
|
||||
* `value` is bound as a genuine parameter, never interpolated.
|
||||
*
|
||||
* Typed `RawQueryFragment & symbol` (widened from a bare `RawQueryFragment`
|
||||
* in Plan 3d Task 0, R3 — `_shared/reservation-visibility.ts`'s
|
||||
* `publicReservationCondition` needs `COALESCE(alias.ingest_state, 'live')`
|
||||
* as a `.where({...})` filter KEY, `{ [coalesceParam(...)]: { $ne: 'staged'
|
||||
* } }`, the same brand `lower()`/`lowerTrim()`/`castInteger()` carry for
|
||||
* that use): every existing UPDATE-SET caller (`Places.repository.ts`'s six
|
||||
* `coalesceParam(...)` assignments into a loosely-typed `data` object) keeps
|
||||
* typechecking unchanged — `RawQueryFragment & symbol` is assignable
|
||||
* anywhere a plain `RawQueryFragment` is expected, a strict widening with no
|
||||
* behaviour change (the function body is untouched).
|
||||
*/
|
||||
export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment {
|
||||
export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ?)`, [value]);
|
||||
return unsupported(platform);
|
||||
}
|
||||
@@ -320,3 +331,172 @@ export function countAllRef(platform: Platform): RawQueryFragment & symbol {
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3d Task 0 (R6) — no consumer yet: Tasks 2/4 wire these in as each
|
||||
// converts the statement that needs them (`reservations.service.ts` RS11 and
|
||||
// RS20 `listUpcoming`, `reservation-visibility.ts` RV2, DY23 in
|
||||
// `days.service.ts`). Added here, with their own SQLF-0xx tests pinning the
|
||||
// SQLite text against a raw statement on seeded rows, so Tasks 1-6 import a
|
||||
// tested helper instead of writing one inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `<col> GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` — "does this
|
||||
* column's TEXT value start with an ISO calendar date (`YYYY-MM-DD`)?", the
|
||||
* legacy `DATED` constant (`reservations.service.ts:120`:
|
||||
* `` `r.reservation_time GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` ``,
|
||||
* used at `:453-454` inside RS20's two `CASE WHEN` arms). Spelled with
|
||||
* SQLite's `[0-9]` character-class glob syntax, NOT the plan's shorthand
|
||||
* `'????-??-??*'` — verified directly against the legacy source, not
|
||||
* assumed: a `?` in SQLite GLOB matches any single character (letters
|
||||
* included), so `????-??-??*` is a strictly WIDER, non-equivalent pattern
|
||||
* (it would also match `abcd-ef-gh...`); only the digit-class form matches
|
||||
* the legacy's actual behaviour byte-for-byte.
|
||||
*
|
||||
* A full boolean expression, not a bare value — pair with a `CASE WHEN`
|
||||
* builder or a QB/Kysely raw condition, never nest inside another raw
|
||||
* 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.
|
||||
*/
|
||||
export function startsWithIsoDate(platform: Platform, ref: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
return raw(`${column(ref)} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `substr(<col>, <start>[, <length>])`, 1-based on both ends — the legacy
|
||||
* shape at `reservations.service.ts:453` (`substr(r.reservation_time, 1,
|
||||
* 10)`, three-arg), `:454` (`substr(r.reservation_time, 12)`, two-arg — "from
|
||||
* position 12 to the end of the string") and `days.service.ts` DY23
|
||||
* (`SUBSTR(reservation_time, 11)`, same two-arg shape, spelled uppercase in
|
||||
* that statement — SQL function names are case-insensitive, so this helper's
|
||||
* lowercase spelling is behaviourally identical). `length` is optional,
|
||||
* matching SQLite's own two-arg `substr(X, Y)` form (everything from `Y` to
|
||||
* the end) — passing it renders the three-arg form instead. `start` and
|
||||
* `length` are validated integers and spelled directly (not bound): they are
|
||||
* always source-code constants at every known call site (`1`, `10`, `11`,
|
||||
* `12`), never user input, the same trust boundary `dateAdd`'s day count and
|
||||
* `columnIncrementedBy`'s amount already rely on in this file.
|
||||
*/
|
||||
export function substring(platform: Platform, ref: string, start: number, length?: number): RawQueryFragment {
|
||||
if (!Number.isInteger(start) || start < 1) {
|
||||
throw new Error(`sql-functions: substring needs a 1-based integer start, got ${start}`);
|
||||
}
|
||||
if (length !== undefined && (!Number.isInteger(length) || length < 0)) {
|
||||
throw new Error(`sql-functions: substring needs a non-negative integer length, got ${length}`);
|
||||
}
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
if (length === undefined) return raw(`substr(${column(ref)}, ${start})`);
|
||||
return raw(`substr(${column(ref)}, ${start}, ${length})`);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part of a `concat()` expression: either a column reference (rendered
|
||||
* through `column()`, the same identifier validation every other helper in
|
||||
* this file applies) or a plain value bound as a genuine parameter (`?`) —
|
||||
* never interpolated, even for what today's only legacy caller spells as a
|
||||
* literal (`'T'` in `d.date || 'T' || a.check_in`,
|
||||
* `reservations.service.ts:475, :493`): a bound constant and an interpolated
|
||||
* one produce byte-identical SQLite behaviour for a plain string, and
|
||||
* binding is the shape every other value-side helper in this file already
|
||||
* prefers (`coalesceParam`, `lowerParam`, `absDifference`).
|
||||
*/
|
||||
export type ConcatPart = { column: string } | { value: string };
|
||||
|
||||
/**
|
||||
* `<part> || <part> || …` — SQLite (and Postgres) string concatenation, the
|
||||
* legacy shape at `reservations.service.ts:475, :493`
|
||||
* (`d.date || 'T' || a.check_in`, RS20's two check-in/check-out arms) and
|
||||
* `days.service.ts` DY23 (`:date || SUBSTR(reservation_time, 11)`, inside an
|
||||
* `UPDATE … SET`). Needs at least two parts — a one-part "concatenation" is
|
||||
* a caller bug, not a legitimate call. Each part is a `ConcatPart` (see its
|
||||
* own docstring) rather than a bare string, so a column reference and a
|
||||
* bound literal can never be confused with each other the way an untyped
|
||||
* `...parts: string[]` would invite (a plain string could mean either).
|
||||
*
|
||||
* Deliberately does NOT accept a nested `RawQueryFragment` as a part (e.g.
|
||||
* the output of `substring()` above) — the same limitation `coalesce()`'s
|
||||
* docstring documents: `RawQueryFragment`'s `[Symbol.toPrimitive]` only
|
||||
* implements the `'string'` coercion hint (the object-key position), not the
|
||||
* `'default'` hint a template-literal embed or an array-join would need, so
|
||||
* composing `concat(platform, substring(...), ...)` throws at the `raw()`
|
||||
* boundary rather than rendering silently wrong SQL. A caller that needs
|
||||
* `SUBSTR(...)` as one side of a `||` builds that one statement directly
|
||||
* (Kysely's `'||'` binary operator, per the inventory's §17a/§19), the same
|
||||
* way RS20's own CASE/CTE shape is built outside this file's helpers.
|
||||
*/
|
||||
export function concat(platform: Platform, ...parts: readonly ConcatPart[]): RawQueryFragment {
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`sql-functions: concat needs at least two parts, got ${parts.length}`);
|
||||
}
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
const bindings: string[] = [];
|
||||
const sql = parts
|
||||
.map((part) => {
|
||||
if ('column' in part) return column(part.column);
|
||||
bindings.push(part.value);
|
||||
return '?';
|
||||
})
|
||||
.join(' || ');
|
||||
return raw(sql, bindings);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `CAST(<col> AS INTEGER)` — the legacy shape at `reservations.service.ts:471,
|
||||
* :489` (inside RS20's correlated `stay_first_reservation_id`/title
|
||||
* subqueries: `CAST(res.accommodation_id AS INTEGER) = a.id`) and
|
||||
* `reservation-visibility.ts:106-107` (RV2's `publicStaySql`:
|
||||
* `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id`). Exists specifically
|
||||
* for `reservations.accommodation_id`, a TEXT column holding integer ids
|
||||
* 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.
|
||||
*
|
||||
* Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()`
|
||||
* carry: both known legacy call sites compare the cast against ANOTHER
|
||||
* COLUMN, not a bound value (`= a.id`, `= ${alias}.id`) — the shape is
|
||||
* `{ [castInteger(platform, 'vr.accommodation_id')]: columnRef(platform,
|
||||
* 'a.id') }`, pairing this helper's filter-KEY brand with `columnRef()`'s
|
||||
* existing VALUE-side column reference. The brand also lets it appear in a
|
||||
* `.select()` projection via `.as(alias)` (the `lowerTrim`/SQLF-027 dual-use
|
||||
* precedent) for a caller that only needs the cast INTEGER value itself.
|
||||
*/
|
||||
export function castInteger(platform: Platform, ref: string): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`CAST(${column(ref)} AS INTEGER)`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `ABS(JULIANDAY(<col>) - JULIANDAY(?))` — the legacy shape at
|
||||
* `reservations.service.ts:307` (RS11, `resolveDayIdFromTime`'s
|
||||
* nearest-day fallback: `ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC,
|
||||
* date ASC LIMIT 1`), which clamps an imported booking's date to the
|
||||
* closest day row in the trip when no exact match exists. `isoDate` is
|
||||
* bound as a genuine parameter (never interpolated), matching the legacy
|
||||
* statement's own bind. Distinct from `absDifference()` above:
|
||||
* `absDifference` is `ABS(<col> - ?)` on plain NUMERIC values (for a
|
||||
* lat/lng tolerance filter); this helper compares two `JULIANDAY(...)`
|
||||
* calendar values. Typed `RawQueryFragment & symbol`, the same brand
|
||||
* `countAllRef()` carries, so it is usable as an `.orderBy()` object key —
|
||||
* `.orderBy({ [dayDistance(platform, 'date', isoDate)]: 'asc' })` —
|
||||
* matching the legacy's own `ORDER BY ABS(JULIANDAY(...) - JULIANDAY(?)) ASC`
|
||||
* 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).
|
||||
*/
|
||||
export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user