From 07b294d65454f9b73527b95326552c61665c5d76 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Sat, 6 Jun 2026 10:14:12 +0100 Subject: [PATCH 1/3] getFirst refactor: Set up before- tests --- libraries/db/src/lib/client.test.ts | 192 +++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 7 deletions(-) diff --git a/libraries/db/src/lib/client.test.ts b/libraries/db/src/lib/client.test.ts index 5ef37f4ff..ff1c09ef9 100644 --- a/libraries/db/src/lib/client.test.ts +++ b/libraries/db/src/lib/client.test.ts @@ -1,12 +1,190 @@ import { - describe, - expect, - test, + beforeAll, beforeEach, describe, expect, test, } from 'vitest'; +import { + PgAirtableDb, + createTestDbClients, + exerciseResponseTable, + personTable, + pushTestSchema, + resetTestDb, + type TestPgAirtableDb, + userTable, +} from '../index'; + +let db: PgAirtableDb; +let testDb: TestPgAirtableDb; + +beforeAll(async () => { + const { pgClient, airtableClient } = createTestDbClients(); + db = new PgAirtableDb({ + pgConnString: 'unused', + airtableApiKey: 'unused', + pgClient, + airtableClient, + }); + testDb = db as unknown as TestPgAirtableDb; + await pushTestSchema(db); +}); + +beforeEach(async () => resetTestDb(db)); + +describe('db.getFirst', () => { + describe('with autoNumberId table', () => { + test('returns null when no record matches', async () => { + const result = await db.getFirst(userTable, { filter: { email: 'nonexistent@example.com' } }); + expect(result).toBeNull(); + }); + + test('returns the matching record', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' }); + const result = await db.getFirst(userTable, { filter: { email: 'a@x' } }); + expect(result?.email).toBe('a@x'); + expect(result?.name).toBe('Alice'); + }); + + test('defaults to autoNumberId DESC (newest first) with no sortBy', async () => { + await testDb.insert(userTable, { + id: 'u1', email: 'shared@x', name: 'First', autoNumberId: 1, + }); + await testDb.insert(userTable, { + id: 'u2', email: 'shared@x', name: 'Second', autoNumberId: 2, + }); + await testDb.insert(userTable, { + id: 'u3', email: 'shared@x', name: 'Third', autoNumberId: 3, + }); + + const result = await db.getFirst(userTable, { filter: { email: 'shared@x' } }); + expect(result?.name).toBe('Third'); + }); + + test('explicit sortBy as string defaults to ASC', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'b@x', name: 'B' }); + await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'A' }); + await testDb.insert(userTable, { id: 'u3', email: 'c@x', name: 'C' }); + + const result = await db.getFirst(userTable, { sortBy: 'email' }); + expect(result?.email).toBe('a@x'); + }); + + test('explicit sortBy: autoNumberId as string defaults to DESC (special case)', async () => { + await testDb.insert(userTable, { + id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1, + }); + await testDb.insert(userTable, { + id: 'u2', email: 'a@x', name: 'B', autoNumberId: 2, + }); + + const result = await db.getFirst(userTable, { sortBy: 'autoNumberId' }); + expect(result?.autoNumberId).toBe(2); + }); + + test('explicit sortBy as object respects direction', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'b@x', name: 'B' }); + await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'A' }); + + const result = await db.getFirst(userTable, { sortBy: { field: 'email', direction: 'desc' } }); + expect(result?.email).toBe('b@x'); + }); + }); + + describe('with non-autoNumberId table', () => { + test('throws when sortBy is not provided', async () => { + // The type system enforces sortBy at compile time; runtime check is the safety net. + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db.getFirst(personTable, { filter: { email: 'a@x' } } as any), + ).rejects.toThrow(/autoNumberId for default sorting/); + }); + + test('works with explicit sortBy', async () => { + await testDb.insert(personTable, { id: 'p1', email: 'b@x' }); + await testDb.insert(personTable, { id: 'p2', email: 'a@x' }); + + const result = await db.getFirst(personTable, { sortBy: 'email' }); + expect(result?.email).toBe('a@x'); + }); + }); + + describe('filter handling', () => { + test('AND filter combines conditions', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' }); + await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'Bob' }); + + const result = await db.getFirst(userTable, { + filter: { AND: [{ email: 'a@x' }, { name: 'Bob' }] }, + }); + expect(result?.name).toBe('Bob'); + }); + + test('OR filter matches either branch', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' }); + + const result = await db.getFirst(userTable, { + filter: { OR: [{ email: 'a@x' }, { email: 'nonexistent@x' }] }, + }); + expect(result?.email).toBe('a@x'); + }); + + test('comparison operator >', async () => { + await testDb.insert(userTable, { + id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1, + }); + await testDb.insert(userTable, { + id: 'u2', email: 'a@x', name: 'B', autoNumberId: 5, + }); + await testDb.insert(userTable, { + id: 'u3', email: 'a@x', name: 'C', autoNumberId: 10, + }); + + // Default sort is autoNumberId DESC, so we get the highest match. + const result = await db.getFirst(userTable, { filter: { autoNumberId: { '>': 3 } } }); + expect(result?.autoNumberId).toBe(10); + }); + + test('comparison operator !=', async () => { + await testDb.insert(userTable, { + id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1, + }); + await testDb.insert(userTable, { + id: 'u2', email: 'b@x', name: 'B', autoNumberId: 2, + }); + + const result = await db.getFirst(userTable, { filter: { email: { '!=': 'a@x' } } }); + expect(result?.email).toBe('b@x'); + }); + + test('empty AND defensively returns no rows (matches no records)', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'A' }); + const result = await db.getFirst(userTable, { filter: { AND: [] } }); + expect(result).toBeNull(); + }); + + test('empty OR returns no rows', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'A' }); + const result = await db.getFirst(userTable, { filter: { OR: [] } }); + expect(result).toBeNull(); + }); + + test('throws on unknown field in filter', async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db.getFirst(userTable, { filter: { nonExistent: 'x' } as any }), + ).rejects.toThrow(/Unknown field/); + }); + }); + + describe('no-options invocation', () => { + test('with autoNumberId table, no options at all returns newest row', async () => { + await testDb.insert(exerciseResponseTable, { + id: 'r1', email: 'a@x', exerciseId: 'e1', response: 'first', autoNumberId: 1, + }); + await testDb.insert(exerciseResponseTable, { + id: 'r2', email: 'a@x', exerciseId: 'e2', response: 'second', autoNumberId: 2, + }); -// TODO -describe('dummy test', () => { - test('should pass', () => { - expect(true).toBe(true); + const result = await db.getFirst(exerciseResponseTable); + expect(result?.id).toBe('r2'); + }); }); }); From 05061e882e991cbf23ee692e88570a498af161e9 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Mon, 8 Jun 2026 08:00:29 +0100 Subject: [PATCH 2/3] WIP: Creating a postgres-compatible version of getFirst --- apps/website/src/server/routers/exercises.ts | 5 +- apps/website/src/server/routers/resources.ts | 4 +- libraries/db/.env.test | 5 + libraries/db/src/index.ts | 2 + libraries/db/src/lib/client.test.ts | 14 +- libraries/db/src/lib/client.ts | 208 +------------------ libraries/db/src/lib/pg-query.ts | 186 +++++++++++++++++ 7 files changed, 213 insertions(+), 211 deletions(-) create mode 100644 libraries/db/.env.test create mode 100644 libraries/db/src/lib/pg-query.ts diff --git a/apps/website/src/server/routers/exercises.ts b/apps/website/src/server/routers/exercises.ts index 1b47d4151..fc33d3492 100644 --- a/apps/website/src/server/routers/exercises.ts +++ b/apps/website/src/server/routers/exercises.ts @@ -7,6 +7,7 @@ import { eq, exerciseResponseTable, exerciseTable, + getFirstFromPg, groupTable, inArray, isNotNull, @@ -32,7 +33,7 @@ export const exercisesRouter = router({ getExerciseResponse: protectedProcedure .input(z.object({ exerciseId: z.string().min(1) })) .query(async ({ input, ctx }) => { - const exerciseResponse = await db.getFirst(exerciseResponseTable, { + const exerciseResponse = await getFirstFromPg(db.pg, exerciseResponseTable.pg, { filter: { exerciseId: input.exerciseId, email: ctx.auth.email }, }); @@ -54,7 +55,7 @@ export const exercisesRouter = router({ } // else undefined = "don't change" const [existingResponse, exercise, user] = await Promise.all([ - db.getFirst(exerciseResponseTable, { + getFirstFromPg(db.pg, exerciseResponseTable.pg, { filter: { exerciseId: input.exerciseId, email: ctx.auth.email }, }), input.completed === true diff --git a/apps/website/src/server/routers/resources.ts b/apps/website/src/server/routers/resources.ts index c47d25df8..0531d9b91 100644 --- a/apps/website/src/server/routers/resources.ts +++ b/apps/website/src/server/routers/resources.ts @@ -1,5 +1,5 @@ import { - and, courseBuilderUserTable, desc, eq, inArray, resourceCompletionTable, unitResourceTable, + and, courseBuilderUserTable, desc, eq, getFirstFromPg, inArray, resourceCompletionTable, unitResourceTable, } from '@bluedot/db'; import { RESOURCE_FEEDBACK } from '@bluedot/db/src/schema'; import { z } from 'zod'; @@ -55,7 +55,7 @@ export const resourcesRouter = router({ })) .mutation(async ({ input, ctx }) => { const [resourceCompletion, unitResource, cbUser] = await Promise.all([ - db.getFirst(resourceCompletionTable, { + getFirstFromPg(db.pg, resourceCompletionTable.pg, { filter: { unitResourceId: input.unitResourceId, email: ctx.auth.email, diff --git a/libraries/db/.env.test b/libraries/db/.env.test new file mode 100644 index 000000000..e3219463e --- /dev/null +++ b/libraries/db/.env.test @@ -0,0 +1,5 @@ +APP_NAME=db-tests +PG_URL=postgresql://fake:fake@localhost:5432/fake +AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN +ALERTS_SLACK_CHANNEL_ID=FAKE_CHANNEL +ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN diff --git a/libraries/db/src/index.ts b/libraries/db/src/index.ts index 1b8a0fb17..1b70bb2eb 100644 --- a/libraries/db/src/index.ts +++ b/libraries/db/src/index.ts @@ -1,5 +1,7 @@ export { PgAirtableDb } from './lib/client'; export type { PgDatabase } from './lib/client'; +export { getFirstFromPg } from './lib/pg-query'; +export type { GetFirstFromPgOptions } from './lib/pg-query'; export { createTestPgClient, createTestAirtableClient, createTestDbClients, pushTestSchema, resetTestDb, } from './lib/test-db'; diff --git a/libraries/db/src/lib/client.test.ts b/libraries/db/src/lib/client.test.ts index ff1c09ef9..d0070711e 100644 --- a/libraries/db/src/lib/client.test.ts +++ b/libraries/db/src/lib/client.test.ts @@ -91,10 +91,9 @@ describe('db.getFirst', () => { describe('with non-autoNumberId table', () => { test('throws when sortBy is not provided', async () => { // The type system enforces sortBy at compile time; runtime check is the safety net. - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - db.getFirst(personTable, { filter: { email: 'a@x' } } as any), - ).rejects.toThrow(/autoNumberId for default sorting/); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const call = db.getFirst(personTable, { filter: { email: 'a@x' } } as any); + await expect(call).rejects.toThrow(/autoNumberId for default sorting/); }); test('works with explicit sortBy', async () => { @@ -167,10 +166,9 @@ describe('db.getFirst', () => { }); test('throws on unknown field in filter', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - db.getFirst(userTable, { filter: { nonExistent: 'x' } as any }), - ).rejects.toThrow(/Unknown field/); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const call = db.getFirst(userTable, { filter: { nonExistent: 'x' } as any }); + await expect(call).rejects.toThrow(/Unknown field/); }); }); diff --git a/libraries/db/src/lib/client.ts b/libraries/db/src/lib/client.ts index 62a9d6d20..0eff2a0fd 100644 --- a/libraries/db/src/lib/client.ts +++ b/libraries/db/src/lib/client.ts @@ -1,17 +1,19 @@ -import { - eq, and, or, gt, lt, gte, lte, ne, sql, type SQL, desc, asc, -} from 'drizzle-orm'; -import { type PgInsertValue, type PgUpdateSetSource, type PgColumn } from 'drizzle-orm/pg-core'; +import { eq } from 'drizzle-orm'; +import { type PgInsertValue, type PgUpdateSetSource } from 'drizzle-orm/pg-core'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { type drizzle as pgLiteDrizzle } from 'drizzle-orm/pglite'; import { AirtableTs, AirtableTsError, type AirtableTsOptions, } from 'airtable-ts'; import { ErrorType } from 'airtable-ts/dist/AirtableTsError'; import { type PgAirtableTable } from './db-core'; import { type AirtableItemFromColumnsMap, type BasePgTableType, type PgAirtableColumnInput } from './typeUtils'; +import { + buildWhereClause, getFirstFromPg, type Filter, type PgDatabase, +} from './pg-query'; import env from './env'; +export { type Filter, type PgDatabase } from './pg-query'; + /** * Base options interface for getFirst method */ @@ -56,28 +58,6 @@ export type GetFirstOptionsWithoutAutoId['$inferSelect']; }; -/** - * Filter operations for querying records - */ -export type FilterOperation = { - [K in keyof T]?: - | T[K] - | { '>': T[K] } - | { '<': T[K] } - | { '>=': T[K] } - | { '<=': T[K] } - | { '=': T[K] } - | { '!=': T[K] }; -}; - -export type Filter = FilterOperation | { - AND: Filter[]; -} | { - OR: Filter[]; -}; - -export type PgDatabase = ReturnType | ReturnType; - /** * Postgres client which is identical to the standard client in terms of functionality, but * with deprecated write functions to warn developers not to use them directly. @@ -183,7 +163,7 @@ export class PgAirtableDb { const baseQuery = this.pgUnrestricted.select().from(table.pg as any); if (filter) { - const whereClause = this.buildWhereClause(table.pg, filter); + const whereClause = buildWhereClause(table.pg, filter); return await baseQuery.where(whereClause) as BasePgTableType['$inferSelect'][]; } @@ -219,177 +199,7 @@ export class PgAirtableDb { : [options: GetFirstOptionsWithoutAutoId] ): Promise['$inferSelect'] | null> { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const options = args[0] || {}; - const { - filter, - sortBy, - limit = 1, - } = options; - - const sortConfig = this.resolveSortConfig(table, sortBy); - - if (!sortConfig) { - const availableFields = Object.keys(table.pg).join(', '); - throw new Error('Table does not have autoNumberId for default sorting. ' - + `Please specify a sortBy field. Available fields: ${availableFields}\n`); - } - - // Build query with sorting - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const baseQuery = this.pgUnrestricted.select().from(table.pg as any); - - // Apply sorting - const fieldKey = sortConfig.field as string; - const column = (table.pg as Record)[fieldKey]; - if (!column) { - throw new Error(`Field "${String(sortConfig.field)}" does not exist on table`); - } - - // Build the final query with all clauses - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let query: any = baseQuery; - - if (filter) { - query = query.where(this.buildWhereClause(table.pg, filter)); - } - - query = sortConfig.direction === 'desc' - ? query.orderBy(desc(column)) - : query.orderBy(asc(column)); - - // Apply limit and execute - query = query.limit(limit); - const results = await query as BasePgTableType['$inferSelect'][]; - - return results.length > 0 ? results[0]! : null; - } - - /** - * Resolves sort configuration for getFirst, with intelligent defaults - */ - private resolveSortConfig< - TTableName extends string, - TColumnsMap extends Record, - >( - table: PgAirtableTable, - sortBy?: keyof BasePgTableType['$inferSelect'] | { field: keyof BasePgTableType['$inferSelect']; direction?: 'asc' | 'desc' }, - ): { field: keyof BasePgTableType['$inferSelect']; direction: 'asc' | 'desc' } | null { - // If explicit sortBy provided, use it - if (sortBy) { - if (typeof sortBy === 'string' || typeof sortBy === 'symbol' || typeof sortBy === 'number') { - const field = sortBy as keyof BasePgTableType['$inferSelect']; - return { - field, - // Default to DESC for autoNumberId (newest first), ASC for others - direction: field === 'autoNumberId' ? 'desc' : 'asc', - }; - } - - return { - field: sortBy.field, - direction: sortBy.direction ?? (sortBy.field === 'autoNumberId' ? 'desc' : 'asc'), - }; - } - - // Check for autoNumberId as default - if ('autoNumberId' in table.pg) { - return { field: 'autoNumberId', direction: 'desc' }; - } - - // No default available - return null; - } - - /** - * Build a WHERE clause from a filter object - */ - private buildWhereClause( - table: Record, - filter: Filter>, - ): SQL { - if ('AND' in filter && Array.isArray(filter.AND)) { - if (filter.AND.length === 0) { - // Mathematically this should be TRUE (empty conjunction), but we return FALSE - // defensively to avoid accidentally returning rows the caller doesn't have - // permission to see. If a live code path needs empty AND to match everything, - // change this to TRUE. - return sql`FALSE`; - } - - const conditions = filter.AND.map((f: Filter>) => this.buildWhereClause(table, f)); - const result = and(...conditions); - if (!result) { - throw new Error('Failed to build AND condition'); - } - - return result; - } - - if ('OR' in filter && Array.isArray(filter.OR)) { - if (filter.OR.length === 0) { - return sql`FALSE`; - } - - const conditions = filter.OR.map((f: Filter>) => this.buildWhereClause(table, f)); - const result = or(...conditions); - if (!result) { - throw new Error('Failed to build OR condition'); - } - - return result; - } - - // Handle field-level filters - const conditions: SQL[] = []; - - for (const [fieldName, fieldFilter] of Object.entries(filter)) { - const column = table[fieldName]; - if (!column) { - throw new Error(`Unknown field: ${fieldName}`); - } - - if (fieldFilter && typeof fieldFilter === 'object' && !Array.isArray(fieldFilter)) { - // Handle operation objects like { $gt: value } - for (const [op, value] of Object.entries(fieldFilter)) { - switch (op) { - case '>': - conditions.push(gt(column, value)); - break; - case '<': - conditions.push(lt(column, value)); - break; - case '>=': - conditions.push(gte(column, value)); - break; - case '<=': - conditions.push(lte(column, value)); - break; - case '=': - conditions.push(eq(column, value)); - break; - case '!=': - conditions.push(ne(column, value)); - break; - default: - throw new Error(`Unknown operation: ${op}`); - } - } - } else { - // Handle direct equality - conditions.push(eq(column, fieldFilter)); - } - } - - if (conditions.length === 0) { - throw new Error('No valid filter conditions found'); - } - - const result = conditions.length === 1 ? conditions[0] : and(...conditions); - if (!result) { - throw new Error('Failed to build WHERE condition'); - } - - return result; + return getFirstFromPg(this.pgUnrestricted, table.pg, args[0] || {}); } /** diff --git a/libraries/db/src/lib/pg-query.ts b/libraries/db/src/lib/pg-query.ts new file mode 100644 index 000000000..d536ef711 --- /dev/null +++ b/libraries/db/src/lib/pg-query.ts @@ -0,0 +1,186 @@ +import { + eq, and, or, gt, lt, gte, lte, ne, sql, desc, asc, + type SQL, +} from 'drizzle-orm'; +import { type PgColumn, type PgTable } from 'drizzle-orm/pg-core'; +import { type drizzle } from 'drizzle-orm/node-postgres'; +import { type drizzle as pgLiteDrizzle } from 'drizzle-orm/pglite'; + +export type PgDatabase = ReturnType | ReturnType; + +/** + * Filter operations for querying records + */ +export type FilterOperation = { + [K in keyof T]?: + | T[K] + | { '>': T[K] } + | { '<': T[K] } + | { '>=': T[K] } + | { '<=': T[K] } + | { '=': T[K] } + | { '!=': T[K] }; +}; + +export type Filter = FilterOperation | { + AND: Filter[]; +} | { + OR: Filter[]; +}; + +export type GetFirstFromPgOptions = { + filter?: Filter; + sortBy?: keyof T['$inferSelect'] | { field: keyof T['$inferSelect']; direction?: 'asc' | 'desc' }; + limit?: number; +}; + +/** + * Get the first record matching the optional filter from a plain drizzle pgTable. + * + * For tables with autoNumberId: defaults to sorting by autoNumberId DESC (newest first). + * For tables without autoNumberId: sortBy must be provided, or this throws. + */ +export async function getFirstFromPg( + pgDb: PgDatabase, + table: T, + options: GetFirstFromPgOptions = {}, +): Promise { + const { filter, sortBy, limit = 1 } = options; + const columns = table as unknown as Record; + + const sortConfig = resolveSortConfig(table, sortBy); + if (!sortConfig) { + const availableFields = Object.keys(columns).join(', '); + throw new Error('Table does not have autoNumberId for default sorting. ' + + `Please specify a sortBy field. Available fields: ${availableFields}\n`); + } + + const sortColumn = columns[sortConfig.field as string]; + if (!sortColumn) { + throw new Error(`Field "${String(sortConfig.field)}" does not exist on table`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let query = pgDb.select().from(table as any).$dynamic(); + if (filter) { + query = query.where(buildWhereClause(table, filter)); + } + + query = sortConfig.direction === 'desc' ? query.orderBy(desc(sortColumn)) : query.orderBy(asc(sortColumn)); + query = query.limit(limit); + + const results = await query as T['$inferSelect'][]; + return results.length > 0 ? results[0]! : null; +} + +/** + * Resolves sort configuration for getFirstFromPg, with intelligent defaults. + */ +export function resolveSortConfig( + table: T, + sortBy?: keyof T['$inferSelect'] | { field: keyof T['$inferSelect']; direction?: 'asc' | 'desc' }, +): { field: keyof T['$inferSelect']; direction: 'asc' | 'desc' } | null { + if (sortBy) { + if (typeof sortBy === 'string' || typeof sortBy === 'symbol' || typeof sortBy === 'number') { + const field = sortBy as keyof T['$inferSelect']; + return { + field, + // Default to DESC for autoNumberId (newest first), ASC for others + direction: field === 'autoNumberId' ? 'desc' : 'asc', + }; + } + + return { + field: sortBy.field, + direction: sortBy.direction ?? (sortBy.field === 'autoNumberId' ? 'desc' : 'asc'), + }; + } + + if ('autoNumberId' in table) { + return { field: 'autoNumberId' as keyof T['$inferSelect'], direction: 'desc' }; + } + + return null; +} + +/** + * Build a WHERE clause from a filter object. + */ +export function buildWhereClause( + table: T, + filter: Filter, +): SQL { + const columns = table as unknown as Record; + return buildWhereClauseInner(columns, filter as Filter>); +} + +function buildWhereClauseInner( + columns: Record, + filter: Filter>, +): SQL { + if ('AND' in filter && Array.isArray(filter.AND)) { + if (filter.AND.length === 0) { + // Mathematically this should be TRUE (empty conjunction), but we return FALSE + // defensively to avoid accidentally returning rows the caller doesn't have + // permission to see. If a live code path needs empty AND to match everything, + // change this to TRUE. + return sql`FALSE`; + } + + const conditions = filter.AND.map((f) => buildWhereClauseInner(columns, f)); + const result = and(...conditions); + if (!result) throw new Error('Failed to build AND condition'); + return result; + } + + if ('OR' in filter && Array.isArray(filter.OR)) { + if (filter.OR.length === 0) return sql`FALSE`; + + const conditions = filter.OR.map((f) => buildWhereClauseInner(columns, f)); + const result = or(...conditions); + if (!result) throw new Error('Failed to build OR condition'); + return result; + } + + const conditions: SQL[] = []; + + for (const [fieldName, fieldFilter] of Object.entries(filter)) { + const column = columns[fieldName]; + if (!column) throw new Error(`Unknown field: ${fieldName}`); + + if (fieldFilter && typeof fieldFilter === 'object' && !Array.isArray(fieldFilter)) { + for (const [op, value] of Object.entries(fieldFilter)) { + switch (op) { + case '>': + conditions.push(gt(column, value)); + break; + case '<': + conditions.push(lt(column, value)); + break; + case '>=': + conditions.push(gte(column, value)); + break; + case '<=': + conditions.push(lte(column, value)); + break; + case '=': + conditions.push(eq(column, value)); + break; + case '!=': + conditions.push(ne(column, value)); + break; + default: + throw new Error(`Unknown operation: ${op}`); + } + } + } else { + conditions.push(eq(column, fieldFilter)); + } + } + + if (conditions.length === 0) throw new Error('No valid filter conditions found'); + + const result = conditions.length === 1 ? conditions[0] : and(...conditions); + if (!result) throw new Error('Failed to build WHERE condition'); + return result; +} From 83d76577154cb5cb917351adacc9fe3ce136f5a9 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Mon, 8 Jun 2026 10:01:02 +0100 Subject: [PATCH 3/3] Address AI comments --- libraries/db/src/lib/client.test.ts | 24 ++++++++++++++++++++++++ libraries/db/src/lib/pg-query.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/libraries/db/src/lib/client.test.ts b/libraries/db/src/lib/client.test.ts index d0070711e..5405a10c3 100644 --- a/libraries/db/src/lib/client.test.ts +++ b/libraries/db/src/lib/client.test.ts @@ -5,6 +5,7 @@ import { PgAirtableDb, createTestDbClients, exerciseResponseTable, + getFirstFromPg, personTable, pushTestSchema, resetTestDb, @@ -186,3 +187,26 @@ describe('db.getFirst', () => { }); }); }); + +describe('getFirstFromPg (direct)', () => { + test('returns the matching record with a raw pgClient and PgTable', async () => { + await testDb.insert(userTable, { id: 'u1', email: 'direct@x', name: 'Direct' }); + + const result = await getFirstFromPg(db.pg, userTable.pg, { + filter: { email: 'direct@x' }, + }); + expect(result?.id).toBe('u1'); + expect(result?.name).toBe('Direct'); + }); + + test('returns null when no record matches', async () => { + const result = await getFirstFromPg(db.pg, userTable.pg, { + filter: { email: 'nobody@x' }, + }); + expect(result).toBeNull(); + }); + + test('throws when table has no autoNumberId and no sortBy is provided', async () => { + await expect(getFirstFromPg(db.pg, personTable.pg)).rejects.toThrow(/autoNumberId for default sorting/); + }); +}); diff --git a/libraries/db/src/lib/pg-query.ts b/libraries/db/src/lib/pg-query.ts index d536ef711..c857f6e70 100644 --- a/libraries/db/src/lib/pg-query.ts +++ b/libraries/db/src/lib/pg-query.ts @@ -76,7 +76,7 @@ export async function getFirstFromPg( /** * Resolves sort configuration for getFirstFromPg, with intelligent defaults. */ -export function resolveSortConfig( +function resolveSortConfig( table: T, sortBy?: keyof T['$inferSelect'] | { field: keyof T['$inferSelect']; direction?: 'asc' | 'desc' }, ): { field: keyof T['$inferSelect']; direction: 'asc' | 'desc' } | null {