// Kysely spike for #297. Deliberately the same target #216 set for Drizzle: // buildItemFilterSql — six optional clauses composed at run time, a recursive // CTE for the category subtree, an ANY(...::int[]) tag match with a count // equality, and array parameters. // // Nothing here connects to a database. Kysely compiles to { sql, parameters } // without a connection, which is the whole point: the questions being asked are // about what SQL comes out and where the values end up. import { Kysely, PostgresDialect, sql, SqlBool, expressionBuilder } from 'kysely'; // What kysely-codegen would generate, hand-written for the four tables this // query touches. Note the names: snake_case, exactly as the database spells // them and exactly as these APIs answer. interface DB { items: { id: number; category_id: number | null; price_cents: number; status: string; }; item_tags: { item_id: number; tag_id: number }; categories: { id: number; parent_id: number | null }; favorites: { customer_id: number; item_id: number }; } const db = new Kysely({ dialect: new PostgresDialect({ pool: {} as never }) }); interface SpikeFilters { categoryIds: number[]; tagIds: number[]; minPriceCents: number | null; maxPriceCents: number | null; status: string[] | null; favoritesOnly: boolean; } function buildItemFilterKysely(filters: SpikeFilters, favoritesCustomerId: number | null) { const eb = expressionBuilder(); const clauses = []; // The recursive CTE, inside an IN (...) subquery. // // ${filters.categoryIds} emits a BIND PARAMETER, and — the question this // spike exists to answer — it emits ONE parameter for the whole array, not a // placeholder list. No sql.param() equivalent is needed. if (filters.categoryIds.length) { clauses.push(sql`items.category_id IN ( WITH RECURSIVE subtree AS ( SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[]) UNION ALL SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id ) SELECT id FROM subtree )`); } // AND, not OR: the item must carry every selected tag. if (filters.tagIds.length) { clauses.push(sql`( SELECT COUNT(*) FROM item_tags it WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[]) ) = ${filters.tagIds.length}`); } if (filters.minPriceCents !== null) clauses.push(eb('items.price_cents', '>=', filters.minPriceCents)); if (filters.maxPriceCents !== null) clauses.push(eb('items.price_cents', '<=', filters.maxPriceCents)); if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status)); if (filters.favoritesOnly && favoritesCustomerId !== null) { clauses.push( eb.exists( eb .selectFrom('favorites') .select('favorites.item_id') .whereRef('favorites.item_id', '=', 'items.id') .where('favorites.customer_id', '=', favoritesCustomerId) ) ); } return clauses; } function show(label: string, compiled: { sql: string; parameters: readonly unknown[] }) { console.log(`\n=== ${label} ===`); console.log(compiled.sql.replace(/\s+/g, ' ').trim()); console.log('parameters:', JSON.stringify(compiled.parameters)); } const filters: SpikeFilters = { categoryIds: [3, 7], tagIds: [11, 12], minPriceCents: 1000, maxPriceCents: 50000, status: ['available', 'reserved'], favoritesOnly: true }; const query = db .selectFrom('items') .select(['items.id', 'items.status', 'items.price_cents']) .where((eb) => eb.and(buildItemFilterKysely(filters, 42))); show('every clause at once', query.compile()); // Question 2: what does a hostile value do? #202's invariant is that only // placeholder indices may reach the SQL text. const hostile = db .selectFrom('items') .select('items.id') .where('items.status', '=', "1); DROP TABLE items; --"); show('hostile value in a status filter', hostile.compile()); // Question 3: the trap that cost #218 a silently wrong count in Drizzle — a // correlated subquery referencing a column of the outer table. const correlated = db .selectFrom('categories') .select([ 'categories.id', sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as('item_count') ]); show('correlated subquery', correlated.compile()); // Question 4: does an empty array still produce one parameter? const emptyish = db .selectFrom('items') .select('items.id') .where(sql`items.id = ANY(${[] as number[]}::int[])`); show('empty array', emptyish.compile());