Feature/308 kysely dynamic queries #309

Merged
bermudalamb merged 5 commits from feature/308-kysely-dynamic-queries into main 2026-09-04 17:47:42 -05:00
4 changed files with 22 additions and 19 deletions
Showing only changes of commit 306db81966 - Show all commits
+1 -1
View File
@@ -37,7 +37,7 @@ Kysely uses the `pg` driver directly, so a Postgres SQLSTATE stays on `err.code`
## The worked example ## The worked example
`buildItemFilterSql` is the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and it is still raw `pg` in `src/itemFilters.ts`, used by both the storefront and the admin listing. The conversion below is an example rather than a source file: committing it as one would put a second copy of a live function in `src/` that nothing calls, which is precisely what the #216 spike became. `buildItemFilterSql` was the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and #308 converted it: it is now `itemFilterExpressions` in `src/itemFilters.ts`, returning Kysely expressions that the storefront and admin listings compose with `eb.and`. What follows is the shape it took, kept here because it is the worked reference for converting anything else of that difficulty.
```ts ```ts
if (filters.categoryIds.length) { if (filters.categoryIds.length) {
+21 -6
View File
@@ -21,12 +21,27 @@
// aggregate independent and drop the GROUP BY entirely. `jsonArrayFrom` emits // aggregate independent and drop the GROUP BY entirely. `jsonArrayFrom` emits
// `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before. // `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before.
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder, Generated, Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { db } from './db'; import { db } from './db';
import { DB } from './db-kysely/schema'; import { DB } from './db-kysely/schema';
import { ItemStatus, ItemImage, ItemTag } from './types'; import { ItemStatus, ItemImage, ItemTag } from './types';
/**
* The mirror types `items.status` as `Generated<string>` because Postgres holds
* it as CHECK-constrained text rather than a native enum, so `kysely-codegen`
* has nothing narrower to emit. `types.ts` already states the real domain.
*
* Narrowed here, once, rather than asserted at each call site. `$castTo` at the
* call site would have replaced the entire row type with an assertion — which
* would silently accept a projection that had lost a column, and losing the
* compile error on exactly that is what this change exists to prevent.
*/
type ItemsWithStatus = Omit<DB['items'], 'status'> & { status: Generated<ItemStatus> };
type ItemDB = Omit<DB, 'items'> & { items: ItemsWithStatus };
const itemDb = db as unknown as Kysely<ItemDB>;
/** /**
* The aliases every item query and every filter clause is written against. * The aliases every item query and every filter clause is written against.
* *
@@ -36,7 +51,7 @@ import { ItemStatus, ItemImage, ItemTag } from './types';
* the builder would have made the diff unreadable against the SQL it replaces. * the builder would have made the diff unreadable against the SQL it replaces.
*/ */
export type ItemContext = ExpressionBuilder< export type ItemContext = ExpressionBuilder<
DB & { i: DB['items']; c: DB['categories'] }, ItemDB & { i: ItemDB['items']; c: ItemDB['categories'] },
'i' | 'c' 'i' | 'c'
>; >;
@@ -92,7 +107,7 @@ function tagsFor(eb: ItemContext) {
* obvious that adding a `where` does not affect anyone else. * obvious that adding a `where` does not affect anyone else.
*/ */
export function publicItemQuery() { export function publicItemQuery() {
return db return itemDb
.selectFrom('items as i') .selectFrom('items as i')
.leftJoin('categories as c', 'c.id', 'i.category_id') .leftJoin('categories as c', 'c.id', 'i.category_id')
.select([ .select([
@@ -111,7 +126,7 @@ export function publicItemQuery() {
/** The admin projection — every item column, plus the admin image fields. */ /** The admin projection — every item column, plus the admin image fields. */
export function adminItemQuery() { export function adminItemQuery() {
return db return itemDb
.selectFrom('items as i') .selectFrom('items as i')
.leftJoin('categories as c', 'c.id', 'i.category_id') .leftJoin('categories as c', 'c.id', 'i.category_id')
.selectAll('i') .selectAll('i')
@@ -135,7 +150,7 @@ interface ItemRowBase {
tags: ItemTag[]; tags: ItemTag[];
} }
/** What PUBLIC_ITEM_SELECT returns. Deliberately no payment or reservation columns. */ /** What publicItemQuery returns. Deliberately no payment or reservation columns. */
export type PublicItemRow = ItemRowBase; export type PublicItemRow = ItemRowBase;
/** /**
@@ -148,7 +163,7 @@ export interface AdminItemImage extends ItemImage {
} }
/** /**
* What ADMIN_ITEM_SELECT returns: `i.*`, so every column on the table. * What adminItemQuery returns: `i.*`, so every column on the table.
* *
* The extra fields are the ones the storefront is not allowed to see, which is * The extra fields are the ones the storefront is not allowed to see, which is
* the whole reason the two selects differ. `images` is narrowed rather than * the whole reason the two selects differ. `images` is narrowed rather than
-6
View File
@@ -137,15 +137,9 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
// sixteen lines in itemFilters.ts explained why that was safe. The clauses // sixteen lines in itemFilters.ts explained why that was safe. The clauses
// are Kysely expressions now: a value cannot reach the SQL text, because the // are Kysely expressions now: a value cannot reach the SQL text, because the
// types do not let it. // types do not let it.
// `.$castTo` narrows `status` from the schema mirror's generic `string` (the
// column is a CHECK-constrained text column, not a native Postgres enum, so
// kysely-codegen has no literal union to give it) to the app-level
// `ItemStatus` the CHECK constraint actually enforces. Every other field on
// AdminItemRow already matches the projection without a cast.
const rows: AdminItemRow[] = await adminItemQuery() const rows: AdminItemRow[] = await adminItemQuery()
.where((eb) => eb.and(itemFilterExpressions(eb, filters, null))) .where((eb) => eb.and(itemFilterExpressions(eb, filters, null)))
.orderBy('i.created_at', 'desc') .orderBy('i.created_at', 'desc')
.$castTo<AdminItemRow>()
.execute(); .execute();
res.json(rows); res.json(rows);
-6
View File
@@ -79,11 +79,6 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
status: filters.status ?? [...defaultStatuses] status: filters.status ?? [...defaultStatuses]
}; };
// `.$castTo` narrows `status` from the schema mirror's generic `string` (the
// column is a CHECK-constrained text column, not a native Postgres enum, so
// kysely-codegen has no literal union to give it) to the app-level
// `ItemStatus` the CHECK constraint actually enforces. Every other field on
// PublicItemRow already matches the projection without a cast.
const rows: PublicItemRow[] = await publicItemQuery() const rows: PublicItemRow[] = await publicItemQuery()
.where((eb) => .where((eb) =>
eb.and([ eb.and([
@@ -92,7 +87,6 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
]) ])
) )
.orderBy('i.created_at', 'desc') .orderBy('i.created_at', 'desc')
.$castTo<PublicItemRow>()
.execute(); .execute();
res.json(rows); res.json(rows);