Summary
apply_default_filters in packages/agami-core/src/semantic_model/runtime.py rewrites a query to AND-in each in-scope table's default_filters (row-scoping predicates such as <table>.is_deleted = false) before execution. The rewrite is not scope-aware and is dialect-blind, so it turns valid analytical SQL into invalid SQL for three common query shapes: queries whose base table lives inside a CTE, EXISTS / NOT EXISTS anti-joins, and any query carrying an INTERVAL 'N days' literal.
The docstring on apply_default_filters actually promises the correct behavior — "only the outermost SELECT is touched; a filter is injected only when its table appears as a base table there" — but the implementation does not enforce it. It collects table/alias scope with tree.find_all(exp.Table), which recurses into every CTE and subquery, and then splices all resolved predicates into the single outermost WHERE.
A server log line confirms the mechanism, e.g.:
[agami] applied default_filters: ['o.is_deleted = false', 'a.is_deleted = false']
Note the returned sql field in the response is the pre-rewrite text, so the Postgres error is the client's only window into the mangled post-rewrite SQL.
Reproduction
All three inputs are valid SQL that a client legitimately sends; each fails only after the rewrite. (Reproduced against the vendored sqlglot 30.10.0 by mimicking apply_default_filters: parse → collect scope via find_all(exp.Table) → inject into the outer WHERE → tree.sql().)
1. CTE outer-scope injection
Base table orders is referenced only inside a CTE:
WITH monthly AS (
SELECT date_trunc('month', close_date) AS m, SUM(amount) AS rev
FROM orders WHERE stage = 'won' GROUP BY 1)
SELECT m, rev, LAG(rev) OVER (ORDER BY m) FROM monthly ORDER BY m;
_tables_in_scope returns {'monthly': 'monthly', 'orders': 'orders'} (it sees orders inside the CTE). The orders.is_deleted = false filter is then injected into the outer query, whose FROM is only monthly:
... SELECT m, rev, LAG(rev) OVER (ORDER BY m) FROM monthly
WHERE (orders.is_deleted = FALSE) ORDER BY m
→ Postgres: missing FROM-clause entry for table "orders".
2. Subquery-alias hoisting (EXISTS / NOT EXISTS anti-join)
SELECT a.id FROM accounts a
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.account_id = a.id AND o.stage = 'won')
AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.account_id = a.id);
_tables_in_scope returns {'a': 'accounts', 'o': 'orders', 'c': 'contacts'} — the subquery aliases o/c are hoisted into the outer scope. The rewrite appends their filters to the outer WHERE:
SELECT a.id FROM accounts AS a
WHERE (EXISTS(...) AND NOT EXISTS(...))
AND ((a.is_deleted = FALSE) AND (o.is_deleted = FALSE))
→ Postgres: missing FROM-clause entry for table "o" (o is not in scope in the outer WHERE). The observable normalizations (false → FALSE, added parenthesization, accounts → accounts AS a) confirm the whole statement is round-tripped through sqlglot's generator.
3. INTERVAL literal mangling
SELECT * FROM orders o WHERE o.created_at < CURRENT_DATE - INTERVAL '180 days';
Whenever the rewrite round-trips the SQL (i.e. any default filter is applied), the dialect-blind tree.sql() re-emits the interval in sqlglot's generic form:
... WHERE o.created_at < CURRENT_DATE - INTERVAL '180' DAYS AND (o.is_deleted = FALSE)
→ Postgres: syntax error at or near "DAYS". Passing the target dialect fixes it — tree.sql(dialect="postgres") emits the valid INTERVAL '180 DAYS'.
Observed vs. expected
- Observed: valid client SQL using a CTE, a correlated
EXISTS/NOT EXISTS subquery, or an INTERVAL literal fails at execution after the default-filter rewrite.
- Expected: each
default_filters predicate is applied only to the query block whose FROM actually introduces that table/alias, dialect-specific literals survive the round-trip unchanged, and the rewrite is a semantics-preserving transform of correct input.
Root cause
apply_default_filters — runtime.py:1500-1565:
-
Scope-blind injection (failure classes 1 & 2). Scope is built by _tables_in_scope (runtime.py:1387-1394) via tree.find_all(exp.Table), which walks the entire tree — CTE bodies and subqueries included — returning one flat alias -> table map with no notion of which query block each alias belongs to (scope = _tables_in_scope(tree), runtime.py:1535). Every resolved predicate is then AND-ed into the single outermost WHERE (runtime.py:1555-1562). Nothing checks that a table/alias is actually a base table of that outer SELECT, so filters for CTE-internal tables and subquery aliases land in a scope where the alias is undefined. This contradicts the function's own docstring at runtime.py:1508-1516.
-
Dialect-blind round-trip (failure class 3, plus the cosmetic churn seen above). Both the parse (sqlglot.parse_one(sql, error_level="ignore"), runtime.py:1529; and the GuardContext parse at runtime.py:91) and the re-emit (tree.sql(), runtime.py:1563) run with no dialect, so sqlglot normalizes to its generic dialect and reformats INTERVAL 'N days' into INTERVAL 'N' DAYS, which Postgres rejects.
Impact
Any query using a CTE, a correlated / EXISTS subquery, or an INTERVAL literal can fail even when the client SQL is correct. This blocks a large and common class of analytical queries — window functions over CTEs, anti-joins, and aging conditions — on any org whose model declares default_filters.
Suggested fix directions
- Make default-filter injection scope-aware. Apply each predicate only to the specific query block whose
FROM/JOIN introduces that table/alias, rather than flattening all tables and injecting into the outermost WHERE. Prefer operating on the AST per-scope (sqlglot's scope/Scope traversal, or by injecting into each exp.Select's own WHERE based on that select's direct sources) over string splicing. At minimum, restrict the outer injection to the base tables of the outermost SELECT (exclude CTE-internal tables and subquery-local aliases) so a filter is never emitted into a scope where its alias is undefined — matching what the docstring already promises.
- Fix
INTERVAL / dialect handling. Round-trip with the target engine's dialect on both parse_one(..., dialect=...) and tree.sql(dialect=...) so dialect-specific literals (and casing/parenthesization) survive unchanged. tree.sql(dialect="postgres") already emits a valid INTERVAL '180 DAYS' in the repro above.
- Return the executed SQL. The
sql returned in the response should reflect what actually ran (post-rewrite), so a client can diagnose failures without inferring the mangled text from the Postgres error.
Suggested regression tests
Add cases that round-trip through apply_default_filters / execute_sql and assert the result is semantically unchanged (executes successfully and returns the same rows as the un-rewritten query on a table that declares a default_filters predicate):
- CTE: a
WITH ... SELECT where the filtered base table appears only inside the CTE — assert the filter lands inside the CTE body and the outer WHERE gains no out-of-scope predicate.
- EXISTS / NOT EXISTS: an anti-join whose filtered tables are subquery-local — assert each filter stays inside its own subquery and no subquery alias leaks into the outer
WHERE.
- INTERVAL: a query with
... < CURRENT_DATE - INTERVAL '180 days' on a table that declares a default_filters predicate — assert the emitted SQL still contains a valid INTERVAL '180 days' / INTERVAL '180 DAYS' and executes without a syntax error.
Summary
apply_default_filtersinpackages/agami-core/src/semantic_model/runtime.pyrewrites a query to AND-in each in-scope table'sdefault_filters(row-scoping predicates such as<table>.is_deleted = false) before execution. The rewrite is not scope-aware and is dialect-blind, so it turns valid analytical SQL into invalid SQL for three common query shapes: queries whose base table lives inside a CTE,EXISTS/NOT EXISTSanti-joins, and any query carrying anINTERVAL 'N days'literal.The docstring on
apply_default_filtersactually promises the correct behavior — "only the outermost SELECT is touched; a filter is injected only when its table appears as a base table there" — but the implementation does not enforce it. It collects table/alias scope withtree.find_all(exp.Table), which recurses into every CTE and subquery, and then splices all resolved predicates into the single outermostWHERE.A server log line confirms the mechanism, e.g.:
Note the returned
sqlfield in the response is the pre-rewrite text, so the Postgres error is the client's only window into the mangled post-rewrite SQL.Reproduction
All three inputs are valid SQL that a client legitimately sends; each fails only after the rewrite. (Reproduced against the vendored
sqlglot30.10.0 by mimickingapply_default_filters: parse → collect scope viafind_all(exp.Table)→ inject into the outerWHERE→tree.sql().)1. CTE outer-scope injection
Base table
ordersis referenced only inside a CTE:_tables_in_scopereturns{'monthly': 'monthly', 'orders': 'orders'}(it seesordersinside the CTE). Theorders.is_deleted = falsefilter is then injected into the outer query, whoseFROMis onlymonthly:→ Postgres:
missing FROM-clause entry for table "orders".2. Subquery-alias hoisting (EXISTS / NOT EXISTS anti-join)
_tables_in_scopereturns{'a': 'accounts', 'o': 'orders', 'c': 'contacts'}— the subquery aliaseso/care hoisted into the outer scope. The rewrite appends their filters to the outerWHERE:→ Postgres:
missing FROM-clause entry for table "o"(ois not in scope in the outerWHERE). The observable normalizations (false→FALSE, added parenthesization,accounts→accounts AS a) confirm the whole statement is round-tripped through sqlglot's generator.3. INTERVAL literal mangling
Whenever the rewrite round-trips the SQL (i.e. any default filter is applied), the dialect-blind
tree.sql()re-emits the interval in sqlglot's generic form:→ Postgres:
syntax error at or near "DAYS". Passing the target dialect fixes it —tree.sql(dialect="postgres")emits the validINTERVAL '180 DAYS'.Observed vs. expected
EXISTS/NOT EXISTSsubquery, or anINTERVALliteral fails at execution after the default-filter rewrite.default_filterspredicate is applied only to the query block whoseFROMactually introduces that table/alias, dialect-specific literals survive the round-trip unchanged, and the rewrite is a semantics-preserving transform of correct input.Root cause
apply_default_filters—runtime.py:1500-1565:Scope-blind injection (failure classes 1 & 2). Scope is built by
_tables_in_scope(runtime.py:1387-1394) viatree.find_all(exp.Table), which walks the entire tree — CTE bodies and subqueries included — returning one flatalias -> tablemap with no notion of which query block each alias belongs to (scope = _tables_in_scope(tree),runtime.py:1535). Every resolved predicate is then AND-ed into the single outermostWHERE(runtime.py:1555-1562). Nothing checks that a table/alias is actually a base table of that outerSELECT, so filters for CTE-internal tables and subquery aliases land in a scope where the alias is undefined. This contradicts the function's own docstring atruntime.py:1508-1516.Dialect-blind round-trip (failure class 3, plus the cosmetic churn seen above). Both the parse (
sqlglot.parse_one(sql, error_level="ignore"),runtime.py:1529; and theGuardContextparse atruntime.py:91) and the re-emit (tree.sql(),runtime.py:1563) run with no dialect, so sqlglot normalizes to its generic dialect and reformatsINTERVAL 'N days'intoINTERVAL 'N' DAYS, which Postgres rejects.Impact
Any query using a CTE, a correlated /
EXISTSsubquery, or anINTERVALliteral can fail even when the client SQL is correct. This blocks a large and common class of analytical queries — window functions over CTEs, anti-joins, and aging conditions — on any org whose model declaresdefault_filters.Suggested fix directions
FROM/JOINintroduces that table/alias, rather than flattening all tables and injecting into the outermostWHERE. Prefer operating on the AST per-scope (sqlglot'sscope/Scopetraversal, or by injecting into eachexp.Select's ownWHEREbased on that select's direct sources) over string splicing. At minimum, restrict the outer injection to the base tables of the outermostSELECT(exclude CTE-internal tables and subquery-local aliases) so a filter is never emitted into a scope where its alias is undefined — matching what the docstring already promises.INTERVAL/ dialect handling. Round-trip with the target engine's dialect on bothparse_one(..., dialect=...)andtree.sql(dialect=...)so dialect-specific literals (and casing/parenthesization) survive unchanged.tree.sql(dialect="postgres")already emits a validINTERVAL '180 DAYS'in the repro above.sqlreturned in the response should reflect what actually ran (post-rewrite), so a client can diagnose failures without inferring the mangled text from the Postgres error.Suggested regression tests
Add cases that round-trip through
apply_default_filters/execute_sqland assert the result is semantically unchanged (executes successfully and returns the same rows as the un-rewritten query on a table that declares adefault_filterspredicate):WITH ... SELECTwhere the filtered base table appears only inside the CTE — assert the filter lands inside the CTE body and the outerWHEREgains no out-of-scope predicate.WHERE.... < CURRENT_DATE - INTERVAL '180 days'on a table that declares adefault_filterspredicate — assert the emitted SQL still contains a validINTERVAL '180 days'/INTERVAL '180 DAYS'and executes without a syntax error.