Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
7d36567
·
1 Parent(s): b1dd8e6

[KM-652] feat(query): single-level FK joins in QueryIR (T4)

Browse files

The 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 CHANGED
@@ -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, single-table query against a structured source (DB "
47
- "schema or tabular file) and return rows. The `ir` argument is an inline "
48
- "QueryIR (the JSON intent: source_id, table_id, select, filters, group_by, "
49
- "order_by, limit) — never SQL. This is the data-access entry point: use it "
50
- "to select, filter, and pull the rows the analytics (`analyze_*`) tools "
51
- "then consume. It also does simple built-in aggregation the IR can express "
52
- "(count/sum/avg/min/max/count_distinct). Do NOT use it for richer statistics "
 
 
 
 
 
 
 
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)."
src/config/prompts/planner.md CHANGED
@@ -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:
src/config/prompts/query_planner.md CHANGED
@@ -15,7 +15,10 @@ A `QueryIR` object:
15
  {
16
  "ir_version": "1.0",
17
  "source_id": "...", // pick from catalog
18
- "table_id": "...", // pick from chosen source
 
 
 
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. **Single-table only in v1.** Pick the table whose columns best answer the question. If the question genuinely needs a join, pick the table that yields the most useful answer alone and the user can refine.
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
  ```
src/query/compiler/pandas.py CHANGED
@@ -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
 
src/query/compiler/sql.py CHANGED
@@ -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
- v1 supports the Postgres dialect only. Supabase reuses the same compiler
13
- output (Supabase = Postgres). MySQL / BigQuery / Snowflake compilers will
14
- be separate classes that implement `BaseCompiler`.
 
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
- _, table, cols_by_id = self._lookup(ir)
69
  params: dict[str, Any] = {}
70
  param_seq = [0]
71
 
72
- select_clause, select_aliases = self._build_select(ir.select, table, cols_by_id)
73
- from_clause = self._build_from(table)
74
- where_clause = self._build_where(ir.filters, table, cols_by_id, params, param_seq)
75
- groupby_clause = self._build_groupby(ir.group_by, table, cols_by_id)
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[Source, Table, dict[str, Column]]:
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
- table = next(
99
- (t for t in source.tables if t.table_id == ir.table_id), None
100
- )
 
 
 
 
 
 
 
 
 
 
101
  if table is None:
102
  raise SqlCompilerError(
103
- f"table_id {ir.table_id!r} not in source {ir.source_id!r}"
104
  )
105
- return source, table, {c.column_id: c for c in table.columns}
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, table: Table, col: Column) -> str:
 
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, table, cols_by_id, i)
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
- col = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
151
- return self._qcol(table, col), item.alias
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, table, cols_by_id, index), item.alias
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
- col = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
171
- return f"COUNT(DISTINCT {self._qcol(table, col)})"
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
- col = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
180
- return f"{item.fn.upper()}({self._qcol(table, col)})"
181
 
182
- def _build_from(self, table: Table) -> str:
183
- return f"FROM {self._qident(table.name)}"
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  def _build_where(
186
  self,
187
  filters: list[FilterClause],
188
- table: Table,
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, table, cols_by_id, params, param_seq, index=i)
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
- table: Table,
205
- cols_by_id: dict[str, Column],
206
  params: dict[str, Any],
207
  param_seq: list[int],
208
  index: int,
209
  ) -> str:
210
- col = self._require_col(cols_by_id, f.column_id, f"filters[{index}]")
211
- col_ref = self._qcol(table, col)
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(table, self._require_col(cols_by_id, col_id, f"group_by[{i}]"))
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
- table: Table,
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(table, cols_by_id[ob.column_id])
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 table "
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, Column], col_id: str, where: str
316
- ) -> Column:
317
- col = cols_by_id.get(col_id)
318
- if col is None:
319
- raise SqlCompilerError(f"{where}.column_id: {col_id!r} not in table")
320
- return col
 
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
src/query/ir/models.py CHANGED
@@ -2,8 +2,14 @@
2
 
3
  See ARCHITECTURE.md §7 for the schema.
4
 
5
- Initial scope: single-table; filter, group_by, agg, order_by, limit.
6
- Joins, having, offset, boolean tree filters are deferred to later versions.
 
 
 
 
 
 
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)
src/query/ir/validator.py CHANGED
@@ -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
- table = self._find_table(source, ir.table_id)
39
- columns_by_id: dict[str, Column] = {c.column_id: c for c in table.columns}
 
 
 
 
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: