diff --git a/src/shared/accounting/queries.ts b/src/shared/accounting/queries.ts index 83d89952bb..320c41ac66 100644 --- a/src/shared/accounting/queries.ts +++ b/src/shared/accounting/queries.ts @@ -45,13 +45,14 @@ import { } from "#shared/db/client.ts"; import { clauseArgs, - queryTail, - rowsUnlessNoneMatch, type WhereClause, whereSql, } from "#shared/db/where-clauses.ts"; import type { AccountRef, Transfer } from "#shared/ledger/types.ts"; +/** Newest first by business time, ties broken by id so the order is stable. */ +const NEWEST_FIRST = "occurred_at DESC, id DESC"; + /** A parameterised "this leg's side IS the account" match — two bound `?` * for (type, id), built from the shared transfers column names so every balance * read filters accounts identically. */ @@ -60,11 +61,14 @@ const legMatchesAccount = (role: "source" | "dest"): string => /** Every transfer touching `account`, as source or destination. */ export const transfersByAccount = (acct: AccountRef): Promise => - selectTransfers( - fromDb, - ` WHERE (${legMatchesAccount("source")}) OR (${legMatchesAccount("dest")})`, - [acct.type, acct.id, acct.type, acct.id], - ); + selectTransfers(fromDb, { + where: [ + { + args: [acct.type, acct.id, acct.type, acct.id], + clause: `(${legMatchesAccount("source")}) OR (${legMatchesAccount("dest")})`, + }, + ], + }); /** Every leg of one business event (booking, refund, …). */ export const transfersByEventGroup = ( @@ -86,17 +90,14 @@ export const eventGroupHasLegs = (eventGroup: string): Promise => /** The whole ledger. For tests and small reports; scoped reads are preferred on * hot paths. */ -export const allTransfers = (): Promise => - selectTransfers(fromDb, "", []); +export const allTransfers = (): Promise => selectTransfers(fromDb); /** The most recent `limit` transfers, newest first (by business time then id, so * ties are stable). The ordering + limit run in SQL so the whole ledger is never * loaded into memory; `occurred_at` is the stored INTEGER epoch, so DESC is * newest-first. */ export const recentTransfers = (limit: number): Promise => - selectTransfers(fromDb, " ORDER BY occurred_at DESC, id DESC LIMIT ?", [ - limit, - ]); + selectTransfers(fromDb, { limit, order: NEWEST_FIRST }); /** Legs shown on the operator-facing ledger list. Routine checkout cash * plumbing ("Card / bank → " and its refund mirror) stays hidden, but @@ -149,13 +150,7 @@ export const visibleTransfers = ( ...occurredAtRange(range), ...listingLegScope(listingIds), ]; - const tail = queryTail(parts, { - limit, - order: "occurred_at DESC, id DESC", - }); - return rowsUnlessNoneMatch(parts, () => - selectTransfers(fromDb, tail.sql, tail.args), - ); + return selectTransfers(fromDb, { limit, order: NEWEST_FIRST, where: parts }); }; /** Distinct-day bounds (earliest/latest `occurred_at`) over the whole ledger, or diff --git a/src/shared/accounting/rows.ts b/src/shared/accounting/rows.ts index af2255224a..180be7cb36 100644 --- a/src/shared/accounting/rows.ts +++ b/src/shared/accounting/rows.ts @@ -15,6 +15,8 @@ import { type SqlStatement, type TxScope, } from "#shared/db/client.ts"; +import { type Read, readStatement } from "#shared/db/read.ts"; +import { equals, rowsUnlessNoneMatch } from "#shared/db/where-clauses.ts"; import { account } from "#shared/ledger/account.ts"; import type { AccountRef, @@ -225,26 +227,36 @@ export const fromTx = async (sql, args) => resultRows(await tx.execute({ args, sql })); -/** Select transfers matching a WHERE clause (pass "" for the whole table). */ -export const selectTransfers = async ( +/** Which transfers to read, in what order, how many — everything but the + * columns and the table, which a transfer read always knows. */ +export type TransferRead = Omit; + +/** Select transfers, saying which rows and in what order rather than writing + * the query. `read` decides where the rows come from — the client, or an open + * transaction. Pass nothing to read the whole table. */ +export const selectTransfers = ( read: RowReader, - where: string, - args: InValue[], -): Promise => { - const rows = await read(`SELECT ${COLUMNS} FROM transfers${where}`, args); - return rows.map(rowToTransfer); -}; + query: TransferRead = {}, +): Promise => + rowsUnlessNoneMatch(query.where ?? [], async () => { + const { sql, args } = readStatement({ + ...query, + columns: COLUMNS, + from: "transfers", + }); + return (await read(sql, args)).map(rowToTransfer); + }); /** Every leg of one business event (booking, refund, …). */ export const selectByEventGroup = ( read: RowReader, eventGroup: string, ): Promise => - selectTransfers(read, " WHERE event_group = ?", [eventGroup]); + selectTransfers(read, { where: equals("event_group", eventGroup) }); /** The stored transfer with this id, or null when none exists. */ export const selectById = async ( read: RowReader, id: number, ): Promise => - (await selectTransfers(read, " WHERE id = ?", [id]))[0] ?? null; + (await selectTransfers(read, { where: equals("id", id) }))[0] ?? null; diff --git a/src/shared/accounting/store.ts b/src/shared/accounting/store.ts index 03138c479b..b22d878738 100644 --- a/src/shared/accounting/store.ts +++ b/src/shared/accounting/store.ts @@ -34,10 +34,10 @@ import { } from "#shared/accounting/rows.ts"; import { executeBatch, - inPlaceholders, type SqlStatement, type TxScope, } from "#shared/db/client.ts"; +import { inList } from "#shared/db/where-clauses.ts"; import type { Transfer, TransferInput } from "#shared/ledger/types.ts"; import { assertValidTransfer } from "#shared/ledger/validate.ts"; import { nowIso } from "#shared/now.ts"; @@ -144,18 +144,14 @@ type BatchSnapshot = { readonly originalsById: ReadonlyMap; }; -/** Read every transfer whose `column` is one of `values`; [] for an empty set - * without touching the database. `column` is a trusted constant, never input. */ +/** Read every transfer whose `column` is one of `values`; an empty set is + * answered without touching the database. `column` is a trusted constant. */ const selectByColumnIn = ( read: RowReader, column: string, values: readonly InValue[], ): Promise => - values.length === 0 - ? Promise.resolve([]) - : selectTransfers(read, ` WHERE ${column} IN (${inPlaceholders(values)})`, [ - ...values, - ]); + selectTransfers(read, { where: inList(column, values) }); /** Load everything {@link planGroup} needs to validate the batch, in three bulk * selects — independent of the number of groups. `read` picks where the diff --git a/src/shared/db/activityLog.ts b/src/shared/db/activityLog.ts index 3a90bfb21a..4cad630456 100644 --- a/src/shared/db/activityLog.ts +++ b/src/shared/db/activityLog.ts @@ -35,10 +35,11 @@ import { } from "#shared/db/client.ts"; import { idAndCreatedSchema } from "#shared/db/common-schema.ts"; import { decryptListingWithCount } from "#shared/db/listings/records.ts"; -import { listingStatement } from "#shared/db/listings/select.ts"; +import { listingReader } from "#shared/db/listings/select.ts"; +import { readRows } from "#shared/db/read.ts"; import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; import { col, defineTable } from "#shared/db/table.ts"; -import { equals, queryTail } from "#shared/db/where-clauses.ts"; +import { equals } from "#shared/db/where-clauses.ts"; import { nowIso } from "#shared/now.ts"; import { requireRequestPrivateKey } from "#shared/session-private-key.ts"; import type { ListingWithCount } from "#shared/types.ts"; @@ -184,21 +185,21 @@ const decryptLogRows = async ( const queryActivityLog = async ( listingId: number | null, limit: number, -): Promise => { - // A null listing means "every listing", which is no filter at all. - const parts = equals("listing_id", listingId ?? undefined); - // Order by id DESC, not created DESC: id is AUTOINCREMENT so it is - // co-monotonic with created (newest row = highest id) but, being the rowid, - // it is served straight from the primary key / idx_activity_log_listing_id - // without a sort over the unbounded log table. - const tail = queryTail(parts, { limit, order: "id DESC" }); - return decryptLogRows( - await queryAll( - `SELECT ${ACTIVITY_LOG_COLUMNS} FROM activity_log${tail.sql}`, - tail.args, - ), +): Promise => + decryptLogRows( + await readRows({ + columns: ACTIVITY_LOG_COLUMNS, + from: "activity_log", + limit, + // Order by id DESC, not created DESC: id is AUTOINCREMENT so it is + // co-monotonic with created (newest row = highest id) but, being the + // rowid, it is served straight from the primary key / + // idx_activity_log_listing_id without a sort over the unbounded log table. + order: "id DESC", + // A null listing means "every listing", which is no filter at all. + where: equals("listing_id", listingId ?? undefined), + }), ); -}; /** * Get activity log entries for an listing (most recent first) @@ -244,7 +245,7 @@ export const getListingWithActivityLogOrNull = async ( limit = 100, ): Promise => { const results = await queryBatch([ - listingStatement({ where: { ids: [listingId] } }), + listingReader.statement({ where: { ids: [listingId] } }), { args: [listingId, limit], sql: `SELECT ${ACTIVITY_LOG_COLUMNS} FROM activity_log WHERE listing_id = ? ORDER BY id DESC LIMIT ?`, diff --git a/src/shared/db/api-keys.ts b/src/shared/db/api-keys.ts index df5fceccf9..6051862827 100644 --- a/src/shared/db/api-keys.ts +++ b/src/shared/db/api-keys.ts @@ -9,20 +9,22 @@ * Keys inherit admin_level from their parent user. */ +/* jscpd:ignore-start */ import { decrypt, encrypt } from "#shared/crypto/encryption.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { wrapKeyWithToken } from "#shared/crypto/keys.ts"; import type { BlindIndex, WrappedKey } from "#shared/crypto/sealed.ts"; -import { chooseColumns } from "#shared/db/chosen-columns.ts"; -import { execute, executeUpdate, queryOne } from "#shared/db/client.ts"; +import { execute, executeUpdate } from "#shared/db/client.ts"; import { idAndCreatedSchema } from "#shared/db/common-schema.ts"; import { defineIdTable } from "#shared/db/define-id-table.ts"; import { col } from "#shared/db/table.ts"; -import { equals } from "#shared/db/where-clauses.ts"; +import { readerFor } from "#shared/db/table-reader.ts"; import { nowIso } from "#shared/now.ts"; import { getTouchOverride } from "#shared/test-overrides.ts"; import type { ApiKey } from "#shared/types.ts"; +/* jscpd:ignore-end */ + /** A row with its `name` decrypted for display — the table's read shape. */ type ApiKeyRow = { id: number; @@ -55,18 +57,25 @@ const apiKeysTable = defineIdTable("api_keys", { wrapped_data_key: col.simple(), }); -/** The api_keys columns, in one place, for the reads that select every column. */ -const API_KEY_COLUMNS = - "id, user_id, key_index, wrapped_data_key, name, created, last_used"; +/** Every read of this table goes through one reader, so a column name is + * checked against the table rather than spelled out as SQL. */ +const apiKeys = readerFor(apiKeysTable); -const apiKeyListColumns = chooseColumns(apiKeysTable, [ +/** What the auth path reads: everything but the name, which it never shows — + * so the one encrypted column is neither fetched nor decrypted on a request + * that only needs to know whose key this is. */ +const apiKeyAuthColumns = apiKeys.pick([ "id", - "name", + "user_id", + "key_index", + "wrapped_data_key", "created", "last_used", ]); -const apiKeyNameColumns = chooseColumns(apiKeysTable, ["id", "name"]); +const apiKeyListColumns = apiKeys.pick(["id", "name", "created", "last_used"]); + +const apiKeyNameColumns = apiKeys.pick(["id", "name"]); /** * Create a new API key for a user. @@ -98,13 +107,8 @@ export const createApiKey = async ( */ export const getApiKeyByToken = async ( token: string, -): Promise => { - const keyIndex = await hmacHash(token); - return queryOne( - `SELECT ${API_KEY_COLUMNS} FROM api_keys WHERE key_index = ?`, - [keyIndex], - ); -}; +): Promise | null> => + apiKeyAuthColumns.one({ key_index: await hmacHash(token) }); /** * List all API keys for a user (decrypts names for display). @@ -114,10 +118,10 @@ export const getApiKeysForUser = async ( ): Promise< Array<{ id: number; name: string; created: string; lastUsed: string }> > => { - const decrypted = await apiKeyListColumns.select({ - order: "id ASC", - where: equals("user_id", userId), - }); + const decrypted = await apiKeyListColumns.many( + { user_id: userId }, + { order: "id ASC" }, + ); return decrypted.map((row) => ({ created: row.created, id: row.id, @@ -133,9 +137,7 @@ export const getApiKeyForUser = async ( id: number, userId: number, ): Promise<{ id: number; name: string }> => { - const decrypted = await apiKeyNameColumns.selectOne({ - where: [...equals("id", id), ...equals("user_id", userId)], - }); + const decrypted = await apiKeyNameColumns.one({ id, user_id: userId }); if (!decrypted) throw new Error(`API key ${id} not found for user ${userId}`); return { id: decrypted.id, name: decrypted.name }; }; diff --git a/src/shared/db/attendees/select.ts b/src/shared/db/attendees/select.ts index 327eeaf011..0be622054d 100644 --- a/src/shared/db/attendees/select.ts +++ b/src/shared/db/attendees/select.ts @@ -22,7 +22,6 @@ */ /* jscpd:ignore-start */ -import type { InValue } from "@libsql/client"; import { ATTENDEE } from "#shared/accounting/accounts.ts"; import { KIND } from "#shared/accounting/kinds.ts"; import { @@ -31,13 +30,12 @@ import { saleLegPredicate, } from "#shared/accounting/projection-sql.ts"; import { ATTENDEE_KIND, SERVICING_KIND } from "#shared/db/attendees/kind.ts"; -import { queryAll } from "#shared/db/client.ts"; +import type { SqlStatement } from "#shared/db/client.ts"; +import { defineReader } from "#shared/db/read.ts"; import { - clauseArgs, inList, - orderSql, + inSubquery, type WhereClause, - whereSql, } from "#shared/db/where-clauses.ts"; import type { Attendee } from "#shared/types.ts"; /* jscpd:ignore-end */ @@ -255,7 +253,7 @@ export type AttendeeWhere = { /** Attendees whose id is returned by this subquery — the "pick attendees, * then return all their booking lines" pattern (newest N, one page). The * subquery carries its own bound args. */ - attendeeIdsSubquery?: { sql: string; args: InValue[] }; + attendeeIdsSubquery?: SqlStatement; /** Booking lines on these listings. A single listing is a one-element array. */ listingIds?: number[]; /** Booking lines within one package group. */ @@ -295,10 +293,7 @@ const whereClauses = (where: AttendeeWhere): WhereClause[] => { ...inList("attendee.id", where.attendeeIds), ]; if (where.attendeeIdsSubquery !== undefined) { - parts.push({ - args: where.attendeeIdsSubquery.args, - clause: `attendee.id IN (${where.attendeeIdsSubquery.sql})`, - }); + parts.push(...inSubquery("attendee.id", where.attendeeIdsSubquery)); } parts.push(...inList("listingAttendee.listing_id", where.listingIds)); if (where.packageGroupId !== undefined) { @@ -328,31 +323,16 @@ const whereClauses = (where: AttendeeWhere): WhereClause[] => { }; /** - * Build the `FROM … WHERE … ORDER BY …` tail (and its bound args) for an - * attendee read. Exposed so the batch readers, which must embed the SQL inside - * a `queryBatch` statement rather than run it, share the exact same filter and - * ordering logic as {@link getAttendees}. + * The tables an attendee read starts from. A daily-range filter is the one + * filter that also needs the listings table, because it asks about the listing + * rather than the booking. */ -export const attendeeFromWhere = ( - join: AttendeeJoin, - where: AttendeeWhere, - order?: AttendeeOrder, -): { from: string; args: InValue[] } => { - const parts = whereClauses(where); - const joinKeyword = join === "left" ? "LEFT JOIN" : "JOIN"; - const listingsJoin = - where.dailyRange !== undefined - ? " JOIN listings AS listing ON listingAttendee.listing_id = listing.id" - : ""; - // A single-attendee read (getAttendeeOrNull) needs no ordering; a list read always - // passes one so its rows are deterministic. - return { - args: clauseArgs(parts), - from: `FROM attendees AS attendee ${joinKeyword} listing_attendees AS listingAttendee ON listingAttendee.attendee_id = attendee.id${listingsJoin}${whereSql( - parts, - )}${orderSql(ORDER_SQL, order)}`, - }; -}; +const attendeeFrom = (join: AttendeeJoin, where: AttendeeWhere): string => + `attendees AS attendee ${join === "left" ? "LEFT JOIN" : "JOIN"}` + + " listing_attendees AS listingAttendee ON listingAttendee.attendee_id = attendee.id" + + (where.dailyRange === undefined + ? "" + : " JOIN listings AS listing ON listingAttendee.listing_id = listing.id"); /** Everything a caller declares to list attendees: which fields to project, * which rows to keep, in what order, over which join. */ @@ -366,6 +346,21 @@ export type GetAttendeesQuery = { join?: AttendeeJoin; }; +/** The attendee reader: the chosen fields, the tables, the filter, the order. + * A single-attendee read passes no order; a list read always passes one so its + * rows come back the same way every time. */ +const attendees = defineReader>( + ORDER_SQL, + (query) => { + const join = query.join ?? "inner"; + return { + columns: attendeeColumns(join, query.fields), + from: attendeeFrom(join, query.where), + where: whereClauses(query.where), + }; + }, +); + /** * A `queryBatch` statement (SQL + bound args) for an attendee read: the single * place a declared query becomes runnable SQL. {@link getAttendees} runs it; the @@ -375,14 +370,7 @@ export type GetAttendeesQuery = { */ export const attendeeBatchStatement = ( query: GetAttendeesQuery, -): { sql: string; args: InValue[] } => { - const join = query.join ?? "inner"; - const { from, args } = attendeeFromWhere(join, query.where, query.order); - return { - args, - sql: `SELECT ${attendeeColumns(join, query.fields)} ${from}`, - }; -}; +): SqlStatement => attendees.statement(query); /** * The one reader every attendee-listing surface uses: declare the fields, the @@ -392,7 +380,4 @@ export const attendeeBatchStatement = ( */ export const getAttendees = ( query: GetAttendeesQuery, -): Promise[]> => { - const { sql, args } = attendeeBatchStatement(query); - return queryAll>(sql, args); -}; +): Promise[]> => attendees.rows>(query); diff --git a/src/shared/db/chosen-columns.ts b/src/shared/db/chosen-columns.ts index 7afe7847b6..771513f2d1 100644 --- a/src/shared/db/chosen-columns.ts +++ b/src/shared/db/chosen-columns.ts @@ -8,18 +8,18 @@ * needs no SQL of its own. */ -import type { InValue } from "@libsql/client"; import { mapParallel } from "#fp"; -import { queryAll, type SqlStatement } from "#shared/db/client.ts"; +import { type Read, readOneRow, readRows } from "#shared/db/read.ts"; import type { Table } from "#shared/db/table.ts"; -import { - queryTail, - rowsUnlessNoneMatch, - type WhereClause, -} from "#shared/db/where-clauses.ts"; +import type { WhereClause } from "#shared/db/where-clauses.ts"; type TableColumn = keyof Row & string; -type ColumnNames = readonly [TableColumn, ...TableColumn[]]; + +/** One or more of a row's own column names — a read must select something. */ +export type ColumnNames = readonly [ + TableColumn, + ...TableColumn[], +]; /** A selected row before the table's declared read transforms run. Database * values are unknown here because booleans and encrypted strings have a @@ -33,13 +33,11 @@ type ChosenRow> = Pick< Columns[number] >; -type ColumnQuery = (sql: string, args?: InValue[]) => Promise; - /** * A read declared rather than written. The chosen set already knows its table * and columns, so this is the whole of an ordinary single-table read; one that - * joins or carries a subquery writes its own SQL and runs it through - * {@link ChosenColumns.queryAll}. + * joins or carries a subquery says so with {@link readRows} instead, passing + * this set's {@link ChosenColumns.columnsSql} as its columns. */ export type ReadRequest = { /** The filters, from `#shared/db/where-clauses.ts`. Absent keeps every row. */ @@ -55,7 +53,6 @@ export type ReadRequest = { export interface ChosenColumns> { readonly columns: Columns; columnsSql: (alias?: string) => string; - queryAll: ColumnQuery[]>; read: ( row: StoredRowOf, rowId?: unknown, @@ -136,38 +133,29 @@ export const chooseColumns = < ? columnsSql(alias) : `${columnsSql(alias)}, ${qualified(table.primaryKey, alias)}`; - /** Turn a declared read into SQL and its bound values. The chosen set - * supplies the columns and the table; the caller supplies the rest. */ - const statementFor = (query: ReadRequest): SqlStatement => { - const from = query.alias ? `${table.name} AS ${query.alias}` : table.name; - const tail = queryTail(query.where ?? [], query); - return { - args: tail.args, - sql: `SELECT ${readColumnsSql(query.alias)} FROM ${from}${tail.sql}`, - }; - }; + /** The chosen set supplies the columns and the table; the caller supplies + * the rest of the read. */ + const readFor = (query: ReadRequest): Read => ({ + ...query, + columns: readColumnsSql(query.alias), + from: query.alias ? `${table.name} AS ${query.alias}` : table.name, + }); /** The stored rows a declared read selects, before the read transforms. */ const storedRowsFor = ( query: ReadRequest, ): Promise[]> => - rowsUnlessNoneMatch(query.where ?? [], () => { - const { sql, args } = statementFor(query); - return queryAll>(sql, args); - }); + readRows>(readFor(query)); return { columns, columnsSql, - queryAll: async (sql, args) => - readAll(await queryAll>(sql, args)), read, readAll, select: async (query = {}) => readAll(await storedRowsFor(query)), selectOne: async (query = {}) => { - // One row is a read capped at one — the same path, not a second one. - const row = (await storedRowsFor({ ...query, limit: 1 }))[0]; - return row === undefined ? null : read(row); + const row = await readOneRow>(readFor(query)); + return row === null ? null : read(row); }, }; }; diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 3345e48b9f..ffdd854ed3 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -51,7 +51,7 @@ import { import { decryptListingWithCount } from "#shared/db/listings/records.ts"; import { type ListingRecordRow, - listingStatement, + listingReader, } from "#shared/db/listings/select.ts"; import { envNameSource, queryAndMap, rowsByIds } from "#shared/db/query.ts"; import { isSlugTakenAnywhere } from "#shared/db/slug-registry.ts"; @@ -191,7 +191,7 @@ export const getListingsByGroupIds = async ( if (groupIds.length === 0) return new Map(); type GroupListingRow = ListingRecordRow & { group_ids: string }; type ListingGroups = { groupIds: number[]; member: ListingWithCount }; - const { sql, args } = listingStatement({ + const { sql, args } = listingReader.statement({ order: "created_desc", where: { activeOnly, inGroups: [...groupIds] }, }); diff --git a/src/shared/db/groups/candidates.ts b/src/shared/db/groups/candidates.ts index 6d49111425..b373ede2e2 100644 --- a/src/shared/db/groups/candidates.ts +++ b/src/shared/db/groups/candidates.ts @@ -4,7 +4,7 @@ import { chooseColumns } from "#shared/db/chosen-columns.ts"; import { LISTING_ORDER_SQL } from "#shared/db/listings/select.ts"; import { rawListingsTable } from "#shared/db/listings/table.ts"; import { settings } from "#shared/db/settings.ts"; -import { orderSql } from "#shared/db/where-clauses.ts"; +import { notInSubquery } from "#shared/db/where-clauses.ts"; import { resolveListingDefaults } from "#shared/listing-defaults.ts"; import type { SortableListing } from "#shared/types.ts"; @@ -39,18 +39,14 @@ const candidateColumns = chooseColumns(rawListingsTable, [ export const getListingsNotInGroup = async ( groupId: number, ): Promise => { - const rows = await candidateColumns.queryAll( - `SELECT ${candidateColumns.columnsSql("listing")} - FROM listings AS listing - WHERE listing.id NOT IN ( - SELECT groupListing.listing_id - FROM group_listings AS groupListing - WHERE groupListing.group_id = ?)${orderSql( - LISTING_ORDER_SQL, - "created_desc", - )}`, - [groupId], - ); + const rows = await candidateColumns.select({ + alias: "listing", + order: LISTING_ORDER_SQL.created_desc, + where: notInSubquery("listing.id", { + args: [groupId], + sql: "SELECT groupListing.listing_id FROM group_listings AS groupListing WHERE groupListing.group_id = ?", + }), + }); return rows.map((row) => { // The flag has done its job once the settings are applied; leaving it on the // candidate would invite a second, pointless overlay. diff --git a/src/shared/db/images.ts b/src/shared/db/images.ts index 8f57200f7c..906d31c5fd 100644 --- a/src/shared/db/images.ts +++ b/src/shared/db/images.ts @@ -7,14 +7,14 @@ import { decrypt, encrypt } from "#shared/crypto/encryption.ts"; import { chooseColumns, type StoredRowOf } from "#shared/db/chosen-columns.ts"; import { executeBatch, - queryAll, - queryOne, type SqlStatement, useTransaction, } from "#shared/db/client.ts"; import { defineIdTable } from "#shared/db/define-id-table.ts"; import { defineOrderedCollection } from "#shared/db/ordered-collection.ts"; +import { type Read, readOneRow, readRows } from "#shared/db/read.ts"; import { type ColumnDef, col } from "#shared/db/table.ts"; +import { equals } from "#shared/db/where-clauses.ts"; import { decryptImageFilename } from "#shared/images/broken.ts"; import type { Image, @@ -108,6 +108,23 @@ export const imageFilenameSubqueries = ( ), ].join(", "); +/** The images attached to one item, in the order the operator arranged them. + * Both image reads below want the same rows and differ only in which columns + * they open, so they say it once here. */ +const imagesOfItem = ( + columns: string, + itemType: ImageUseItemType, + itemId: number, +): Read => ({ + columns, + from: "image_uses AS imageUse JOIN images AS image ON image.id = imageUse.image_id", + order: "imageUse.sort_order ASC, imageUse.image_id ASC", + where: [ + ...equals("imageUse.item_type", itemType), + ...equals("imageUse.item_id", itemId), + ], +}); + export const getImageFilenamesForItem = async ( itemType: ImageUseItemType, itemId: number, @@ -115,14 +132,8 @@ export const getImageFilenamesForItem = async ( type ImageFileRow = StoredRowOf & { id: number; }; - const stored = await queryOne( - `SELECT ${imageFileColumns.columnsSql("image")} - FROM image_uses AS imageUse - JOIN images AS image ON image.id = imageUse.image_id - WHERE imageUse.item_type = ? AND imageUse.item_id = ? - ORDER BY imageUse.sort_order ASC, imageUse.image_id ASC - LIMIT 1`, - [itemType, itemId], + const stored = await readOneRow( + imagesOfItem(imageFileColumns.columnsSql("image"), itemType, itemId), ); if (!stored) return { image_alt_text: "", image_thumb_url: "", image_url: "" }; @@ -144,14 +155,12 @@ export const getImagesForItem = async ( type OrderedImageRow = StoredRowOf & { sort_order: number; }; - const rows = await queryAll( - `SELECT ${imageColumns.columnsSql("image")}, - imageUse.sort_order - FROM image_uses AS imageUse - JOIN images AS image ON image.id = imageUse.image_id - WHERE imageUse.item_type = ? AND imageUse.item_id = ? - ORDER BY imageUse.sort_order ASC, imageUse.image_id ASC`, - [itemType, itemId], + const rows = await readRows( + imagesOfItem( + `${imageColumns.columnsSql("image")}, imageUse.sort_order`, + itemType, + itemId, + ), ); const images = await imageColumns.readAll(rows); return images.map((image, index) => ({ @@ -161,13 +170,12 @@ export const getImagesForItem = async ( }; export const getImageUsesForImage = (imageId: number): Promise => - queryAll( - `SELECT image_id, item_type, item_id, sort_order - FROM image_uses - WHERE image_id = ? - ORDER BY item_type ASC, item_id ASC`, - [imageId], - ); + readRows({ + columns: "image_id, item_type, item_id, sort_order", + from: "image_uses", + order: "item_type ASC, item_id ASC", + where: equals("image_id", imageId), + }); const itemTable: Record = { group: "groups", diff --git a/src/shared/db/listings/attendees.ts b/src/shared/db/listings/attendees.ts index 8503564f3c..833130e066 100644 --- a/src/shared/db/listings/attendees.ts +++ b/src/shared/db/listings/attendees.ts @@ -17,7 +17,7 @@ import { } from "#shared/db/client.ts"; import type { Attendee, ListingWithCount } from "#shared/types.ts"; import { decryptListingWithCount } from "./records.ts"; -import { type ListingRecordRow, listingStatement } from "./select.ts"; +import { type ListingRecordRow, listingReader } from "./select.ts"; const withBatchListing = async ( listingResult: ResultSet, @@ -30,7 +30,7 @@ const withBatchListing = async ( /** The listing half of these one-round-trip batch reads. */ const oneListing = (id: number): SqlStatement => - listingStatement({ where: { ids: [id] } }); + listingReader.statement({ where: { ids: [id] } }); export type ListingWithAttendees = { listing: ListingWithCount; diff --git a/src/shared/db/listings/catalog.ts b/src/shared/db/listings/catalog.ts index abd6b77b14..c397728001 100644 --- a/src/shared/db/listings/catalog.ts +++ b/src/shared/db/listings/catalog.ts @@ -2,7 +2,7 @@ import { chooseColumns } from "#shared/db/chosen-columns.ts"; import { settings } from "#shared/db/settings.ts"; -import { equals } from "#shared/db/where-clauses.ts"; +import { equals, notInSubquery } from "#shared/db/where-clauses.ts"; import type { CatalogSourceListing } from "#shared/external-order.ts"; import type { Listing } from "#shared/types.ts"; import { rawListingsTable } from "./table.ts"; @@ -70,24 +70,30 @@ const catalogVisibleSql = (hiddenDefault: boolean | undefined): string => { /** Read only active, effectively visible listings for the public catalog. */ export const getCatalogListings = async (): Promise => { - const rows = await catalogListingColumns.queryAll( - `SELECT ${catalogListingColumns.columnsSql("listing")} - FROM listings AS listing - WHERE listing.active = 1 - AND ${catalogVisibleSql(settings.listingDefaults.hidden)} - AND (listing.bookable_alone = 1 - OR listing.id NOT IN ( - SELECT listingParent.child_listing_id - FROM listing_parents AS listingParent - )) - AND listing.id NOT IN ( - SELECT groupListing.listing_id - FROM group_listings AS groupListing - JOIN groups AS listingGroup ON listingGroup.id = groupListing.group_id - WHERE listingGroup.is_package = 1 - AND listingGroup.hide_package_listings = 1 - )`, - ); + const rows = await catalogListingColumns.select({ + alias: "listing", + where: [ + { args: [], clause: "listing.active = 1" }, + { + args: [], + clause: catalogVisibleSql(settings.listingDefaults.hidden), + }, + // A child listing is only offered on its own when it says it may be. + { + args: [], + clause: `(listing.bookable_alone = 1 OR listing.id NOT IN ( + SELECT listingParent.child_listing_id FROM listing_parents AS listingParent))`, + }, + ...notInSubquery("listing.id", { + args: [], + sql: `SELECT groupListing.listing_id + FROM group_listings AS groupListing + JOIN groups AS listingGroup ON listingGroup.id = groupListing.group_id + WHERE listingGroup.is_package = 1 + AND listingGroup.hide_package_listings = 1`, + }), + ], + }); return rows.map((row) => ({ active: true, hidden: false, diff --git a/src/shared/db/listings/records.ts b/src/shared/db/listings/records.ts index c193893915..7fe9fcf937 100644 --- a/src/shared/db/listings/records.ts +++ b/src/shared/db/listings/records.ts @@ -27,7 +27,7 @@ import { getListingRows, type ListingRecordRow, type ListingWhere, - listingStatement, + listingReader, } from "./select.ts"; import { computeSlugIndex, @@ -212,7 +212,7 @@ export const getListingWithCount = ( export const getListingWithCountPrimary = async ( id: number, ): Promise => { - const { sql, args } = listingStatement({ where: { ids: [id] } }); + const { sql, args } = listingReader.statement({ where: { ids: [id] } }); const row = await queryOnePrimary(sql, args); return row === null ? null : decryptListingWithCount(row); }; diff --git a/src/shared/db/listings/select.ts b/src/shared/db/listings/select.ts index 271d47a8fc..5eeb3c2645 100644 --- a/src/shared/db/listings/select.ts +++ b/src/shared/db/listings/select.ts @@ -15,16 +15,9 @@ import { accountBalanceSubquery, creditsLessWriteoffDebits, } from "#shared/accounting/projection-sql.ts"; -import { queryAll, type SqlStatement } from "#shared/db/client.ts"; import { imageFilenameSubqueries } from "#shared/db/images.ts"; -import { - clauseArgs, - inList, - orderSql, - rowsUnlessNoneMatch, - type WhereClause, - whereSql, -} from "#shared/db/where-clauses.ts"; +import { defineReader } from "#shared/db/read.ts"; +import { inList, type WhereClause } from "#shared/db/where-clauses.ts"; import type { ListingWithCount } from "#shared/types.ts"; /* jscpd:ignore-end */ @@ -109,40 +102,27 @@ export type GetListingsQuery = { order?: ListingOrder; }; -/** Assemble the SQL from an already-built clause list, so a caller that has to - * inspect the clauses first does not build them twice. */ -const statementFor = ( - query: GetListingsQuery, - parts: WhereClause[], -): SqlStatement => { - const byGroup = query.where.inGroups !== undefined; - const columns = byGroup - ? `json_group_array(groupListing.group_id) AS group_ids, ${listingColumns()}` - : listingColumns(); - // Reading by group starts from the membership rows and folds a listing's - // several memberships back into one row; every other read starts from the - // listings themselves. - const from = byGroup - ? "FROM group_listings AS groupListing JOIN listings AS listing ON listing.id = groupListing.listing_id" - : "FROM listings AS listing"; - const groupBy = byGroup ? " GROUP BY listing.id" : ""; - return { - args: clauseArgs(parts), - sql: `SELECT ${columns} ${from}${whereSql(parts)}${groupBy}${orderSql( - LISTING_ORDER_SQL, - query.order, - )}`, - }; -}; - /** - * A `queryBatch` statement (SQL + bound args) for a listing read: the single - * place a declared query becomes runnable SQL. {@link getListingRows} runs it; - * the activity-log reader embeds it in a batch, and the read-your-own-write - * reader runs it against the primary. + * The listing reader. Reading by group starts from the membership rows and + * folds a listing's several memberships back into one row, naming which groups + * it matched; every other read starts from the listings themselves. */ -export const listingStatement = (query: GetListingsQuery): SqlStatement => - statementFor(query, whereClauses(query.where)); +export const listingReader = defineReader( + LISTING_ORDER_SQL, + (query) => { + const byGroup = query.where.inGroups !== undefined; + return { + columns: byGroup + ? `json_group_array(groupListing.group_id) AS group_ids, ${listingColumns()}` + : listingColumns(), + from: byGroup + ? "group_listings AS groupListing JOIN listings AS listing ON listing.id = groupListing.listing_id" + : "listings AS listing", + groupBy: byGroup ? "listing.id" : undefined, + where: whereClauses(query.where), + }; + }, +); /** The raw shape a listing read returns: the stored columns plus the worked-out * values, before decryption and before any inherited defaults are overlaid. */ @@ -155,10 +135,4 @@ export type ListingRecordRow = Omit; */ export const getListingRows = ( query: GetListingsQuery, -): Promise => { - const parts = whereClauses(query.where); - return rowsUnlessNoneMatch(parts, () => { - const { sql, args } = statementFor(query, parts); - return queryAll(sql, args); - }); -}; +): Promise => listingReader.rows(query); diff --git a/src/shared/db/news-posts.ts b/src/shared/db/news-posts.ts index 150e282c8c..a21d12a229 100644 --- a/src/shared/db/news-posts.ts +++ b/src/shared/db/news-posts.ts @@ -19,7 +19,6 @@ import { chooseColumns, type StoredRowOf } from "#shared/db/chosen-columns.ts"; import { executeBatch, queryAll, - queryOne, resultRows, type TxScope, useTransaction, @@ -36,12 +35,14 @@ import { clearImageUsesForItemStatement, imageFilenameSubqueries, } from "#shared/db/images.ts"; +import { readRows } from "#shared/db/read.ts"; import { unclaimedSlugCondition, updateRowWithUnclaimedSlug, } from "#shared/db/slug-registry.ts"; import type { SluggedContentInput } from "#shared/db/slugged-content-input.ts"; import { col } from "#shared/db/table.ts"; +import { readerFor } from "#shared/db/table-reader.ts"; import { decryptImageFilenameOrEmpty } from "#shared/images/broken.ts"; import { nowIso } from "#shared/now.ts"; import { requestCache } from "#shared/request-cache.ts"; @@ -135,22 +136,17 @@ type SealedCardRow = StoredRowOf< * slug, name, snippet — no image reads or decrypts. Feeds the RSS feed and the * admin list, which render no images. */ export const getNewsPostSummaries = (): Promise => - newsSummaryColumns.queryAll( - `SELECT ${newsSummaryColumns.columnsSql()} - FROM news_posts - ORDER BY created DESC, id DESC`, - ); + newsSummaryColumns.select({ order: "created DESC, id DESC" }); /** Load the card projection for every post, newest first: the summary plus * the post's first linked image — for the public /news list, the one reader * that shows pictures. */ export const getNewsPostCards = async (): Promise => { - const rows = await queryAll( - `SELECT ${newsSummaryColumns.columnsSql("news_post")}, - ${imageFilenameSubqueries("news", "news_post.id")} - FROM news_posts AS news_post - ORDER BY news_post.created DESC, news_post.id DESC`, - ); + const rows = await readRows({ + columns: `${newsSummaryColumns.columnsSql("news_post")}, ${imageFilenameSubqueries("news", "news_post.id")}`, + from: "news_posts AS news_post", + order: "news_post.created DESC, news_post.id DESC", + }); return mapParallel(async (row: SealedCardRow) => ({ ...(await newsSummaryColumns.read(row)), image_alt_text: await decryptTextOrEmpty(row.image_alt_text), @@ -168,9 +164,9 @@ export const getNewsPostCards = async (): Promise => { /** id → decrypted name for every post, newest first — the image library's * link-target labels (nothing but the name decrypted). */ export const getNewsPostNames = async (): Promise> => { - const rows = await newsNameColumns.queryAll( - `SELECT ${newsNameColumns.columnsSql()} FROM news_posts ORDER BY created DESC, id DESC`, - ); + const rows = await newsNameColumns.select({ + order: "created DESC, id DESC", + }); return fieldById("name")(rows); }; @@ -179,22 +175,14 @@ export const getNewsPostNames = async (): Promise> => { export const getNewsPostById = (id: number): Promise => newsPostsTable.findById(id); -/** Every {@link NewsPost} column, listed explicitly (AGENTS.md) so a future - * column can't silently widen what the single-post read fetches and decrypts. */ -const NEWS_POST_COLUMNS = - "id, created, slug, slug_index, name, meta_title, meta_description, snippet, content"; +/** The whole post, fully decrypted. */ +const wholeNewsPost = readerFor(newsPostsTable); /** One full post (fully decrypted) by blind-index slug lookup — the public * `/news/:slug` read. Null when absent. */ -export const getNewsPostBySlugIndex = async ( +export const getNewsPostBySlugIndex = ( slugIndex: BlindIndex, -): Promise => { - const row = await queryOne( - `SELECT ${NEWS_POST_COLUMNS} FROM news_posts WHERE slug_index = ? LIMIT 1`, - [slugIndex], - ); - return row ? newsPostsTable.fromDb(row) : null; -}; +): Promise => wholeNewsPost.one({ slug_index: slugIndex }); /** Create a post, stamping `created` now (the admin flow) or at a pinned time * (tests/restore), and deriving its immutable `/news` permalink from that date diff --git a/src/shared/db/notes/queries.ts b/src/shared/db/notes/queries.ts index 1369e3e2fe..50860d92df 100644 --- a/src/shared/db/notes/queries.ts +++ b/src/shared/db/notes/queries.ts @@ -10,13 +10,12 @@ import { execute, executeBatch, insert, - queryAll, type SqlStatement, } from "#shared/db/client.ts"; +import { readOneRow, readRows } from "#shared/db/read.ts"; import { clauseArgs, equals, - rowsUnlessNoneMatch, type WhereClause, whereSql, } from "#shared/db/where-clauses.ts"; @@ -61,12 +60,12 @@ export const createOwnerNote = noteWriterOf("owner"); /** The still-sealed rows matching a WHERE body, oldest first per record. Shared * by every read so the column list and the ordering live in one place. */ const noteRowsWhere = (where: WhereClause[]): Promise => - rowsUnlessNoneMatch(where, () => - queryAll( - `SELECT ${NOTE_COLUMNS} FROM system_notes${whereSql(where)} ORDER BY entity_id, id`, - clauseArgs(where), - ), - ); + readRows({ + columns: NOTE_COLUMNS, + from: "system_notes", + order: "entity_id, id", + where, + }); /** * The still-sealed rows for several records of one kind, oldest first. Cheap @@ -160,12 +159,11 @@ export const getNote = async ( noteId: number, privateKey: CryptoKey, ): Promise => { - const where = noteOfTarget(target, noteId); - const rows = await queryAll( - `SELECT ${NOTE_COLUMNS} FROM system_notes${whereSql(where)}`, - clauseArgs(where), - ); - const row = rows[0]; + const row = await readOneRow({ + columns: NOTE_COLUMNS, + from: "system_notes", + where: noteOfTarget(target, noteId), + }); return row ? openNote(row, privateKey) : null; }; diff --git a/src/shared/db/questions/queries.ts b/src/shared/db/questions/queries.ts index 6fdb050cd5..36ab36a9ff 100644 --- a/src/shared/db/questions/queries.ts +++ b/src/shared/db/questions/queries.ts @@ -7,16 +7,16 @@ */ import { filter, map, mapParallel, reduce } from "#fp"; -import { inPlaceholders, queryAll } from "#shared/db/client.ts"; +import { + inPlaceholders, + queryAll, + type SqlStatement, +} from "#shared/db/client.ts"; import { linkTableSide } from "#shared/db/link-table.ts"; import type { Answer, QuestionWithAnswers } from "#shared/db/question-types.ts"; import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; -import { - clauseArgs, - equals, - type WhereClause, - whereSql, -} from "#shared/db/where-clauses.ts"; +import { readRows } from "#shared/db/read.ts"; +import { equals, type WhereClause } from "#shared/db/where-clauses.ts"; /** Direct question-to-listing assignments, viewed from either side. */ export const questionListings = linkTableSide( @@ -105,22 +105,28 @@ const withAnswers = filter( q.display_type === "free_text" || q.answers.length > 0, ); -/** Fetch questions with their answers, keeping only the ones the clauses select. */ -const fetchQuestions = (parts: WhereClause[]) => - queryAll( - `SELECT ${QA_COLS} FROM ${QA_JOIN}${whereSql(parts)} ORDER BY answer.sort_order`, - clauseArgs(parts), - ); +/** The order every question list comes back in: the operator's arrangement, + * then each question's answers in theirs. */ +const QUESTION_ORDER = "question.sort_order, question.id, answer.sort_order"; + +/** Fetch questions with their answers, keeping only the ones the clauses + * select. `from` differs only for the per-listing read, which joins the + * assignment table to find out which questions a listing asks. */ +const fetchQuestions = ( + where: WhereClause[], + from: string | SqlStatement = QA_JOIN, +) => + readRows({ + columns: QA_COLS, + from, + order: QUESTION_ORDER, + where, + }); /** Get all questions with their answers (sorted by sort_order), decrypted */ export const getAllQuestionsWithAnswers = async (): Promise< QuestionWithAnswers[] -> => - groupJoinedRows( - await queryAll( - `SELECT ${QA_COLS} FROM ${QA_JOIN} ORDER BY question.sort_order, question.id, answer.sort_order`, - ), - ); +> => groupJoinedRows(await fetchQuestions([])); /** Get questions assigned to a listing, in the global question order. * Questions with no answers are excluded (nothing useful to ask). */ @@ -129,14 +135,20 @@ export const getQuestionsForListing = async ( ): Promise => withAnswers( await groupJoinedRows( - await queryAll( - `SELECT ${QA_COLS} - FROM questions AS question + await fetchQuestions( + [ + { + args: [], + clause: + "question.assign_all = 1 OR listingQuestion.listing_id IS NOT NULL", + }, + ], + { + args: [listingId], + sql: `questions AS question LEFT JOIN listing_questions AS listingQuestion ON question.id = listingQuestion.question_id AND listingQuestion.listing_id = ? - LEFT JOIN answers AS answer ON answer.question_id = question.id - WHERE question.assign_all = 1 OR listingQuestion.listing_id IS NOT NULL - ORDER BY question.sort_order, question.id, answer.sort_order`, - [listingId], + LEFT JOIN answers AS answer ON answer.question_id = question.id`, + }, ), ), ); diff --git a/src/shared/db/read.ts b/src/shared/db/read.ts new file mode 100644 index 0000000000..f116affffb --- /dev/null +++ b/src/shared/db/read.ts @@ -0,0 +1,119 @@ +/** + * One shape for a read, and the two things you can do with it: turn it into a + * statement, or run it. + * + * A read is one sentence — these columns, from here, keeping these rows, in + * this order, at most this many. A reader says the sentence; this assembles it, + * and skips the database when the filter cannot match a row. + * + * A read that joins or carries a subquery says so in its `from` — it is still + * one sentence, just a longer one. + */ + +import { queryAll, type SqlStatement } from "#shared/db/client.ts"; +import { + clauseArgs, + rowsUnlessNoneMatch, + type WhereClause, + whereSql, +} from "#shared/db/where-clauses.ts"; + +/** A read, said rather than written. */ +export type Read = { + /** The SELECT column list, without the keyword. */ + columns: string; + /** The FROM body, without the keyword — a table name, or a whole join. A + * join that narrows on a value (an `ON … = ?`) brings that value with it. */ + from: string | SqlStatement; + /** Which rows to keep. Absent or empty keeps every row. */ + where?: readonly WhereClause[] | undefined; + /** A `GROUP BY` body without the keyword, for a read that folds rows. */ + groupBy?: string | undefined; + /** An `ORDER BY` body without the keyword. Omit when the caller does not care + * how the rows come back. Always a constant belonging to the read, never + * caller input, so unlike a filter it carries no values of its own. */ + order?: string | undefined; + /** A row cap. Omit for no limit. */ + limit?: number | undefined; +}; + +const tail = (part: string, body: string | undefined): string => + body === undefined ? "" : `${part}${body}`; + +/** The statement a read becomes: its SQL, and the values that fill it in the + * order the placeholders appear. For a caller that must embed the read in a + * batch rather than run it. */ +export const readStatement = (read: Read): SqlStatement => { + const from = + typeof read.from === "string" ? { args: [], sql: read.from } : read.from; + return { + // Placeholder order, which is SQL order: the join's values, then the + // filters', then the cap. + args: [ + ...from.args, + ...clauseArgs(read.where ?? []), + ...(read.limit === undefined ? [] : [read.limit]), + ], + sql: + `SELECT ${read.columns} FROM ${from.sql}` + + whereSql(read.where ?? []) + + tail(" GROUP BY ", read.groupBy) + + tail(" ORDER BY ", read.order) + + (read.limit === undefined ? "" : " LIMIT ?"), + }; +}; + +/** Run a read and return every matching row. A filter that cannot match + * anything is already answered, so it costs no round trip. */ +export const readRows = (read: Read): Promise => + rowsUnlessNoneMatch(read.where ?? [], () => { + const { sql, args } = readStatement(read); + return queryAll(sql, args); + }); + +/** Run a read for at most one row. Capping the read is the whole difference + * from {@link readRows} — there is no second path for a single row. */ +export const readOneRow = async (read: Read): Promise => + (await readRows({ ...read, limit: 1 }))[0] ?? null; + +/** The `ORDER BY` body for a named order, or nothing when the caller does not + * care. Named so a caller cannot hand-roll a stray order of its own. */ +const namedOrder = ( + orders: Record, + order: Order | undefined, +): string | undefined => (order === undefined ? undefined : orders[order]); + +/** What a reader can be asked for: which rows, and which of its named orders + * they come back in. */ +export type NamedOrderQuery = { order?: Order }; + +/** The two things every reader offers: the statement, for a caller that must + * embed the read in a batch, and the rows, for everyone else. The row shape is + * the caller's to name, as it is with `queryAll`. */ +export type Reader = { + statement: (query: Query) => SqlStatement; + rows: (query: Query) => Promise; +}; + +/** + * Build a reader from its orders and one function saying what the read is. + * A collection's reader differs from another's only in those two things — the + * turning of a said read into a statement, and of a statement into rows, is + * the same work every time, so it is done here once. + */ +export const defineReader = < + Order extends string, + Query extends NamedOrderQuery, +>( + orders: Record, + readFor: (query: Query) => Omit, +): Reader => { + const read = (query: Query): Read => ({ + ...readFor(query), + order: namedOrder(orders, query.order), + }); + return { + rows: (query) => readRows(read(query)), + statement: (query) => readStatement(read(query)), + }; +}; diff --git a/src/shared/db/site-pages.ts b/src/shared/db/site-pages.ts index 393648f917..fabeeaa4b7 100644 --- a/src/shared/db/site-pages.ts +++ b/src/shared/db/site-pages.ts @@ -14,7 +14,7 @@ import { decrypt, encrypt } from "#shared/crypto/encryption.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; import type { BlindIndex } from "#shared/crypto/sealed.ts"; import { chooseColumns } from "#shared/db/chosen-columns.ts"; -import { queryOne, type TxScope, useTransaction } from "#shared/db/client.ts"; +import { type TxScope, useTransaction } from "#shared/db/client.ts"; import { idAndEncryptedSlugSchema } from "#shared/db/common-schema.ts"; import { encryptedNameAndSeoSchema } from "#shared/db/content-columns.ts"; import { defineIdTable } from "#shared/db/define-id-table.ts"; @@ -25,12 +25,7 @@ import { } from "#shared/db/slug-registry.ts"; import type { SluggedContentInput } from "#shared/db/slugged-content-input.ts"; import { cachedTable, col, writeTableRow } from "#shared/db/table.ts"; -import { - clauseArgs, - equals, - type WhereClause, - whereSql, -} from "#shared/db/where-clauses.ts"; +import { readerFor } from "#shared/db/table-reader.ts"; import { errorResult, okResult, type Result } from "#shared/result.ts"; import type { SitePage, SitePageNavRow } from "#shared/types.ts"; /* jscpd:ignore-end */ @@ -78,31 +73,17 @@ export const sitePageOrder = defineOrderedCollection({ table: "site_pages", }); -/** Every {@link SitePage} column, listed explicitly (AGENTS.md) so a future - * column can't silently widen what the single-page reads fetch and decrypt. */ -const SITE_PAGE_COLUMNS = - "id, slug, slug_index, name, meta_title, meta_description, content, sort_order"; - -/** Load one full page (fully decrypted) — for the public/admin single-page - * views. Null when absent. */ -const querySitePage = async ( - where: WhereClause[], -): Promise => { - const row = await queryOne( - `SELECT ${SITE_PAGE_COLUMNS} FROM site_pages${whereSql(where)} LIMIT 1`, - clauseArgs(where), - ); - return row ? rawSitePagesTable.fromDb(row) : null; -}; +/** The whole page, fully decrypted — for the public/admin single-page views. */ +const wholePage = readerFor(rawSitePagesTable); /** One full page by blind-index slug lookup (the `/page/:slug` read). */ export const getSitePageBySlugIndex = ( - slugIndex: string, -): Promise => querySitePage(equals("slug_index", slugIndex)); + slugIndex: BlindIndex, +): Promise => wholePage.one({ slug_index: slugIndex }); /** One full page by id (the admin edit read). */ export const getSitePageById = (id: number): Promise => - querySitePage(equals("id", id)); + wholePage.one({ id }); /** A create/update provides every editable column; the blind index is computed * HERE from the slug (never caller-supplied), so `slug` and `slug_index` move diff --git a/src/shared/db/table-reader.ts b/src/shared/db/table-reader.ts new file mode 100644 index 0000000000..46debcfda8 --- /dev/null +++ b/src/shared/db/table-reader.ts @@ -0,0 +1,134 @@ +/** + * Reading a table's own rows, with the filter written as the row. + * + * A table already declares its columns, their types, and how to open them. A + * read of that table should not have to say any of it again — so it doesn't: + * name the table and the reader knows what to select, what shape comes back, + * and how to decrypt it. + * + * The filter is the row's own shape, so a column that does not exist, or a + * value of the wrong type, is a mistake the compiler catches rather than the + * database. A read that joins, or that selects something SQL invents, has no + * table to infer from — those say their read with `readRows` instead. + */ + +import type { InValue } from "@libsql/client"; +import { + type ChosenColumns, + type ColumnNames, + chooseColumns, +} from "#shared/db/chosen-columns.ts"; +import type { Table, TableSchema } from "#shared/db/table.ts"; +import { equals, inList, type WhereClause } from "#shared/db/where-clauses.ts"; + +/** + * Which rows to keep, written as the row itself: a value means "equals this", + * a list means "is one of these". An absent column does not constrain, and an + * empty filter keeps every row. + * + * One item and many are the same filter — `{ id: 3 }` and `{ id: [3] }` differ + * only in how they read, never in which path runs. + */ +export type RowFilter = { + [Column in keyof Row]?: Row[Column] extends InValue + ? Row[Column] | readonly Row[Column][] + : never; +}; + +/** The rest of a read: how the rows come back, and how many. */ +export type RowOptions = { + /** An `ORDER BY` body without the keyword. Omit for no ordering. */ + order?: string; + /** A row cap. Omit for no limit. */ + limit?: number; +}; + +/** + * Turn a filter written as a row into the shared clause vocabulary. + * + * A column stored in a different form from the value the caller holds — an + * encrypted one — cannot be compared against that value: the database would + * match plaintext against ciphertext and quietly find nothing. That is refused + * rather than answered wrongly. Such a value is found by the column that + * carries its one-way code, which is stored as it is written. + */ +const filterClauses = ( + table: { name: string; schema: TableSchema }, + filter: RowFilter, +): WhereClause[] => + Object.entries(filter).flatMap(([column, value]) => { + if (table.schema[column as keyof Row]?.write !== undefined) { + throw new Error( + `Cannot filter ${table.name} by ${column}: it is stored in a different form from the value you have. Filter on the column holding its one-way code instead.`, + ); + } + return Array.isArray(value) + ? inList(column, value as readonly InValue[]) + : equals(column, value as Exclude); + }); + +/** + * Reading some rows of one table, or one of them. The filter covers the whole + * row — a read may narrow what it *selects* without narrowing what it can ask + * about — while `Selected` is what comes back. + */ +export type Rows = { + /** Every row the filter keeps. */ + many: (filter?: RowFilter, options?: RowOptions) => Promise; + /** The first row the filter keeps, or null when it keeps none. */ + one: ( + filter?: RowFilter, + options?: RowOptions, + ) => Promise; +}; + +/** Reading a table: its whole row, or just the columns a caller names. */ +export type TableReader = Rows & { + /** The same reads over a narrower set of columns — nothing else is selected, + * so nothing else is fetched or decrypted. */ + pick: >( + columns: Columns, + ) => Rows>; +}; + +const rowsOf = >( + table: Table, + chosen: ChosenColumns, +): Rows> => ({ + many: (filter = {}, options = {}) => + chosen.select({ ...options, where: filterClauses(table, filter) }), + one: (filter = {}, options = {}) => + chosen.selectOne({ ...options, where: filterClauses(table, filter) }), +}); + +/** + * The reader for a table. `pick` narrows the columns; calling the reader's own + * `one`/`many` reads the whole row. + */ +export const readerFor = ( + table: Table, +): TableReader => { + // A whole-row read can only promise the whole row when the row IS its stored + // columns. A table that works some of its values out from other tables has + // more in its row than it stores, so those reads must name their columns. + const workedOut = Object.keys(table.schema).filter( + (column) => table.schema[column as keyof Row]?.projected, + ); + if (workedOut.length > 0) { + throw new Error( + `${table.name} has values that are not stored columns (${workedOut.join(", ")}), so a whole-row read cannot return them. Choose the columns you want with chooseColumns instead.`, + ); + } + + const pick = >( + columns: Columns, + ): Rows> => + rowsOf(table, chooseColumns(table, columns)); + // A table's declared columns are exactly the keys of its row, and a table + // with no columns cannot be declared — so this is the whole row. + const whole = pick(table.columns as unknown as ColumnNames) as Rows< + Row, + Row + >; + return { ...whole, pick }; +}; diff --git a/src/shared/db/where-clauses.ts b/src/shared/db/where-clauses.ts index 2c17e621ac..1c06337291 100644 --- a/src/shared/db/where-clauses.ts +++ b/src/shared/db/where-clauses.ts @@ -57,6 +57,24 @@ export const equals = ( : [{ args: [value], clause: `${column} = ?` }]; }; +/** Keep rows whose `column` is (or is not) among the ones another query names. + * The subquery brings its own values, so they stay tied to its placeholders. */ +const bySubquery = + (keyword: string) => + (column: string, subquery: SqlStatement): WhereClause[] => [ + { args: subquery.args, clause: `${column} ${keyword} (${subquery.sql})` }, + ]; + +/** Keep or drop rows another query names. */ +type SubqueryFilter = (column: string, subquery: SqlStatement) => WhereClause[]; + +/** Keep rows the subquery names — "pick some rows, then read everything that + * hangs off them". */ +export const inSubquery: SubqueryFilter = bySubquery("IN"); + +/** Keep rows the subquery does NOT name. */ +export const notInSubquery: SubqueryFilter = bySubquery("NOT IN"); + /** Whether these clauses can never match a row — the ordinary cause being a * filter asking for none of something. */ const matchesNoRows = (parts: readonly WhereClause[]): boolean => @@ -78,30 +96,3 @@ export const whereSql = (parts: readonly WhereClause[]): string => /** Every clause's arguments, in clause order. */ export const clauseArgs = (parts: readonly WhereClause[]): InValue[] => parts.flatMap((part) => part.args); - -/** - * Everything after a read's columns and table: which rows, in what order, how - * many — with the values that fill them, in the order the placeholders appear. - * `order` is a constant belonging to the read, never caller input, so unlike a - * filter it carries no values of its own. - */ -export const queryTail = ( - where: readonly WhereClause[], - options: { order?: string; limit?: number } = {}, -): SqlStatement => ({ - args: [ - ...clauseArgs(where), - ...(options.limit === undefined ? [] : [options.limit]), - ], - sql: - whereSql(where) + - (options.order === undefined ? "" : ` ORDER BY ${options.order}`) + - (options.limit === undefined ? "" : " LIMIT ?"), -}); - -/** The ` ORDER BY …` tail for a named order, or nothing when the caller does - * not care how the rows come back. */ -export const orderSql = ( - orders: Record, - order: Order | undefined, -): string => (order === undefined ? "" : ` ORDER BY ${orders[order]}`); diff --git a/test/shared/accounting/rows.test.ts b/test/shared/accounting/rows.test.ts index 3232664852..fdb02d3123 100644 --- a/test/shared/accounting/rows.test.ts +++ b/test/shared/accounting/rows.test.ts @@ -114,7 +114,7 @@ describe("accounting > rows > stored-row round-trip", () => { insertStatement(voiding, recordedAt), ]); - const all = await selectTransfers(fromDb, " ORDER BY id", []); + const all = await selectTransfers(fromDb, { order: "id" }); expect(all.length).toBe(2); const [first, second] = all; @@ -143,7 +143,7 @@ describe("accounting > rows > stored-row round-trip", () => { source: account("attendee", 3), }; await executeBatch([insertStatement(kindless, recordedAt)]); - const [stored] = await selectTransfers(fromDb, "", []); + const [stored] = await selectTransfers(fromDb); // Omitted, not "": a stored transfer and a never-stored input must agree // on what "no kind" looks like (mirroring reverses_id). expect(stored!.kind).toBeUndefined(); diff --git a/test/shared/db/attendees/select.test.ts b/test/shared/db/attendees/select.test.ts index 7c6a8e9508..0763c16275 100644 --- a/test/shared/db/attendees/select.test.ts +++ b/test/shared/db/attendees/select.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for the attendee SELECT builder (`src/shared/db/attendees/select.ts`). * - * `attendeeColumns` and `attendeeFromWhere` are pure string builders — no DB — + * `attendeeColumns` and the statement builder are pure — no DB — * so they are tested directly here. This pins the field-selection contract (the * expensive ledger subqueries appear only when their field is requested) and * the filter/order SQL that every attendee-listing read now shares, so a mutant @@ -14,10 +14,30 @@ import { ATTENDEE_KIND, SERVICING_KIND } from "#shared/db/attendees/kind.ts"; import { ATTENDEE_FIELDS, type AttendeeField, + type AttendeeJoin, + type AttendeeOrder, + type AttendeeWhere, + attendeeBatchStatement, attendeeColumns, - attendeeFromWhere, } from "#shared/db/attendees/select.ts"; +/** The filter and order half of an attendee read — everything from `FROM` + * onwards — plus its bound values. Field selection is covered separately, so + * these ask for no fields and drop the column list. */ +const attendeeFromWhere = ( + join: AttendeeJoin, + where: AttendeeWhere, + order?: AttendeeOrder, +): { from: string; args: unknown[] } => { + const { sql, args } = attendeeBatchStatement({ + fields: [], + join, + where, + ...(order === undefined ? {} : { order }), + }); + return { args, from: sql.slice(sql.indexOf("FROM ")) }; +}; + const CORE_ALIASES = [ "attendee.id", "attendee.created", @@ -96,7 +116,7 @@ describe("attendeeColumns", () => { }); }); -describe("attendeeFromWhere", () => { +describe("attendee filter and order SQL", () => { test("uses the requested join keyword", () => { expect(attendeeFromWhere("inner", { attendeeIds: [1] }).from).toContain( "FROM attendees AS attendee JOIN listing_attendees AS listingAttendee", @@ -220,7 +240,7 @@ describe("attendeeFromWhere", () => { }); test("each named order maps to its ORDER BY, and omitting order omits the clause", () => { - const cases: [Parameters[2], string][] = [ + const cases: [AttendeeOrder, string][] = [ ["created_desc", "ORDER BY attendee.created DESC"], ["id_asc", "ORDER BY attendee.id ASC, listingAttendee.listing_id ASC"], ["id_desc", "ORDER BY attendee.id DESC, listingAttendee.listing_id ASC"], diff --git a/test/shared/db/listings/delete.test.ts b/test/shared/db/listings/delete.test.ts index 6fa9c25720..a77628def4 100644 --- a/test/shared/db/listings/delete.test.ts +++ b/test/shared/db/listings/delete.test.ts @@ -18,7 +18,7 @@ import { getListingWithCount, listingsTable, } from "#shared/db/listings/records.ts"; -import { listingStatement } from "#shared/db/listings/select.ts"; +import { listingReader } from "#shared/db/listings/select.ts"; import { isSessionProcessed, reserveSession, @@ -277,7 +277,7 @@ describeWithEnv("db > listings", { db: true, triggers: true }, () => { const listing = await createTestListing({ name: "Deleted listing" }); // The exact statement getAllListings issues, built the same way it is, so // the stubbed replica result cannot drift from the real read. - const listSql = listingStatement({ + const listSql = listingReader.statement({ order: "created_desc", where: {}, }).sql; diff --git a/test/shared/db/listings/select.test.ts b/test/shared/db/listings/select.test.ts index a9d55438ca..c55052d890 100644 --- a/test/shared/db/listings/select.test.ts +++ b/test/shared/db/listings/select.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for the listing SELECT builder (`src/shared/db/listings/select.ts`). * - * `listingStatement` is a pure string builder — no DB — so it is tested + * The listing reader's statement is a pure string builder — no DB — so it is tested * directly here. This pins the filter and order SQL that every listing-record * read now shares, so a mutant that drops a clause, loses a join, or mis-orders * the bound args is caught. @@ -9,7 +9,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { listingStatement } from "#shared/db/listings/select.ts"; +import { listingReader } from "#shared/db/listings/select.ts"; const LISTINGS_FROM = "FROM listings AS listing"; const GROUPS_FROM = @@ -24,15 +24,15 @@ const GROUPS_FROM = const tail = (sql: string): string => sql.slice(Math.max(sql.indexOf(LISTINGS_FROM), sql.indexOf(GROUPS_FROM))); -describe("listingStatement", () => { +describe("the listing reader's statement", () => { test("an empty filter reads every listing, unordered", () => { - const { sql, args } = listingStatement({ where: {} }); + const { sql, args } = listingReader.statement({ where: {} }); expect(tail(sql)).toBe(LISTINGS_FROM); expect(args).toEqual([]); }); test("always projects the money, day-price, image and count values", () => { - const { sql } = listingStatement({ where: {} }); + const { sql } = listingReader.statement({ where: {} }); for (const projection of [ "AS income", "AS cost", @@ -45,25 +45,29 @@ describe("listingStatement", () => { }); test("filters by id with one placeholder per id", () => { - const { sql, args } = listingStatement({ where: { ids: [7, 9] } }); + const { sql, args } = listingReader.statement({ where: { ids: [7, 9] } }); expect(tail(sql)).toBe(`${LISTINGS_FROM} WHERE listing.id IN (?, ?)`); expect(args).toEqual([7, 9]); }); test("an empty id list matches nothing and stays valid SQL", () => { - const { sql, args } = listingStatement({ where: { ids: [] } }); + const { sql, args } = listingReader.statement({ where: { ids: [] } }); expect(tail(sql)).toBe(`${LISTINGS_FROM} WHERE listing.id IN (NULL)`); expect(args).toEqual([]); }); test("filters by slug index", () => { - const { sql, args } = listingStatement({ where: { slugIndexes: ["abc"] } }); + const { sql, args } = listingReader.statement({ + where: { slugIndexes: ["abc"] }, + }); expect(tail(sql)).toBe(`${LISTINGS_FROM} WHERE listing.slug_index IN (?)`); expect(args).toEqual(["abc"]); }); test("an empty slug index list matches nothing", () => { - const { sql, args } = listingStatement({ where: { slugIndexes: [] } }); + const { sql, args } = listingReader.statement({ + where: { slugIndexes: [] }, + }); expect(tail(sql)).toBe( `${LISTINGS_FROM} WHERE listing.slug_index IN (NULL)`, ); @@ -71,7 +75,9 @@ describe("listingStatement", () => { }); test("reading by group joins the membership rows and names the groups", () => { - const { sql, args } = listingStatement({ where: { inGroups: [3, 4] } }); + const { sql, args } = listingReader.statement({ + where: { inGroups: [3, 4] }, + }); expect(sql).toContain( "json_group_array(groupListing.group_id) AS group_ids", ); @@ -82,28 +88,30 @@ describe("listingStatement", () => { }); test("a read that is not by group has no join, group column, or grouping", () => { - const { sql } = listingStatement({ where: { ids: [1] } }); + const { sql } = listingReader.statement({ where: { ids: [1] } }); expect(sql).not.toContain("group_listings"); expect(sql).not.toContain("group_ids"); expect(tail(sql)).not.toContain("GROUP BY"); }); test("keeps only active listings when asked", () => { - const { sql, args } = listingStatement({ where: { activeOnly: true } }); + const { sql, args } = listingReader.statement({ + where: { activeOnly: true }, + }); expect(tail(sql)).toBe(`${LISTINGS_FROM} WHERE listing.active = 1`); expect(args).toEqual([]); }); test("does not constrain on activity when not asked", () => { for (const where of [{}, { activeOnly: false }]) { - expect(tail(listingStatement({ where }).sql)).not.toContain( + expect(tail(listingReader.statement({ where }).sql)).not.toContain( "listing.active", ); } }); test("joins several filters with AND, args in clause order", () => { - const { sql, args } = listingStatement({ + const { sql, args } = listingReader.statement({ where: { activeOnly: true, ids: [5], inGroups: [8] }, }); expect(tail(sql)).toBe( @@ -115,14 +123,17 @@ describe("listingStatement", () => { }); test("orders newest first when asked", () => { - const { sql } = listingStatement({ order: "created_desc", where: {} }); + const { sql } = listingReader.statement({ + order: "created_desc", + where: {}, + }); expect(tail(sql)).toBe( `${LISTINGS_FROM} ORDER BY listing.created DESC, listing.id DESC`, ); }); test("puts the order after the grouping for a by-group read", () => { - const { sql } = listingStatement({ + const { sql } = listingReader.statement({ order: "created_desc", where: { inGroups: [1] }, }); diff --git a/test/shared/db/read.test.ts b/test/shared/db/read.test.ts new file mode 100644 index 0000000000..01407bde86 --- /dev/null +++ b/test/shared/db/read.test.ts @@ -0,0 +1,102 @@ +/** + * Unit tests for `src/shared/db/read.ts` — the one place a said read becomes + * SQL. These are pure string-and-args assertions with no database, so they pin + * the exact shape every reader now inherits: the order of the parts, and the + * order of the values that fill them. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { defineReader, readStatement } from "#shared/db/read.ts"; +import { equals, inList } from "#shared/db/where-clauses.ts"; + +describe("readStatement", () => { + test("says the plainest read with nothing but columns and a table", () => { + expect(readStatement({ columns: "id, name", from: "listings" })).toEqual({ + args: [], + sql: "SELECT id, name FROM listings", + }); + }); + + test("assembles the parts in SQL order", () => { + expect( + readStatement({ + columns: "listing.id", + from: "listings AS listing", + groupBy: "listing.id", + limit: 5, + order: "listing.created DESC", + where: equals("listing.active", 1), + }), + ).toEqual({ + args: [1, 5], + sql: + "SELECT listing.id FROM listings AS listing WHERE listing.active = ?" + + " GROUP BY listing.id ORDER BY listing.created DESC LIMIT ?", + }); + }); + + test("binds a join's own values before the filter's, then the cap", () => { + expect( + readStatement({ + columns: "question.id", + from: { + args: ["joined"], + sql: "questions AS question LEFT JOIN listing_questions AS listingQuestion ON listingQuestion.listing_id = ?", + }, + limit: 2, + where: equals("question.active", "filtered"), + }).args, + ).toEqual(["joined", "filtered", 2]); + }); + + test("omits every part the read does not ask for", () => { + const { sql } = readStatement({ columns: "id", from: "listings" }); + + expect(sql).not.toContain("WHERE"); + expect(sql).not.toContain("GROUP BY"); + expect(sql).not.toContain("ORDER BY"); + expect(sql).not.toContain("LIMIT"); + }); + + test("an empty filter list adds no WHERE", () => { + expect( + readStatement({ columns: "id", from: "listings", where: [] }).sql, + ).toBe("SELECT id FROM listings"); + }); + + test("a filter matching nothing still says so in SQL", () => { + // readRows skips the round trip, but a caller embedding this in a batch + // must still get valid SQL rather than an invalid `IN ()`. + expect( + readStatement({ + columns: "id", + from: "listings", + where: inList("id", []), + }).sql, + ).toBe("SELECT id FROM listings WHERE id IN (NULL)"); + }); +}); + +describe("defineReader", () => { + const reader = defineReader< + "newest" | "oldest", + { order?: "newest" | "oldest" } + >({ newest: "created DESC", oldest: "created ASC" }, () => ({ + columns: "id", + from: "listings", + })); + + test("each named order becomes its own ORDER BY", () => { + expect(reader.statement({ order: "newest" }).sql).toBe( + "SELECT id FROM listings ORDER BY created DESC", + ); + expect(reader.statement({ order: "oldest" }).sql).toBe( + "SELECT id FROM listings ORDER BY created ASC", + ); + }); + + test("omitting the order omits the clause", () => { + expect(reader.statement({}).sql).toBe("SELECT id FROM listings"); + }); +}); diff --git a/test/shared/db/table-reader.test.ts b/test/shared/db/table-reader.test.ts new file mode 100644 index 0000000000..b457f5a191 --- /dev/null +++ b/test/shared/db/table-reader.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for `src/shared/db/table-reader.ts` — reading a table with the filter + * written as the row. These use real tables, so the inference and the guards + * being claimed are the ones a caller actually gets. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { attributesTable } from "#shared/db/attributes.ts"; +import { rawListingsTable } from "#shared/db/listings/table.ts"; +import { readerFor } from "#shared/db/table-reader.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; + +describeWithEnv("db > table reader", { db: true }, () => { + const attributes = readerFor(attributesTable); + const add = (name: string, sortOrder = 0) => + attributesTable.insert({ name, sortOrder }); + + test("reads a whole row, decrypted, without naming its columns", async () => { + const made = await add("Whole"); + + const row = await attributes.one({ id: made.id }); + + // `name` is stored encrypted; the reader opens it because the table says to. + expect(row).toEqual({ id: made.id, name: "Whole", sort_order: 0 }); + }); + + test("a value filters by equals, a list by is-one-of", async () => { + const first = await add("First"); + const second = await add("Second"); + + const one = await attributes.many({ id: first.id }); + const both = await attributes.many({ id: [first.id, second.id] }); + + expect(one.map((row) => row.name)).toEqual(["First"]); + expect(both.map((row) => row.name).sort()).toEqual(["First", "Second"]); + }); + + test("several columns in one filter must all match", async () => { + const made = await add("Both", 7); + + expect(await attributes.one({ id: made.id, sort_order: 7 })).not.toBeNull(); + expect(await attributes.one({ id: made.id, sort_order: 8 })).toBeNull(); + }); + + test("an empty filter keeps every row", async () => { + await add("Only"); + + expect((await attributes.many()).map((row) => row.name)).toEqual(["Only"]); + }); + + test("asking for none of something asks the database nothing", async () => { + await add("Present"); + + expect(await attributes.many({ id: [] })).toEqual([]); + }); + + test("one returns null when the filter keeps nothing", async () => { + expect(await attributes.one({ id: 987_654 })).toBeNull(); + }); + + test("options order and cap the rows", async () => { + await add("Alpha"); + await add("Beta"); + + const rows = await attributes.many({}, { limit: 1, order: "id DESC" }); + + expect(rows.map((row) => row.name)).toEqual(["Beta"]); + }); + + test("pick selects only the named columns", async () => { + const made = await add("Narrow"); + + const row = await attributes.pick(["id", "name"]).one({ id: made.id }); + + expect(row).toEqual({ id: made.id, name: "Narrow" }); + }); + + test("a narrowed read can still filter on a column it does not select", async () => { + const made = await add("Filtered", 3); + + const row = await attributes + .pick(["name"]) + .one({ id: made.id, sort_order: 3 }); + + expect(row).toEqual({ name: "Filtered" }); + }); + + test("refuses to filter on a column stored in another form", async () => { + await add("Sealed"); + + // `name` is encrypted, so comparing it against the plain word would match + // nothing and read as "no such attribute" rather than as a mistake. It is + // refused as the call is made, before any query is built. + expect(() => attributes.one({ name: "Sealed" })).toThrow( + "Cannot filter attributes by name: it is stored in a different form", + ); + }); +}); + +describe("readerFor guards", () => { + test("refuses a table whose row is more than its stored columns", () => { + // listings works its image and day-price values out from other tables, so a + // whole-row read cannot return a whole Listing. + expect(() => readerFor(rawListingsTable)).toThrow( + "listings has values that are not stored columns", + ); + }); +}); diff --git a/test/shared/db/where-clauses.test.ts b/test/shared/db/where-clauses.test.ts index a5c229bfba..718c87aafe 100644 --- a/test/shared/db/where-clauses.test.ts +++ b/test/shared/db/where-clauses.test.ts @@ -12,7 +12,6 @@ import { clauseArgs, equals, inList, - orderSql, rowsUnlessNoneMatch, whereSql, } from "#shared/db/where-clauses.ts"; @@ -121,16 +120,3 @@ describe("clauseArgs", () => { expect(clauseArgs([])).toEqual([]); }); }); - -describe("orderSql", () => { - const ORDERS = { newest: "created DESC", oldest: "created ASC" }; - - test("is empty when the caller does not care about order", () => { - expect(orderSql(ORDERS, undefined)).toBe(""); - }); - - test("names the SQL for the chosen order", () => { - expect(orderSql(ORDERS, "newest")).toBe(" ORDER BY created DESC"); - expect(orderSql(ORDERS, "oldest")).toBe(" ORDER BY created ASC"); - }); -});