[KM-652] feat(query): single-level FK joins in QueryIR (T4)
Browse filesThe e2e "revenue by category" failed because the measure (order_items.line_total)
and the dimension (products.category) live in different tables and QueryIR was
single-table — the planner couldn't combine them and picked wrong tables.
- QueryIR gains `joins: [{target_table_id, left_column_id, right_column_id, type}]`.
select/group_by/filters/order_by may now reference columns from joined tables.
- SqlCompiler resolves each column_id to its owning table and emits table-qualified
refs + INNER/LEFT JOIN. Output is byte-identical to before when joins=[].
- IRValidator: joins are database-only and each column pair must match a foreign
key declared in the catalog; joined columns are added to the resolvable set.
- PandasCompiler rejects joins (cross-file merge deferred) — DB sources only in v1.
- retrieve_data spec + planner.md + query_planner.md teach the join pattern with a
revenue-by-category few-shot; prefer existing measure cols + single table when possible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/planner/registry.py +14 -7
- src/config/prompts/planner.md +11 -0
- src/config/prompts/query_planner.md +27 -2
- src/query/compiler/pandas.py +7 -0
- src/query/compiler/sql.py +73 -65
- src/query/ir/models.py +24 -2
- src/query/ir/validator.py +51 -2
|
@@ -43,13 +43,20 @@ _DATA_ACCESS_SPEC_BODIES: tuple[ToolSpec, ...] = (
|
|
| 43 |
input_schema={"required": ["ir"], "properties": {"ir": {"type": "object"}}},
|
| 44 |
output_kind="table",
|
| 45 |
description=(
|
| 46 |
-
"Run one validated
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
"(median/percentile/mode/stddev/skew → analyze_descriptive), trends "
|
| 54 |
"(analyze_trend), correlation, segmentation, or share-of-total; and do NOT "
|
| 55 |
"use it to read documents (use retrieve_knowledge)."
|
|
|
|
| 43 |
input_schema={"required": ["ir"], "properties": {"ir": {"type": "object"}}},
|
| 44 |
output_kind="table",
|
| 45 |
description=(
|
| 46 |
+
"Run one validated query against a structured source and return rows. The "
|
| 47 |
+
"`ir` argument is an inline QueryIR (the JSON intent: source_id, table_id, "
|
| 48 |
+
"joins, select, filters, group_by, order_by, limit) — never SQL. This is the "
|
| 49 |
+
"data-access entry point: use it to select, filter, and pull the rows the "
|
| 50 |
+
"analytics (`analyze_*`) tools then consume. It also does simple built-in "
|
| 51 |
+
"aggregation the IR can express (count/sum/avg/min/max/count_distinct). "
|
| 52 |
+
"JOINS (database sources only): to group a measure in one table by a "
|
| 53 |
+
"dimension in a RELATED table, add a `joins` entry "
|
| 54 |
+
"({target_table_id, left_column_id, right_column_id}) along a declared "
|
| 55 |
+
"foreign key — e.g. sum order_items.line_total grouped by products.category "
|
| 56 |
+
"via order_items.product_id = products.id. Prefer an existing measure column "
|
| 57 |
+
"(e.g. line_total) over recomputing, and a single table when the measure and "
|
| 58 |
+
"dimension already live together. Joins are NOT supported on tabular/file "
|
| 59 |
+
"sources yet. Do NOT use this for richer statistics "
|
| 60 |
"(median/percentile/mode/stddev/skew → analyze_descriptive), trends "
|
| 61 |
"(analyze_trend), correlation, segmentation, or share-of-total; and do NOT "
|
| 62 |
"use it to read documents (use retrieve_knowledge)."
|
|
@@ -39,6 +39,17 @@ only a `TaskList` object that conforms to the provided schema.
|
|
| 39 |
profiling — run `retrieve_data` to fetch the rows, then pass its output to
|
| 40 |
the matching composite `analyze_*` tool via a `"${t<id>}"` `data` argument
|
| 41 |
(referencing the upstream result's column aliases).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
- **Mixing structured + unstructured.** If qualitative context helps, add a
|
| 43 |
`retrieve_knowledge` task against an unstructured source listed in the catalog.
|
| 44 |
- **CRISP-DM stages.** Tag each task with the stage it serves:
|
|
|
|
| 39 |
profiling — run `retrieve_data` to fetch the rows, then pass its output to
|
| 40 |
the matching composite `analyze_*` tool via a `"${t<id>}"` `data` argument
|
| 41 |
(referencing the upstream result's column aliases).
|
| 42 |
+
- **Measure by a dimension in another table (joins).** When the number you are
|
| 43 |
+
aggregating and the grouping dimension live in DIFFERENT tables of the same
|
| 44 |
+
database source, add a `joins` entry to the `retrieve_data` IR along a foreign
|
| 45 |
+
key declared in the catalog — do NOT pick a table that lacks the measure, and do
|
| 46 |
+
NOT try to "combine" unrelated tables. Example — "revenue by category": the
|
| 47 |
+
measure `order_items.line_total` joined to `products` on
|
| 48 |
+
`order_items.product_id = products.id`, grouped by `products.category`. Prefer an
|
| 49 |
+
existing measure column over recomputing; use a single table (no join) when the
|
| 50 |
+
measure and dimension already live together (e.g. "revenue by region" from
|
| 51 |
+
`orders.region` + `orders.total_amount`). Joins are database-only — not available
|
| 52 |
+
for tabular/file sources.
|
| 53 |
- **Mixing structured + unstructured.** If qualitative context helps, add a
|
| 54 |
`retrieve_knowledge` task against an unstructured source listed in the catalog.
|
| 55 |
- **CRISP-DM stages.** Tag each task with the stage it serves:
|
|
@@ -15,7 +15,10 @@ A `QueryIR` object:
|
|
| 15 |
{
|
| 16 |
"ir_version": "1.0",
|
| 17 |
"source_id": "...", // pick from catalog
|
| 18 |
-
"table_id": "...", //
|
|
|
|
|
|
|
|
|
|
| 19 |
"select": [
|
| 20 |
{"kind": "column", "column_id": "...", "alias": "..."},
|
| 21 |
{"kind": "agg", "fn": "count|count_distinct|sum|avg|min|max",
|
|
@@ -36,7 +39,7 @@ A `QueryIR` object:
|
|
| 36 |
## Hard constraints (a violation makes the IR invalid)
|
| 37 |
|
| 38 |
1. `source_id`, `table_id`, `column_id` must come **verbatim** from the catalog. Never invent IDs or copy table/column **names** in their place.
|
| 39 |
-
2. **
|
| 40 |
3. Use only listed operators / aggregates. No window functions, no `CASE WHEN`, no subqueries — those are not part of v1.
|
| 41 |
4. `value_type` must be compatible with the column's `data_type`:
|
| 42 |
- `int` column ↔ value_type ∈ {int, decimal}
|
|
@@ -115,6 +118,28 @@ Output:
|
|
| 115 |
}
|
| 116 |
```
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
Catalog excerpt (tabular source — XLSX sheet):
|
| 119 |
|
| 120 |
```
|
|
|
|
| 15 |
{
|
| 16 |
"ir_version": "1.0",
|
| 17 |
"source_id": "...", // pick from catalog
|
| 18 |
+
"table_id": "...", // base table
|
| 19 |
+
"joins": [ // optional; DATABASE sources only; FK-backed
|
| 20 |
+
{"target_table_id": "...", "left_column_id": "...", "right_column_id": "...", "type": "inner"}
|
| 21 |
+
],
|
| 22 |
"select": [
|
| 23 |
{"kind": "column", "column_id": "...", "alias": "..."},
|
| 24 |
{"kind": "agg", "fn": "count|count_distinct|sum|avg|min|max",
|
|
|
|
| 39 |
## Hard constraints (a violation makes the IR invalid)
|
| 40 |
|
| 41 |
1. `source_id`, `table_id`, `column_id` must come **verbatim** from the catalog. Never invent IDs or copy table/column **names** in their place.
|
| 42 |
+
2. **Joins (database sources only).** Prefer a single table when the measure and the grouping dimension already live together. When the number you aggregate and the dimension are in DIFFERENT tables of the same DB source, add a `joins` entry along a **foreign key declared in the catalog** — `left_column_id` must already be in the query (base table or an earlier join), `right_column_id` is in `target_table_id`. Do NOT pick a table missing the measure, and do NOT join unrelated tables. Tabular/file sources are single-table only — no joins.
|
| 43 |
3. Use only listed operators / aggregates. No window functions, no `CASE WHEN`, no subqueries — those are not part of v1.
|
| 44 |
4. `value_type` must be compatible with the column's `data_type`:
|
| 45 |
- `int` column ↔ value_type ∈ {int, decimal}
|
|
|
|
| 118 |
}
|
| 119 |
```
|
| 120 |
|
| 121 |
+
Question: "Which product category generates the most total revenue?"
|
| 122 |
+
(catalog DB source `src_prod_db` also has: `order_items` [id=t_order_items] with `line_total` [decimal, id=c_oi_line_total] and `product_id` [int, id=c_oi_product_id, FK → products.id]; `products` [id=t_products] with `category` [string, id=c_products_category] and `id` [int, id=c_products_id])
|
| 123 |
+
Output:
|
| 124 |
+
```json
|
| 125 |
+
{
|
| 126 |
+
"ir_version": "1.0",
|
| 127 |
+
"source_id": "src_prod_db",
|
| 128 |
+
"table_id": "t_order_items",
|
| 129 |
+
"joins": [
|
| 130 |
+
{"target_table_id": "t_products", "left_column_id": "c_oi_product_id", "right_column_id": "c_products_id", "type": "inner"}
|
| 131 |
+
],
|
| 132 |
+
"select": [
|
| 133 |
+
{"kind": "column", "column_id": "c_products_category"},
|
| 134 |
+
{"kind": "agg", "fn": "sum", "column_id": "c_oi_line_total", "alias": "total_revenue"}
|
| 135 |
+
],
|
| 136 |
+
"filters": [],
|
| 137 |
+
"group_by": ["c_products_category"],
|
| 138 |
+
"order_by": [{"column_id": "total_revenue", "dir": "desc"}],
|
| 139 |
+
"limit": 100
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
Catalog excerpt (tabular source — XLSX sheet):
|
| 144 |
|
| 145 |
```
|
|
@@ -48,6 +48,13 @@ class PandasCompiler(BaseCompiler):
|
|
| 48 |
self._catalog = catalog
|
| 49 |
|
| 50 |
def compile(self, ir: QueryIR) -> CompiledPandas:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
_, table, cols_by_id = self._lookup(ir)
|
| 52 |
output_columns = _output_column_names(ir.select, cols_by_id)
|
| 53 |
|
|
|
|
| 48 |
self._catalog = catalog
|
| 49 |
|
| 50 |
def compile(self, ir: QueryIR) -> CompiledPandas:
|
| 51 |
+
if ir.joins:
|
| 52 |
+
# Tabular joins need the executor to load + merge multiple parquet blobs
|
| 53 |
+
# (deferred). The validator already rejects joins on tabular sources;
|
| 54 |
+
# this is the defensive backstop. Joins are DB-only in v1 (KM-652 T4).
|
| 55 |
+
raise PandasCompilerError(
|
| 56 |
+
"joins are not supported on tabular sources yet (database sources only)"
|
| 57 |
+
)
|
| 58 |
_, table, cols_by_id = self._lookup(ir)
|
| 59 |
output_columns = _output_column_names(ir.select, cols_by_id)
|
| 60 |
|
|
@@ -9,9 +9,10 @@ The output `CompiledSql.sql` uses SQLAlchemy-style named placeholders
|
|
| 9 |
(`:p_0, :p_1, ...`) so it can be executed via `text(sql)` with a params
|
| 10 |
dict on a sync SQLAlchemy engine.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
@@ -36,6 +37,9 @@ from .base import BaseCompiler
|
|
| 36 |
# `row_cap` and flags truncation.
|
| 37 |
MAX_RESULT_ROWS = 10_000
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
@dataclass
|
| 41 |
class CompiledSql:
|
|
@@ -65,17 +69,15 @@ class SqlCompiler(BaseCompiler):
|
|
| 65 |
self._dialect = dialect
|
| 66 |
|
| 67 |
def compile(self, ir: QueryIR) -> CompiledSql:
|
| 68 |
-
|
| 69 |
params: dict[str, Any] = {}
|
| 70 |
param_seq = [0]
|
| 71 |
|
| 72 |
-
select_clause, select_aliases = self._build_select(ir.select,
|
| 73 |
-
from_clause = self._build_from(
|
| 74 |
-
where_clause = self._build_where(ir.filters,
|
| 75 |
-
groupby_clause = self._build_groupby(ir.group_by,
|
| 76 |
-
orderby_clause = self._build_orderby(
|
| 77 |
-
ir.order_by, table, cols_by_id, select_aliases
|
| 78 |
-
)
|
| 79 |
limit_clause, row_cap = self._build_limit(ir.limit)
|
| 80 |
|
| 81 |
parts: list[str] = [select_clause, from_clause]
|
|
@@ -86,23 +88,33 @@ class SqlCompiler(BaseCompiler):
|
|
| 86 |
return CompiledSql(sql=" ".join(parts), params=params, row_cap=row_cap)
|
| 87 |
|
| 88 |
# ------------------------------------------------------------------
|
| 89 |
-
# Catalog lookup
|
| 90 |
# ------------------------------------------------------------------
|
| 91 |
|
| 92 |
-
def _lookup(self, ir: QueryIR) -> tuple[
|
| 93 |
source = next(
|
| 94 |
(s for s in self._catalog.sources if s.source_id == ir.source_id), None
|
| 95 |
)
|
| 96 |
if source is None:
|
| 97 |
raise SqlCompilerError(f"source_id {ir.source_id!r} not in catalog")
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if table is None:
|
| 102 |
raise SqlCompilerError(
|
| 103 |
-
f"table_id {
|
| 104 |
)
|
| 105 |
-
return
|
| 106 |
|
| 107 |
# ------------------------------------------------------------------
|
| 108 |
# Identifier quoting
|
|
@@ -113,7 +125,8 @@ class SqlCompiler(BaseCompiler):
|
|
| 113 |
"""Postgres-style double-quoted identifier with embedded-quote escape."""
|
| 114 |
return '"' + name.replace('"', '""') + '"'
|
| 115 |
|
| 116 |
-
def _qcol(self,
|
|
|
|
| 117 |
return f"{self._qident(table.name)}.{self._qident(col.name)}"
|
| 118 |
|
| 119 |
# ------------------------------------------------------------------
|
|
@@ -121,17 +134,14 @@ class SqlCompiler(BaseCompiler):
|
|
| 121 |
# ------------------------------------------------------------------
|
| 122 |
|
| 123 |
def _build_select(
|
| 124 |
-
self,
|
| 125 |
-
items: list[SelectItem],
|
| 126 |
-
table: Table,
|
| 127 |
-
cols_by_id: dict[str, Column],
|
| 128 |
) -> tuple[str, set[str]]:
|
| 129 |
if not items:
|
| 130 |
raise SqlCompilerError("select clause cannot be empty")
|
| 131 |
parts: list[str] = []
|
| 132 |
aliases: set[str] = set()
|
| 133 |
for i, item in enumerate(items):
|
| 134 |
-
expr, alias = self._select_item(item,
|
| 135 |
if alias:
|
| 136 |
parts.append(f"{expr} AS {self._qident(alias)}")
|
| 137 |
aliases.add(alias)
|
|
@@ -140,35 +150,27 @@ class SqlCompiler(BaseCompiler):
|
|
| 140 |
return "SELECT " + ", ".join(parts), aliases
|
| 141 |
|
| 142 |
def _select_item(
|
| 143 |
-
self,
|
| 144 |
-
item: SelectItem,
|
| 145 |
-
table: Table,
|
| 146 |
-
cols_by_id: dict[str, Column],
|
| 147 |
-
index: int,
|
| 148 |
) -> tuple[str, str | None]:
|
| 149 |
if isinstance(item, ColumnSelect):
|
| 150 |
-
|
| 151 |
-
return self._qcol(
|
| 152 |
if not isinstance(item, AggSelect):
|
| 153 |
raise SqlCompilerError(
|
| 154 |
f"select[{index}]: unknown SelectItem kind {type(item).__name__}"
|
| 155 |
)
|
| 156 |
-
return self._compile_agg(item,
|
| 157 |
|
| 158 |
def _compile_agg(
|
| 159 |
-
self,
|
| 160 |
-
item: AggSelect,
|
| 161 |
-
table: Table,
|
| 162 |
-
cols_by_id: dict[str, Column],
|
| 163 |
-
index: int,
|
| 164 |
) -> str:
|
| 165 |
if item.fn == "count_distinct":
|
| 166 |
if item.column_id is None:
|
| 167 |
raise SqlCompilerError(
|
| 168 |
f"select[{index}].fn=count_distinct requires column_id"
|
| 169 |
)
|
| 170 |
-
|
| 171 |
-
return f"COUNT(DISTINCT {self._qcol(
|
| 172 |
if item.column_id is None:
|
| 173 |
if item.fn != "count":
|
| 174 |
raise SqlCompilerError(
|
|
@@ -176,24 +178,35 @@ class SqlCompiler(BaseCompiler):
|
|
| 176 |
"(only 'count' may omit it for COUNT(*))"
|
| 177 |
)
|
| 178 |
return "COUNT(*)"
|
| 179 |
-
|
| 180 |
-
return f"{item.fn.upper()}({self._qcol(
|
| 181 |
|
| 182 |
-
def _build_from(
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
def _build_where(
|
| 186 |
self,
|
| 187 |
filters: list[FilterClause],
|
| 188 |
-
|
| 189 |
-
cols_by_id: dict[str, Column],
|
| 190 |
params: dict[str, Any],
|
| 191 |
param_seq: list[int],
|
| 192 |
) -> str:
|
| 193 |
if not filters:
|
| 194 |
return ""
|
| 195 |
parts = [
|
| 196 |
-
self._compile_filter(f,
|
| 197 |
for i, f in enumerate(filters)
|
| 198 |
]
|
| 199 |
return "WHERE " + " AND ".join(parts)
|
|
@@ -201,14 +214,13 @@ class SqlCompiler(BaseCompiler):
|
|
| 201 |
def _compile_filter(
|
| 202 |
self,
|
| 203 |
f: FilterClause,
|
| 204 |
-
|
| 205 |
-
cols_by_id: dict[str, Column],
|
| 206 |
params: dict[str, Any],
|
| 207 |
param_seq: list[int],
|
| 208 |
index: int,
|
| 209 |
) -> str:
|
| 210 |
-
|
| 211 |
-
col_ref = self._qcol(
|
| 212 |
op = f.op
|
| 213 |
|
| 214 |
if op == "is_null":
|
|
@@ -248,15 +260,12 @@ class SqlCompiler(BaseCompiler):
|
|
| 248 |
raise SqlCompilerError(f"filters[{index}]: unhandled op {op!r}")
|
| 249 |
|
| 250 |
def _build_groupby(
|
| 251 |
-
self,
|
| 252 |
-
group_by: list[str],
|
| 253 |
-
table: Table,
|
| 254 |
-
cols_by_id: dict[str, Column],
|
| 255 |
) -> str:
|
| 256 |
if not group_by:
|
| 257 |
return ""
|
| 258 |
parts = [
|
| 259 |
-
self._qcol(
|
| 260 |
for i, col_id in enumerate(group_by)
|
| 261 |
]
|
| 262 |
return "GROUP BY " + ", ".join(parts)
|
|
@@ -264,8 +273,7 @@ class SqlCompiler(BaseCompiler):
|
|
| 264 |
def _build_orderby(
|
| 265 |
self,
|
| 266 |
order_by: list[OrderByClause],
|
| 267 |
-
|
| 268 |
-
cols_by_id: dict[str, Column],
|
| 269 |
select_aliases: set[str],
|
| 270 |
) -> str:
|
| 271 |
if not order_by:
|
|
@@ -273,12 +281,12 @@ class SqlCompiler(BaseCompiler):
|
|
| 273 |
parts: list[str] = []
|
| 274 |
for i, ob in enumerate(order_by):
|
| 275 |
if ob.column_id in cols_by_id:
|
| 276 |
-
ref = self._qcol(
|
| 277 |
elif ob.column_id in select_aliases:
|
| 278 |
ref = self._qident(ob.column_id)
|
| 279 |
else:
|
| 280 |
raise SqlCompilerError(
|
| 281 |
-
f"order_by[{i}].column_id: {ob.column_id!r} not in
|
| 282 |
"columns or select aliases"
|
| 283 |
)
|
| 284 |
parts.append(f"{ref} {ob.dir.upper()}")
|
|
@@ -312,9 +320,9 @@ class SqlCompiler(BaseCompiler):
|
|
| 312 |
|
| 313 |
@staticmethod
|
| 314 |
def _require_col(
|
| 315 |
-
cols_by_id: dict[str,
|
| 316 |
-
) ->
|
| 317 |
-
|
| 318 |
-
if
|
| 319 |
-
raise SqlCompilerError(f"{where}.column_id: {col_id!r} not in
|
| 320 |
-
return
|
|
|
|
| 9 |
(`:p_0, :p_1, ...`) so it can be executed via `text(sql)` with a params
|
| 10 |
dict on a sync SQLAlchemy engine.
|
| 11 |
|
| 12 |
+
Joins (KM-652 T4): a column_id resolves to its owning table across the base
|
| 13 |
+
table + any joined tables, so every reference is emitted table-qualified
|
| 14 |
+
(`"table"."col"`). With `joins=[]` the output is identical to the single-table
|
| 15 |
+
form. v1 supports the Postgres dialect only (Supabase = Postgres).
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
|
|
| 37 |
# `row_cap` and flags truncation.
|
| 38 |
MAX_RESULT_ROWS = 10_000
|
| 39 |
|
| 40 |
+
# A resolved column reference: the table that owns it + the column itself.
|
| 41 |
+
ColRef = tuple[Table, Column]
|
| 42 |
+
|
| 43 |
|
| 44 |
@dataclass
|
| 45 |
class CompiledSql:
|
|
|
|
| 69 |
self._dialect = dialect
|
| 70 |
|
| 71 |
def compile(self, ir: QueryIR) -> CompiledSql:
|
| 72 |
+
base_table, cols_by_id = self._lookup(ir)
|
| 73 |
params: dict[str, Any] = {}
|
| 74 |
param_seq = [0]
|
| 75 |
|
| 76 |
+
select_clause, select_aliases = self._build_select(ir.select, cols_by_id)
|
| 77 |
+
from_clause = self._build_from(base_table, ir, cols_by_id)
|
| 78 |
+
where_clause = self._build_where(ir.filters, cols_by_id, params, param_seq)
|
| 79 |
+
groupby_clause = self._build_groupby(ir.group_by, cols_by_id)
|
| 80 |
+
orderby_clause = self._build_orderby(ir.order_by, cols_by_id, select_aliases)
|
|
|
|
|
|
|
| 81 |
limit_clause, row_cap = self._build_limit(ir.limit)
|
| 82 |
|
| 83 |
parts: list[str] = [select_clause, from_clause]
|
|
|
|
| 88 |
return CompiledSql(sql=" ".join(parts), params=params, row_cap=row_cap)
|
| 89 |
|
| 90 |
# ------------------------------------------------------------------
|
| 91 |
+
# Catalog lookup — column_id -> (owning Table, Column) across base + joins
|
| 92 |
# ------------------------------------------------------------------
|
| 93 |
|
| 94 |
+
def _lookup(self, ir: QueryIR) -> tuple[Table, dict[str, ColRef]]:
|
| 95 |
source = next(
|
| 96 |
(s for s in self._catalog.sources if s.source_id == ir.source_id), None
|
| 97 |
)
|
| 98 |
if source is None:
|
| 99 |
raise SqlCompilerError(f"source_id {ir.source_id!r} not in catalog")
|
| 100 |
+
base_table = self._find_table(source, ir.table_id)
|
| 101 |
+
cols_by_id: dict[str, ColRef] = {
|
| 102 |
+
c.column_id: (base_table, c) for c in base_table.columns
|
| 103 |
+
}
|
| 104 |
+
for j in ir.joins:
|
| 105 |
+
target = self._find_table(source, j.target_table_id)
|
| 106 |
+
for c in target.columns:
|
| 107 |
+
cols_by_id[c.column_id] = (target, c)
|
| 108 |
+
return base_table, cols_by_id
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
def _find_table(source: Source, table_id: str) -> Table:
|
| 112 |
+
table = next((t for t in source.tables if t.table_id == table_id), None)
|
| 113 |
if table is None:
|
| 114 |
raise SqlCompilerError(
|
| 115 |
+
f"table_id {table_id!r} not in source {source.source_id!r}"
|
| 116 |
)
|
| 117 |
+
return table
|
| 118 |
|
| 119 |
# ------------------------------------------------------------------
|
| 120 |
# Identifier quoting
|
|
|
|
| 125 |
"""Postgres-style double-quoted identifier with embedded-quote escape."""
|
| 126 |
return '"' + name.replace('"', '""') + '"'
|
| 127 |
|
| 128 |
+
def _qcol(self, ref: ColRef) -> str:
|
| 129 |
+
table, col = ref
|
| 130 |
return f"{self._qident(table.name)}.{self._qident(col.name)}"
|
| 131 |
|
| 132 |
# ------------------------------------------------------------------
|
|
|
|
| 134 |
# ------------------------------------------------------------------
|
| 135 |
|
| 136 |
def _build_select(
|
| 137 |
+
self, items: list[SelectItem], cols_by_id: dict[str, ColRef]
|
|
|
|
|
|
|
|
|
|
| 138 |
) -> tuple[str, set[str]]:
|
| 139 |
if not items:
|
| 140 |
raise SqlCompilerError("select clause cannot be empty")
|
| 141 |
parts: list[str] = []
|
| 142 |
aliases: set[str] = set()
|
| 143 |
for i, item in enumerate(items):
|
| 144 |
+
expr, alias = self._select_item(item, cols_by_id, i)
|
| 145 |
if alias:
|
| 146 |
parts.append(f"{expr} AS {self._qident(alias)}")
|
| 147 |
aliases.add(alias)
|
|
|
|
| 150 |
return "SELECT " + ", ".join(parts), aliases
|
| 151 |
|
| 152 |
def _select_item(
|
| 153 |
+
self, item: SelectItem, cols_by_id: dict[str, ColRef], index: int
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
) -> tuple[str, str | None]:
|
| 155 |
if isinstance(item, ColumnSelect):
|
| 156 |
+
ref = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
|
| 157 |
+
return self._qcol(ref), item.alias
|
| 158 |
if not isinstance(item, AggSelect):
|
| 159 |
raise SqlCompilerError(
|
| 160 |
f"select[{index}]: unknown SelectItem kind {type(item).__name__}"
|
| 161 |
)
|
| 162 |
+
return self._compile_agg(item, cols_by_id, index), item.alias
|
| 163 |
|
| 164 |
def _compile_agg(
|
| 165 |
+
self, item: AggSelect, cols_by_id: dict[str, ColRef], index: int
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
) -> str:
|
| 167 |
if item.fn == "count_distinct":
|
| 168 |
if item.column_id is None:
|
| 169 |
raise SqlCompilerError(
|
| 170 |
f"select[{index}].fn=count_distinct requires column_id"
|
| 171 |
)
|
| 172 |
+
ref = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
|
| 173 |
+
return f"COUNT(DISTINCT {self._qcol(ref)})"
|
| 174 |
if item.column_id is None:
|
| 175 |
if item.fn != "count":
|
| 176 |
raise SqlCompilerError(
|
|
|
|
| 178 |
"(only 'count' may omit it for COUNT(*))"
|
| 179 |
)
|
| 180 |
return "COUNT(*)"
|
| 181 |
+
ref = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
|
| 182 |
+
return f"{item.fn.upper()}({self._qcol(ref)})"
|
| 183 |
|
| 184 |
+
def _build_from(
|
| 185 |
+
self, base_table: Table, ir: QueryIR, cols_by_id: dict[str, ColRef]
|
| 186 |
+
) -> str:
|
| 187 |
+
sql = f"FROM {self._qident(base_table.name)}"
|
| 188 |
+
for i, j in enumerate(ir.joins):
|
| 189 |
+
left = self._require_col(cols_by_id, j.left_column_id, f"joins[{i}].left")
|
| 190 |
+
right = self._require_col(cols_by_id, j.right_column_id, f"joins[{i}].right")
|
| 191 |
+
target_table = right[0]
|
| 192 |
+
keyword = "INNER JOIN" if j.type == "inner" else "LEFT JOIN"
|
| 193 |
+
sql += (
|
| 194 |
+
f" {keyword} {self._qident(target_table.name)} "
|
| 195 |
+
f"ON {self._qcol(left)} = {self._qcol(right)}"
|
| 196 |
+
)
|
| 197 |
+
return sql
|
| 198 |
|
| 199 |
def _build_where(
|
| 200 |
self,
|
| 201 |
filters: list[FilterClause],
|
| 202 |
+
cols_by_id: dict[str, ColRef],
|
|
|
|
| 203 |
params: dict[str, Any],
|
| 204 |
param_seq: list[int],
|
| 205 |
) -> str:
|
| 206 |
if not filters:
|
| 207 |
return ""
|
| 208 |
parts = [
|
| 209 |
+
self._compile_filter(f, cols_by_id, params, param_seq, index=i)
|
| 210 |
for i, f in enumerate(filters)
|
| 211 |
]
|
| 212 |
return "WHERE " + " AND ".join(parts)
|
|
|
|
| 214 |
def _compile_filter(
|
| 215 |
self,
|
| 216 |
f: FilterClause,
|
| 217 |
+
cols_by_id: dict[str, ColRef],
|
|
|
|
| 218 |
params: dict[str, Any],
|
| 219 |
param_seq: list[int],
|
| 220 |
index: int,
|
| 221 |
) -> str:
|
| 222 |
+
ref = self._require_col(cols_by_id, f.column_id, f"filters[{index}]")
|
| 223 |
+
col_ref = self._qcol(ref)
|
| 224 |
op = f.op
|
| 225 |
|
| 226 |
if op == "is_null":
|
|
|
|
| 260 |
raise SqlCompilerError(f"filters[{index}]: unhandled op {op!r}")
|
| 261 |
|
| 262 |
def _build_groupby(
|
| 263 |
+
self, group_by: list[str], cols_by_id: dict[str, ColRef]
|
|
|
|
|
|
|
|
|
|
| 264 |
) -> str:
|
| 265 |
if not group_by:
|
| 266 |
return ""
|
| 267 |
parts = [
|
| 268 |
+
self._qcol(self._require_col(cols_by_id, col_id, f"group_by[{i}]"))
|
| 269 |
for i, col_id in enumerate(group_by)
|
| 270 |
]
|
| 271 |
return "GROUP BY " + ", ".join(parts)
|
|
|
|
| 273 |
def _build_orderby(
|
| 274 |
self,
|
| 275 |
order_by: list[OrderByClause],
|
| 276 |
+
cols_by_id: dict[str, ColRef],
|
|
|
|
| 277 |
select_aliases: set[str],
|
| 278 |
) -> str:
|
| 279 |
if not order_by:
|
|
|
|
| 281 |
parts: list[str] = []
|
| 282 |
for i, ob in enumerate(order_by):
|
| 283 |
if ob.column_id in cols_by_id:
|
| 284 |
+
ref = self._qcol(cols_by_id[ob.column_id])
|
| 285 |
elif ob.column_id in select_aliases:
|
| 286 |
ref = self._qident(ob.column_id)
|
| 287 |
else:
|
| 288 |
raise SqlCompilerError(
|
| 289 |
+
f"order_by[{i}].column_id: {ob.column_id!r} not in query "
|
| 290 |
"columns or select aliases"
|
| 291 |
)
|
| 292 |
parts.append(f"{ref} {ob.dir.upper()}")
|
|
|
|
| 320 |
|
| 321 |
@staticmethod
|
| 322 |
def _require_col(
|
| 323 |
+
cols_by_id: dict[str, ColRef], col_id: str, where: str
|
| 324 |
+
) -> ColRef:
|
| 325 |
+
ref = cols_by_id.get(col_id)
|
| 326 |
+
if ref is None:
|
| 327 |
+
raise SqlCompilerError(f"{where}.column_id: {col_id!r} not in query tables")
|
| 328 |
+
return ref
|
|
@@ -2,8 +2,14 @@
|
|
| 2 |
|
| 3 |
See ARCHITECTURE.md §7 for the schema.
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from typing import Any, Literal
|
|
@@ -18,6 +24,21 @@ FilterOp = Literal[
|
|
| 18 |
AggFn = Literal["count", "count_distinct", "sum", "avg", "min", "max"]
|
| 19 |
ValueType = Literal["int", "decimal", "string", "datetime", "date", "bool"]
|
| 20 |
SortDir = Literal["asc", "desc"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class ColumnSelect(BaseModel):
|
|
@@ -52,6 +73,7 @@ class QueryIR(BaseModel):
|
|
| 52 |
ir_version: str = "1.0"
|
| 53 |
source_id: str
|
| 54 |
table_id: str
|
|
|
|
| 55 |
select: list[SelectItem]
|
| 56 |
filters: list[FilterClause] = Field(default_factory=list)
|
| 57 |
group_by: list[str] = Field(default_factory=list)
|
|
|
|
| 2 |
|
| 3 |
See ARCHITECTURE.md §7 for the schema.
|
| 4 |
|
| 5 |
+
Scope: filter, group_by, agg, order_by, limit, plus a single-level `joins` to
|
| 6 |
+
related tables in the SAME source (KM-652 T4). having, offset, and boolean tree
|
| 7 |
+
filters are still deferred. Joins are supported on database (schema) sources only;
|
| 8 |
+
the validator rejects them on tabular sources (cross-file merge is a later step).
|
| 9 |
+
|
| 10 |
+
With joins, `select` / `group_by` / `filters` / `order_by` may reference a
|
| 11 |
+
`column_id` from the base table OR any joined table (column_ids are globally
|
| 12 |
+
unique), so a measure in one table can be grouped by a dimension in a related one.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from typing import Any, Literal
|
|
|
|
| 24 |
AggFn = Literal["count", "count_distinct", "sum", "avg", "min", "max"]
|
| 25 |
ValueType = Literal["int", "decimal", "string", "datetime", "date", "bool"]
|
| 26 |
SortDir = Literal["asc", "desc"]
|
| 27 |
+
JoinType = Literal["inner", "left"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Join(BaseModel):
|
| 31 |
+
"""A single equi-join to another table in the same source.
|
| 32 |
+
|
| 33 |
+
`left_column_id` is a column already in the query (base table or an earlier
|
| 34 |
+
join); `right_column_id` is a column in `target_table_id`. The validator
|
| 35 |
+
requires the pair to match a foreign key declared in the catalog.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
target_table_id: str
|
| 39 |
+
left_column_id: str
|
| 40 |
+
right_column_id: str
|
| 41 |
+
type: JoinType = "inner"
|
| 42 |
|
| 43 |
|
| 44 |
class ColumnSelect(BaseModel):
|
|
|
|
| 73 |
ir_version: str = "1.0"
|
| 74 |
source_id: str
|
| 75 |
table_id: str
|
| 76 |
+
joins: list[Join] = Field(default_factory=list)
|
| 77 |
select: list[SelectItem]
|
| 78 |
filters: list[FilterClause] = Field(default_factory=list)
|
| 79 |
group_by: list[str] = Field(default_factory=list)
|
|
@@ -35,8 +35,12 @@ class IRValidator:
|
|
| 35 |
|
| 36 |
def validate(self, ir: QueryIR, catalog: Catalog) -> None:
|
| 37 |
source = self._find_source(catalog, ir.source_id)
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
select_aliases: set[str] = set()
|
| 42 |
for i, item in enumerate(ir.select):
|
|
@@ -96,6 +100,51 @@ class IRValidator:
|
|
| 96 |
f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
|
| 97 |
)
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
@staticmethod
|
| 100 |
def _find_source(catalog: Catalog, source_id: str) -> Source:
|
| 101 |
for s in catalog.sources:
|
|
|
|
| 35 |
|
| 36 |
def validate(self, ir: QueryIR, catalog: Catalog) -> None:
|
| 37 |
source = self._find_source(catalog, ir.source_id)
|
| 38 |
+
base_table = self._find_table(source, ir.table_id)
|
| 39 |
+
# Columns available to select/filter/group/order — grows as joins are added.
|
| 40 |
+
columns_by_id: dict[str, Column] = {c.column_id: c for c in base_table.columns}
|
| 41 |
+
|
| 42 |
+
if ir.joins:
|
| 43 |
+
self._validate_joins(ir, source, columns_by_id)
|
| 44 |
|
| 45 |
select_aliases: set[str] = set()
|
| 46 |
for i, item in enumerate(ir.select):
|
|
|
|
| 100 |
f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
|
| 101 |
)
|
| 102 |
|
| 103 |
+
def _validate_joins(
|
| 104 |
+
self, ir: QueryIR, source: Source, columns_by_id: dict[str, Column]
|
| 105 |
+
) -> None:
|
| 106 |
+
"""Validate joins and grow `columns_by_id` with each joined table's columns.
|
| 107 |
+
|
| 108 |
+
Joins are DB-only in v1; each join's column pair must be a declared foreign
|
| 109 |
+
key, the left column must already be in the query, the right in the target.
|
| 110 |
+
"""
|
| 111 |
+
if source.source_type != "schema":
|
| 112 |
+
raise IRValidationError(
|
| 113 |
+
f"joins are only supported on database sources in v1; source "
|
| 114 |
+
f"{ir.source_id!r} is {source.source_type!r}"
|
| 115 |
+
)
|
| 116 |
+
fk_pairs = self._fk_pairs(source)
|
| 117 |
+
for i, j in enumerate(ir.joins):
|
| 118 |
+
where = f"joins[{i}]"
|
| 119 |
+
target = self._find_table(source, j.target_table_id)
|
| 120 |
+
target_cols = {c.column_id: c for c in target.columns}
|
| 121 |
+
if j.left_column_id not in columns_by_id:
|
| 122 |
+
raise IRValidationError(
|
| 123 |
+
f"{where}.left_column_id {j.left_column_id!r} is not in the query "
|
| 124 |
+
"so far (base table or an earlier join)"
|
| 125 |
+
)
|
| 126 |
+
if j.right_column_id not in target_cols:
|
| 127 |
+
raise IRValidationError(
|
| 128 |
+
f"{where}.right_column_id {j.right_column_id!r} not in target table "
|
| 129 |
+
f"{j.target_table_id!r}"
|
| 130 |
+
)
|
| 131 |
+
if frozenset((j.left_column_id, j.right_column_id)) not in fk_pairs:
|
| 132 |
+
raise IRValidationError(
|
| 133 |
+
f"{where}: ({j.left_column_id!r}, {j.right_column_id!r}) is not a "
|
| 134 |
+
f"declared foreign key in source {ir.source_id!r} — only FK-backed "
|
| 135 |
+
"joins are allowed"
|
| 136 |
+
)
|
| 137 |
+
columns_by_id.update(target_cols)
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def _fk_pairs(source: Source) -> set[frozenset[str]]:
|
| 141 |
+
"""All declared FK column pairs in the source, as unordered {col, col} sets."""
|
| 142 |
+
pairs: set[frozenset[str]] = set()
|
| 143 |
+
for t in source.tables:
|
| 144 |
+
for fk in t.foreign_keys:
|
| 145 |
+
pairs.add(frozenset((fk.column_id, fk.target_column_id)))
|
| 146 |
+
return pairs
|
| 147 |
+
|
| 148 |
@staticmethod
|
| 149 |
def _find_source(catalog: Catalog, source_id: str) -> Source:
|
| 150 |
for s in catalog.sources:
|