// What a search hands to the database: SQL text and its parameters.
type Query = { sql: string; params: string[] };
// The fields a search may filter or sort by. A value travels as a
// parameter, but a field name is written into the SQL, so only these are allowed.
const fields = ['name', 'kind', 'place', 'nights'];
type Filter = { field: string; value: string };
// Each part of the page (search box, filter chips, sort menu, pager) calls
// the search as the user acts; the list page calls build to load results.
// The search keeps a draft; build writes the SQL in SQL's order, whatever
// order the parts arrived in, and numbers the parameters.
class TripSearch {
private filters: Filter[] = [];
private sort: string | null = null;
private pageNumber: number | null = null;
filter(field: string, value: string): void {
this.filters.push({ field, value });
}
orderBy(field: string): void {
this.sort = field;
}
page(n: number): void {
this.pageNumber = n;
}
build(): Query {
const named = this.filters.map((f) => f.field);
if (this.sort !== null) named.push(this.sort);
for (const field of named)
if (!fields.includes(field)) throw new Error(`unknown field: ${field}`);
if (this.pageNumber !== null) {
if (this.pageNumber < 1) throw new Error('page must be 1 or more');
if (this.sort === null) throw new Error('page needs an order');
}
let sql = 'SELECT * FROM trips';
if (this.filters.length > 0)
sql += ' WHERE ' + this.filters.map((f, i) => `${f.field} = $${i + 1}`).join(' AND ');
if (this.sort !== null) sql += ` ORDER BY ${this.sort}`;
if (this.pageNumber !== null) sql += ` LIMIT 20 OFFSET ${(this.pageNumber - 1) * 20}`;
return { sql, params: this.filters.map((f) => f.value) };
}
}
The starter wrote its product as the parts arrived, so the product’s shape followed the user’s clicks. The fix gives the assembly a draft: filter appends to a list, orderBy and page replace a single choice, and nothing is written until build. Build then owns everything that needs the whole draft: the clause order SQL demands, the parameter numbers, which depend on how many filters came before, and the rules that relate parts (a page needs an order, wherever in the sequence each arrived). It is the trip builder’s handoff with a product whose structure differs from the order of the steps. Values are parameters, never text in the SQL; field names cannot be, so build checks them against an allowlist. Build reads the draft without changing it, so a rejected draft can be repaired, and each query owns its own parameter list.