Why can dynamically composed AND/OR filter fragments leak rows, and how do you compose them safely?
answer
- each fragment is correct on its own
- the concatenation is not
- one connective outranks the other
- a top-level OR escapes the scope filter
- wrap every fragment in parentheses
basics
~20 sAND binds tighter than OR, so an unparenthesized OR fragment escapes the conditions concatenated before it — including tenant or ownership scoping. Wrap every generated fragment in its own parentheses and join fragments with AND.
solid answer
~50 sFilter builders concatenate independently-written fragments into one boolean expression, and precedence then re-groups them in ways no single fragment's author anticipated. If the scope clause emits `tenant_id = 7` and a search fragment emits `status = 'open' OR status = 'closed'`, the concatenation `tenant_id = 7 AND status = 'open' OR status = 'closed'` parses as `(tenant_id = 7 AND status = 'open') OR status = 'closed'` — every closed ticket in every tenant is returned. The rule that fixes it is compositional: each fragment is parenthesized by construction before it is joined, and fragments are joined with `AND`, so no fragment can ever alter another's grouping. Enforce that in the builder rather than in each call site, keep mandatory scope predicates outside the composable set entirely, and cover it with a test asserting that a scoped query returns nothing outside its scope for every filter combination.
code
sql · 6 lines-- Generated by joining fragments without parentheses
SELECT ticket_id, tenant_id, status
FROM tickets
WHERE tenant_id = 7 AND status = 'open' OR status = 'closed';
-- parses as: (tenant_id = 7 AND status = 'open') OR status = 'closed'
-- every tenant's closed tickets are returnedgo deeper
Recognize the shape: a condition list containing an unparenthesized OR returns more rows than intended. Know that wrapping the OR group in parentheses restores the meaning.
Explain the parse of the generated clause and give the construction rule — parenthesize each fragment, join with AND — rather than advising care. Be able to rewrite the broken clause on the spot.
Frame it as a scope-leak defect that only appears for certain filter combinations, propose keeping mandatory scoping out of the composable set, and describe the invariant test across filter combinations that catches it.
Own the design: filter composition as a typed builder with parenthesization as an invariant, scoping enforced at a layer queries cannot bypass, generated SQL logged in a readable shape, and a standing test that no filter combination widens scope.
## The failure Any code that assembles a `WHERE` clause from parts — a search endpoint with optional filters, a saved-report builder, a repository layer that appends conditions — is building one boolean expression out of fragments written at different times by different people. Each fragment is correct in isolation. The composition is where the bug lives. ```sql -- scope fragment: tenant_id = 7 -- status fragment: status = 'open' OR status = 'closed' SELECT * FROM tickets WHERE tenant_id = 7 AND status = 'open' OR status = 'closed'; ``` Because `AND` binds tighter than `OR`, this parses as: ```sql WHERE (tenant_id = 7 AND status = 'open') OR status = 'closed' ``` Every closed ticket in the database is returned, from every tenant. The query does not error, it returns more rows than expected, and the extra rows are precisely the ones the scope predicate existed to exclude. When the scope is a tenant, a customer or an owning user, this is a data-exposure defect rather than a reporting nuisance. ## Why it survives review The fragment authors are not wrong. Whoever wrote `status = 'open' OR status = 'closed'` wrote a valid predicate; whoever wrote `tenant_id = 7` wrote a valid predicate. The defect exists only in the concatenated string, which frequently no human ever reads — it is built at runtime from whichever optional filters the caller supplied. Worse, the combination that breaks may require two specific optional filters to be active at once, so it is invisible until a user picks that combination. ## The compositional rule The fix is a construction rule, not a review habit: 1. **Parenthesize every fragment as it is produced.** A fragment's text is `(` + predicate + `)`. Then its internal connectives cannot escape. 2. **Join fragments with `AND` only.** If a filter needs alternatives, the alternatives live *inside* one fragment, already wrapped. 3. **Never let a caller pass raw predicate text into the join.** Fragments come from the builder's own vocabulary, so the parenthesization invariant holds by construction. Applied to the example: ```sql SELECT * FROM tickets WHERE (tenant_id = 7) AND (status = 'open' OR status = 'closed'); ``` With this rule, a fragment added next year cannot change the meaning of a fragment written today. That is the property you actually want — local correctness composing into global correctness. ## Keep the mandatory scope out of the composable set A tenant or ownership predicate is not an optional filter and should not be an element of the same list the optional filters go into. Emit it as a fixed prefix that the composition cannot touch, or better, remove it from the composition problem entirely by scoping at a level the builder cannot bypass — a view that already restricts rows, or a required parameter the builder always applies. The general principle: a security-relevant predicate should not depend on a string-assembly step being right. ## The 1=1 idiom, and its limit A common trick is to start the clause with a constant true predicate so every fragment can be appended uniformly: ```sql WHERE 1 = 1 AND (tenant_id = 7) AND (status = 'open' OR status = 'closed') ``` This removes the "is this the first fragment?" special case that produces stray `AND`s and dangling clauses. It is a readability and code-simplicity aid only — it does nothing about precedence. Without the inner parentheses, `WHERE 1 = 1 AND tenant_id = 7 AND status = 'open' OR status = 'closed'` is just as broken. ## Formatting that exposes the shape When the generated SQL is logged or shown in a debug view, format it one condition per line with the connective leading: ```sql WHERE (tenant_id = 7) AND (created_on >= DATE '2026-01-01') AND (status = 'open' OR status = 'closed') ``` A stray top-level `OR` is then immediately visible in the log, which turns an invisible composition bug into something a developer notices while debugging something else. ## Test it like a boundary, not like a filter Unit tests for filter builders usually assert the happy path: "filtering by status returns open tickets". The test that catches this class of bug asserts the *invariant*: for every combination of optional filters, the result contains no row outside the caller's scope. Seed two tenants, run the combinations, assert that the foreign tenant's rows never appear. That test fails loudly on the day someone adds an OR fragment without parentheses. ## What interviewers listen for They want the precedence explanation applied to composition, the recognition that the leaked rows are exactly the scoped-out ones, a construction-level fix (parenthesize by construction, join with AND) rather than "be careful", the point that mandatory scoping should not live in the composable set at all, and a test strategy that asserts the invariant across filter combinations.
- Does starting the clause with WHERE 1 = 1 make the composition safe?No. It only removes the special case for the first fragment so every fragment can be appended as `AND <fragment>`, which simplifies the builder. Precedence is untouched: `WHERE 1 = 1 AND tenant_id = 7 AND status = 'open' OR status = 'closed'` still groups the trailing OR at the top level. Safety comes from parenthesizing each fragment, not from the constant.
- What test would have caught the tenant leak before release?An invariant test rather than a feature test. Seed rows for two tenants, then for every combination of optional filters assert that the result contains no row belonging to the other tenant. Feature tests check that a filter returns the right kind of row; only the invariant test checks that no combination of filters can widen the scope.
- Where should a mandatory tenant predicate live if not in the fragment list?Outside the composable set: a fixed prefix the builder always emits and callers cannot supply, or better, a layer the query cannot bypass — a view that already restricts rows to the caller's tenant, or a session-scoped parameter applied unconditionally. A security-relevant predicate should not depend on a string-assembly step being correct.
saying these in an interview costs you the question
- Says the fix is to review generated SQL more carefully
- Believes wrapping the whole clause in one set of parentheses solves it
- Thinks WHERE 1 = 1 makes appended fragments safe
- Assumes each fragment being individually correct makes the composition correct
- Treats the extra rows as a reporting quirk rather than a scope leak