---
phase: 03-expansion-engine
plan: "01"
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/model.rs
  - src/expand.rs
  - src/lib.rs
  - Cargo.toml
autonomous: true
requirements:
  - MODEL-01
  - MODEL-02
  - MODEL-04
  - EXPAND-01
  - EXPAND-04
  - TEST-01

must_haves:
  truths:
    - "expand() with dimensions and metrics produces a CTE-wrapped SQL string with correct GROUP BY"
    - "expand() with empty dimensions produces a global aggregate query without GROUP BY"
    - "All table names, aliases, and CTE names in emitted SQL are double-quoted"
    - "Dimension expressions and metric expressions are NOT double-quoted (emitted as raw SQL)"
    - "Filter expressions are AND-composed in the WHERE clause, each parenthesized"
    - "Existing SemanticViewDefinition JSON without source_table still deserializes correctly"
  artifacts:
    - path: "src/expand.rs"
      provides: "expand() function, QueryRequest, ExpandError, quote_ident()"
      exports: ["expand", "QueryRequest", "ExpandError", "quote_ident"]
    - path: "src/model.rs"
      provides: "Updated Dimension and Metric with optional source_table field"
      contains: "source_table"
    - path: "src/lib.rs"
      provides: "pub mod expand declaration"
      contains: "pub mod expand"
  key_links:
    - from: "src/expand.rs"
      to: "src/model.rs"
      via: "uses SemanticViewDefinition, Dimension, Metric"
      pattern: "use crate::model"
---

<objective>
Implement the core expansion engine: model struct updates and the `expand()` function that generates CTE-wrapped SQL from a semantic view definition and a dimension+metric selection.

Purpose: This is the heart of the extension -- transforming a declarative semantic view definition into executable SQL. The expand() function must correctly handle GROUP BY inference, filter composition, identifier quoting, and the CTE structure. Join resolution is deferred to Plan 02.

Output: `src/expand.rs` with `expand()`, `QueryRequest`, `ExpandError`, `quote_ident()` -- fully unit-tested via TDD. Updated `src/model.rs` with `source_table` field on Dimension and Metric.
</objective>

<execution_context>
@/Users/paul/.claude/get-shit-done/workflows/execute-plan.md
@/Users/paul/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-expansion-engine/03-CONTEXT.md
@.planning/phases/03-expansion-engine/03-RESEARCH.md
@src/model.rs
@src/lib.rs
@Cargo.toml
</context>

<tasks>

<task type="auto">
  <name>Task 1: Update model structs and add expand module scaffold</name>
  <files>src/model.rs, src/expand.rs, src/lib.rs</files>
  <action>
**src/model.rs changes:**
- Add `#[serde(default)] pub source_table: Option<String>` to `Dimension` struct
- Add `#[serde(default)] pub source_table: Option<String>` to `Metric` struct
- Add a unit test that verifies old JSON (without `source_table`) still deserializes correctly (backward compat with Phase 2 definitions)
- Add a unit test that verifies JSON with `source_table` present deserializes correctly

**src/expand.rs creation:**
- Create `src/expand.rs` with these public types:
  - `QueryRequest { pub dimensions: Vec<String>, pub metrics: Vec<String> }` -- the user's query selection
  - `ExpandError` enum with variants: `EmptyMetrics { view_name: String }`, `UnknownDimension { view_name: String, name: String, available: Vec<String>, suggestion: Option<String> }`, `UnknownMetric { view_name: String, name: String, available: Vec<String>, suggestion: Option<String> }`, `DuplicateDimension { view_name: String, name: String }`, `DuplicateMetric { view_name: String, name: String }`. Implement `std::fmt::Display` for `ExpandError` with clear error messages.
  - `quote_ident(ident: &str) -> String` -- wraps identifier in double-quotes, escapes embedded `"` as `""`. This is a public utility.
  - `expand(view_name: &str, def: &SemanticViewDefinition, req: &QueryRequest) -> Result<String, ExpandError>` -- stub that returns `todo!()`
- Add unit tests for `quote_ident`: simple identifier, reserved word, embedded double quote, identifier with spaces

**src/lib.rs:**
- Add `pub mod expand;` declaration (NOT feature-gated -- this is pure Rust, usable in all build modes)

**Cargo.toml:**
- No changes yet (strsim added in Plan 02)
  </action>
  <verify>
    <automated>cargo test --lib -- model:: expand::tests::quote_ident 2>&1 | tail -20</automated>
    <manual>Verify model backward compat test passes and quote_ident tests pass</manual>
  </verify>
  <done>
- Dimension and Metric structs have `source_table: Option&lt;String&gt;` with `#[serde(default)]`
- Old JSON without `source_table` still deserializes (backward compat verified by test)
- `expand.rs` exists with QueryRequest, ExpandError (with Display), quote_ident, and expand stub
- quote_ident unit tests pass (4+ tests)
- `pub mod expand;` in lib.rs
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement expand() with TDD -- RED then GREEN</name>
  <files>src/expand.rs</files>
  <action>
**TDD RED phase -- write failing tests first:**

Write the following unit tests in `src/expand.rs` (all should fail initially because expand() is a todo!() stub):

1. `test_basic_single_dimension_single_metric` -- expand with `dimensions: ["region"]`, `metrics: ["total_revenue"]` on an orders view with `base_table: "orders"`, dimension `{name: "region", expr: "region"}`, metric `{name: "total_revenue", expr: "sum(amount)"}`. Assert the output matches the CTE-wrapped SQL:
```sql
WITH "_base" AS (
    SELECT *
    FROM "orders"
)
SELECT
    region AS "region",
    sum(amount) AS "total_revenue"
FROM "_base"
GROUP BY
    region
```

2. `test_multiple_dimensions_multiple_metrics` -- two dimensions, two metrics. Assert both dimensions appear in GROUP BY and both metrics in SELECT.

3. `test_global_aggregate_no_dimensions` -- empty dimensions, one metric. Assert no GROUP BY clause appears. Assert the SQL is:
```sql
WITH "_base" AS (
    SELECT *
    FROM "orders"
)
SELECT
    sum(amount) AS "total_revenue"
FROM "_base"
```

4. `test_filters_and_composed` -- two filter expressions. Assert WHERE clause appears in the base CTE with `(filter1) AND (filter2)` structure.

5. `test_single_filter` -- one filter expression. Assert WHERE clause has `(filter)` without AND.

6. `test_identifier_quoting` -- base table name is a reserved word (e.g., "select"). Assert the FROM clause quotes it as `"select"`. Verify CTE name is always `"_base"`.

7. `test_dimension_expression_not_quoted` -- dimension with expr `date_trunc('month', created_at)`. Assert the expression appears verbatim in SELECT and GROUP BY (NOT wrapped in double quotes).

8. `test_empty_metrics_error` -- empty metrics array. Assert `ExpandError::EmptyMetrics` is returned.

**TDD GREEN phase -- implement expand():**

Implement `expand()` following this algorithm:
1. Validate: if `req.metrics` is empty, return `ExpandError::EmptyMetrics`
2. (Skip name validation and join resolution -- those come in Plan 02)
3. Build the base CTE:
   - `WITH "_base" AS (\n    SELECT *\n    FROM {quote_ident(base_table)}`
   - If joins exist AND are needed (Plan 02 adds this logic; for now, include ALL declared joins): append each join as `\n    JOIN {quote_ident(join.table)} ON {join.on}`
   - If filters exist: append `\n    WHERE {filters joined with " AND ", each wrapped in parens}`
   - Close CTE: `\n)`
4. Build the outer SELECT:
   - For each dimension: `    {dim.expr} AS {quote_ident(dim.name)}`
   - For each metric: `    {metric.expr} AS {quote_ident(metric.name)}`
   - Join with `,\n`
5. Build GROUP BY (only if dimensions non-empty):
   - For each dimension: `    {dim.expr}`
   - Join with `,\n`
6. Assemble: CTE + `\nSELECT\n` + select_items + `\nFROM "_base"` + optional `\nGROUP BY\n` + group_items

Use `std::fmt::Write` or simple `format!`/`push_str` for string building. Ensure human-readable formatting with consistent indentation (4 spaces).

**Important anti-patterns to avoid (from RESEARCH.md):**
- Do NOT quote dimension/metric expressions -- they are raw SQL fragments
- Do NOT quote filter expressions -- they are raw SQL fragments
- DO quote: base_table name, join table names, dimension aliases (AS "name"), metric aliases (AS "name"), CTE name ("_base")
- Use the expression string in both SELECT and GROUP BY (not the alias)

**Name matching note:** Use case-insensitive matching when looking up dimension/metric names (per RESEARCH.md Pitfall 6). Use `.eq_ignore_ascii_case()` or build a case-insensitive lookup. This applies to the dimension/metric lookup when translating requested names to their expressions.
  </action>
  <verify>
    <automated>cargo test --lib -- expand::tests 2>&1 | tail -30</automated>
    <manual>All 8+ expand unit tests pass. Output SQL matches expected CTE structure.</manual>
  </verify>
  <done>
- expand() produces correct CTE-wrapped SQL for single dim/metric, multiple dims/metrics, and global aggregates
- Filters are AND-composed with parentheses in the WHERE clause
- All identifiers (table names, aliases) are double-quoted via quote_ident()
- Expressions (dim.expr, metric.expr, filters) are NOT quoted
- Empty metrics returns ExpandError::EmptyMetrics
- All 8+ TDD unit tests pass (cargo test --lib -- expand::tests)
- Name matching is case-insensitive
  </done>
</task>

</tasks>

<verification>
```bash
# All expand module tests pass
cargo test --lib -- expand::tests

# All model tests still pass (backward compat)
cargo test --lib -- model::tests

# Clippy clean
cargo clippy -- -D warnings

# Full test suite green
cargo test
```
</verification>

<success_criteria>
- `expand()` generates correct CTE-wrapped SQL for all non-join cases
- 12+ unit tests pass covering quote_ident, basic expansion, filters, global aggregates, error cases
- Model structs updated with backward-compatible `source_table` field
- `cargo test` and `cargo clippy` both pass cleanly
</success_criteria>

<output>
After completion, create `.planning/phases/03-expansion-engine/03-01-SUMMARY.md`
</output>
