skip to content

A mapped type carries a live-rows-only filter, yet deleted rows still reach callers. Which paths skip it?

level: seniorimportance: must knowfreq 58%

answer

  1. injected at composition time only
  2. no statement, no predicate
  3. hand-written, set-based, key lookup, cache
  4. joins and aggregates leak quietly
  5. test for absence, not presence

basics

~20 s

Any path where the layer does not compose the SQL: hand-written statements, set-based updates and deletes, a key lookup answered from objects already tracked, an object served from a long-lived cache, and rows the database itself changes.

solid answer

~50 s

An always-on predicate is injected when the layer builds a statement, so anything that avoids that step avoids the filter. The recurring holes are: statements written by hand and merely executed; set-based updates and deletes translated straight to SQL; a lookup by key that the unit of work answers from an object it already holds, with no query issued; an object handed back from a cache that outlives the unit of work; rows the database changes itself through a declared referential action; and, in some layers, the far side of a join or an eagerly joined association, where the predicate is applied to the root type but not to every joined type. The consequences are asymmetric — a hidden row surfacing is a data leak, while a hidden row *not* surfacing shows up as a unique-constraint violation naming a row the caller cannot see.

go deeper

for a junior

Remember the shape of the rule: the predicate is added when the layer writes the SQL, so anything that does not go through that step is unfiltered.

for a middle

Be able to name several concrete paths — hand-written statements, set-based writes, a key lookup answered from memory, a cached object — and say why each one misses the predicate.

for a senior

Demonstrate how you would find and close these in a real codebase: enumerate the unfiltered paths, read the generated SQL, and write tests that assert excluded rows never appear.

for a principal

Argue where the invariant should ultimately live given the blast radius: a mapper-level default is fine for hiding deleted rows and thin for isolating customers, and that difference should drive the design.

## Why holes exist at all An always-on filter is a **statement-composition** feature. When the layer builds SQL for a mapped type, it appends the declared predicate to the WHERE clause. That is the whole mechanism, and it explains every gap precisely: if no statement is composed by the layer, or the composed statement is not the one you think, the predicate is not there. Treating the filter as a guarantee rather than a default is the mistake that produces the incident. It is a safe default for the ordinary read path and nothing more. ## The recurring holes | Path | Why the predicate is missing | What the caller sees | |---|---|---| | Hand-written statement executed through the layer | The layer did not compose the text | Deleted or other-tenant rows in the result | | Set-based update or delete | One SQL statement built from the caller's predicate only | Rows outside the filter silently modified | | Lookup by key served from the tracked set | No statement is issued at all | An object the filter would have excluded | | Object from a cache spanning units of work | Returned without a round trip | Same as above, and it can outlive the change | | Rows changed by a database-declared action | The engine, not the layer, wrote them | Stamps stale, filter irrelevant | | Far side of a join, or a joined association | Predicate applied to the root type, not necessarily every joined type | Excluded rows reached through a relationship | | Aggregate computed in SQL over a joined table | The COUNT or SUM spans rows the read path hides | Totals that disagree with the list on screen | The last two are the ones that survive review longest, because the code looks entirely ordinary: a query against a filtered root type that navigates one relationship, and a count that no longer matches the rows the user can see. ## Two different failure shapes It helps to separate them, because they are found by different means. **A hidden row surfaces.** Someone sees a deleted record, or — much worse — a record belonging to another tenant. This is a correctness and confidentiality failure, and it is found by tests that assert the *absence* of rows, not their presence. A test that inserts one live row and asserts it comes back proves nothing; a test that inserts a live row and an excluded row and asserts the excluded one never appears is the one that catches the hole. **A hidden row blocks the caller.** The application queries for an identifier, finds nothing, inserts, and the database rejects the insert against a row the query could not see. From inside the request this is inexplicable: the row does not exist, and the insert says it does. The application-side lesson is that uniqueness is enforced by the database over *all* rows while your reads see a subset — so the insert path must handle the violation deliberately instead of trusting the preceding read. How to model the constraint so it only covers live rows is a schema question and belongs with the schema design of that pattern. ## Finding the holes on purpose 1. **Enumerate the unfiltered paths in your own codebase.** Every hand-written statement, every set-based update or delete, every direct-write import path. That list is short and reviewable; the alternative is auditing every query. 2. **Read the generated SQL** for the paths you doubt, rather than reasoning about what the layer should do. The predicate is either in the text or it is not, and for joins and aggregates this is the only reliable answer. 3. **Write negative tests.** For each filtered type, one test proving an excluded row never appears through the ordinary read path, and one for each deliberate exception. 4. **Make the unfiltered paths carry the predicate themselves.** A hand-written statement is not exempt from the invariant, only from the mechanism; it must repeat the clause. 5. **Prefer a second line of defence for tenancy.** Where the consequence is one customer seeing another's data, the mapper-level filter should not be the only thing standing between them; enforcement closer to the data is a different discussion, but the fact that one filter is not enough is not. ## Why key lookups and caches are special Both are cases where the layer deliberately avoids a round trip: one because the object is already in the unit of work, the other because it is in a cache shared beyond it. The optimisation is the point of those mechanisms, and a filter cannot be applied to a statement that is never issued. So an object can enter the tracked set through an unfiltered path — a hand-written statement, or a read taken while the filter was switched off — and be handed out afterwards to code that expected the filter to be in force. That is why a switch-off should be scoped tightly and why long-lived caches over filtered types are worth avoiding. ## The blunt summary The filter narrows the statements the mapper writes. Anything reaching the row another way is outside it, and the way to know which paths those are in your system is to list them and read the SQL, not to trust the declaration.

  • The application checks that an identifier is free, finds nothing, inserts, and the database rejects it. How does the filter explain that?
    The read was filtered and the constraint is not. The identifier belongs to a row the query excluded — deleted, or otherwise outside the predicate — while the unique index covers every row in the table. The insert path must therefore handle the violation itself rather than treating the preceding read as proof; whether the constraint should cover only live rows is a schema modelling decision.
  • How would you prove that a given read path really applies the filter?
    Read the generated SQL for that path and look for the predicate, then back it with a test that inserts an excluded row alongside a live one and asserts the excluded one never appears. Reasoning about what the layer ought to do is unreliable exactly where it matters most: joins, aggregates and lazily resolved associations.
  • Why is an object served without a database round trip immune to the filter?
    Because there is no statement to add a predicate to. When a lookup by key is answered from objects the unit of work already holds, or from a cache that outlives it, the layer returns the object directly. If it entered by an unfiltered path, it comes back out regardless of the declaration.
  • Which of these holes is the most dangerous, and why?
    The tenant case, whichever path causes it. A surfaced deleted row is a correctness bug; a surfaced other-tenant row is a confidentiality breach that is often invisible in logs and discovered by the affected customer. That asymmetry is why tenancy usually warrants enforcement beyond the mapper's filter.

saying these in an interview costs you the question

  • Treats the declared filter as a security guarantee
  • Assumes set-based deletes inherit the type's predicate
  • Believes a key lookup always issues a filtered query
  • Tests only that live rows come back, never that excluded rows do not
  • Thinks a join automatically filters every joined type
  • Blames the database when an insert collides with an invisible row