---
phase: 04-query-interface
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
  - src/expand.rs
  - src/query/mod.rs
  - src/query/table_function.rs
  - src/query/error.rs
  - src/lib.rs
autonomous: true
requirements:
  - QUERY-01
  - QUERY-02
  - QUERY-03

must_haves:
  truths:
    - "FROM semantic_query('orders', dimensions := ['region'], metrics := ['total_revenue']) returns a result set with correct aggregated values"
    - "FROM semantic_query('orders', dimensions := ['region']) without metrics returns DISTINCT region values"
    - "FROM semantic_query('orders', metrics := ['total_revenue']) without dimensions returns a single global aggregate row"
    - "User WHERE clauses AND-compose with view's row-level filters -- both are applied"
    - "SELECT * returns columns named after the requested dimensions and metrics with correct DuckDB types"
    - "FROM semantic_query('nonexistent', ...) errors with a fuzzy suggestion of similar view names"
    - "FROM semantic_query('orders') with no dimensions or metrics errors with a helpful message"
  artifacts:
    - path: "src/expand.rs"
      provides: "Modified expand() supporting dimensions-only (SELECT DISTINCT) and metrics-only queries"
      contains: "SELECT DISTINCT"
    - path: "src/query/table_function.rs"
      provides: "SemanticViewVTab implementing VTab trait with named LIST(VARCHAR) parameters"
      contains: "named_parameters"
    - path: "src/query/error.rs"
      provides: "Query-specific error types with fuzzy view name matching and actionable hints"
      contains: "suggest_closest"
    - path: "src/query/mod.rs"
      provides: "Query module declarations"
    - path: "src/lib.rs"
      provides: "Updated entrypoint registering semantic_query table function"
      contains: "semantic_query"
  key_links:
    - from: "src/query/table_function.rs"
      to: "src/expand.rs"
      via: "expand() call in bind phase"
      pattern: "expand\\("
    - from: "src/query/table_function.rs"
      to: "src/catalog.rs"
      via: "CatalogState read in bind phase"
      pattern: "CatalogState"
    - from: "src/lib.rs"
      to: "src/query/table_function.rs"
      via: "register_table_function_with_extra_info in entrypoint"
      pattern: "register_table_function"
    - from: "src/query/table_function.rs"
      to: "libduckdb-sys"
      via: "duckdb_query FFI for SQL execution in func phase"
      pattern: "duckdb_query"
---

<objective>
Implement the core query table function that wires the expansion engine to DuckDB's query pipeline, enabling `FROM semantic_query('view_name', dimensions := [...], metrics := [...])` to return correct result sets.

Purpose: This is the central deliverable of Phase 4 -- connecting the pure Rust expand() function (Phase 3) to real DuckDB query execution. Without this, semantic views are definitions only, not queryable objects.

Output: A working `semantic_query` table function that accepts named LIST(VARCHAR) parameters, expands the query via expand(), executes the generated SQL via raw FFI, and returns results with correct column types inferred at bind time.
</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/04-query-interface/04-RESEARCH.md
@.planning/phases/03-expansion-engine/03-01-SUMMARY.md
@.planning/phases/03-expansion-engine/03-02-SUMMARY.md
@.planning/phases/02-storage-and-ddl/02-02-SUMMARY.md
@.planning/phases/02-storage-and-ddl/02-04-SUMMARY.md
@src/expand.rs
@src/model.rs
@src/catalog.rs
@src/lib.rs
@src/ddl/define.rs
@Cargo.toml
</context>

<tasks>

<task type="auto">
  <name>Task 1: Modify expand() for dimensions-only and metrics-only queries</name>
  <files>src/expand.rs</files>
  <action>
Modify the `expand()` function in `src/expand.rs` to support three query modes per CONTEXT.md decision (Snowflake model):

1. **Dimensions-only** (dimensions present, no metrics): Generate `SELECT DISTINCT dim_expr AS "dim_name", ... FROM "_base"` -- no GROUP BY, no aggregation. Remove or reclassify the `EmptyMetrics` error.

2. **Metrics-only** (metrics present, no dimensions): Already works (global aggregate with no GROUP BY). Keep as-is.

3. **Both dimensions and metrics**: Already works (GROUP BY). Keep as-is.

4. **Neither dimensions nor metrics**: This is the new error case. Replace `EmptyMetrics` with `EmptyRequest` error variant:
   ```rust
   EmptyRequest { view_name: String }
   ```
   Display message: `"semantic view '{view_name}': specify at least dimensions := [...] or metrics := [...]"`

**Implementation details:**
- Move the empty-check to the top: if both `req.dimensions.is_empty()` and `req.metrics.is_empty()`, return `Err(ExpandError::EmptyRequest { ... })`
- Remove the `EmptyMetrics` check entirely
- After building the outer SELECT, add logic: if `resolved_mets.is_empty()` (dimensions-only), prepend `SELECT DISTINCT` instead of `SELECT`, and skip the GROUP BY clause
- When dimensions-only, the SELECT items are just `dim.expr AS quote_ident(&dim.name)` with no metric items and no GROUP BY
- When metrics-only (no dimensions), behavior is unchanged: SELECT metrics FROM "_base" with no GROUP BY

**Test updates:**
- Update the existing `test_empty_metrics_is_error` test to become `test_empty_request_is_error` -- test that both empty dims and empty metrics triggers `EmptyRequest`
- Add `test_dimensions_only_generates_distinct` -- verify that dimensions-only produces `SELECT DISTINCT` without GROUP BY
- Add `test_metrics_only_still_works` -- verify that metrics-only (no dimensions) still produces global aggregate
- Verify all existing tests still pass -- the only signature change is `EmptyMetrics` renamed to `EmptyRequest`

**Also update proptest fixtures** in `tests/proptest_expand.rs`: the `arb_query_request()` strategy currently generates at least one metric. Update it to also allow dimensions-only requests (empty metrics, non-empty dimensions). The property `group_by_contains_all_dimensions` should be adjusted: when metrics is empty, assert SELECT DISTINCT is used instead of GROUP BY.

**Warning:** The `EmptyMetrics` variant is likely referenced in proptest fixtures. Search for all references and update them.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- --include-ignored 2>&1 | tail -20</automated>
    <manual>Check that expand() with dimensions-only produces SELECT DISTINCT, metrics-only produces global aggregate, and both-empty errors with EmptyRequest</manual>
  </verify>
  <done>expand() supports all three query modes (dimensions-only, metrics-only, both) and errors on empty requests. All existing tests pass with updated error variant. New tests cover dimensions-only and empty-request cases.</done>
</task>

<task type="auto">
  <name>Task 2: Implement semantic_query table function with FFI SQL execution</name>
  <files>
    src/query/mod.rs
    src/query/table_function.rs
    src/query/error.rs
    src/lib.rs
  </files>
  <action>
Create the `src/query/` module implementing the `semantic_query` table function. This is the core of Phase 4.

**Use the single generic function approach** per RESEARCH.md recommendation: register one `semantic_query(view_name VARCHAR, dimensions := LIST(VARCHAR), metrics := LIST(VARCHAR))` function rather than per-view functions. This avoids the dynamic registration problem from scalar invoke. The view name is a positional parameter.

**Module structure:**

`src/query/mod.rs`:
```rust
#[cfg(feature = "extension")]
pub mod table_function;
#[cfg(feature = "extension")]
pub mod error;
```

`src/query/error.rs`:
- `QueryError` enum with variants:
  - `ViewNotFound { name: String, suggestion: Option<String>, available: Vec<String> }` -- uses `suggest_closest` from expand.rs (make it `pub` if not already)
  - `EmptyRequest { view_name: String }` -- no dimensions or metrics specified
  - `ExpandFailed { source: ExpandError }` -- wraps expand() errors
  - `SqlExecution { expanded_sql: String, duckdb_error: String }` -- SQL execution failed; shows both per CONTEXT.md
- All error messages include actionable hints per CONTEXT.md: `"Run FROM describe_semantic_view('{name}') to see available dimensions and metrics"`
- Implement `Display` and `Error` for `QueryError`

`src/query/table_function.rs`:
Implement `SemanticViewVTab` as a `VTab` with:

1. **Named parameters:**
   ```rust
   fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>> {
       let list_varchar = LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar));
       Some(vec![
           ("dimensions".to_string(), list_varchar.clone()),
           ("metrics".to_string(), list_varchar),
       ])
   }
   ```

2. **Parameters (positional):**
   ```rust
   fn parameters() -> Option<Vec<LogicalTypeHandle>> {
       Some(vec![LogicalTypeHandle::from(LogicalTypeId::Varchar)])  // view_name
   }
   ```

3. **BindData struct (`SemanticViewBindData`)**:
   - `expanded_sql: String` -- the SQL generated by expand()
   - `column_names: Vec<String>` -- output column names
   - `column_types: Vec<LogicalTypeId>` -- output column types
   - `view_name: String` -- for error context
   - `dimensions: Vec<String>` -- for EXPLAIN metadata
   - `metrics: Vec<String>` -- for EXPLAIN metadata

4. **InitData struct (`SemanticViewInitData`)**:
   - `done: AtomicBool` -- signals completion
   - Optionally: materialized result data if executing in func

5. **bind() implementation:**
   - Extract view_name from positional param 0 via `bind.get_parameter(0)`
   - Extract dimensions and metrics from named params via `bind.get_named_parameter("dimensions")` / `bind.get_named_parameter("metrics")`
   - Extract strings from `Value::List(Vec<Value>)` -- each value should be `Value::Text(s)`. Handle the variant correctly by checking what duckdb-rs actually uses (may be `Value::Text` or need `.to_string()`)
   - Read CatalogState from extra_info: `bind.get_extra_info::<QueryState>()` -- look up view JSON by name
   - If view not found: fuzzy match against catalog keys using `suggest_closest()` (made pub); return `QueryError::ViewNotFound`
   - Parse definition via `SemanticViewDefinition::from_json()`
   - Build `QueryRequest` and call `expand()` -- catch ExpandError, wrap as QueryError::ExpandFailed
   - **Schema inference via LIMIT 0**: Execute `{expanded_sql} LIMIT 0` using the raw `duckdb_connection` from QueryState to discover column names and types. Use `duckdb_column_count()`, `duckdb_column_name()`, `duckdb_column_type()` from libduckdb-sys. Map DuckDB type enums to `LogicalTypeId` values.
   - If LIMIT 0 execution fails (re-entrant SQL not allowed in bind), **fall back**: declare all dimension columns as VARCHAR and all metric columns as DOUBLE. Log a warning but proceed.
   - Call `bind.add_result_column(name, type)` for each inferred column

6. **func() implementation:**
   - The critical path: execute the expanded SQL via raw FFI `duckdb_query()` on the stored connection handle
   - Use the `duckdb_connection` from QueryState (passed via extra_info at registration time)
   - Execute SQL, fetch result chunks via `duckdb_fetch_chunk()`, copy data to output `DataChunkHandle`
   - Handle type mapping: for each column, read values from the FFI result and write to the output chunk's flat vectors
   - Set `done` AtomicBool when all rows are emitted
   - On SQL error: include both the expanded SQL and DuckDB's error message per CONTEXT.md

7. **QueryState struct** (stored as extra_info):
   ```rust
   #[derive(Clone)]
   pub struct QueryState {
       pub catalog: CatalogState,
       pub conn: ffi::duckdb_connection,  // raw pointer from entrypoint
   }
   // SAFETY: duckdb_connection is the host connection that lives for the session lifetime.
   // It is safe to send across threads because DuckDB manages internal synchronization.
   unsafe impl Send for QueryState {}
   unsafe impl Sync for QueryState {}
   ```

**CRITICAL: Extracting the raw duckdb_connection from the Connection object.**
The `duckdb_entrypoint_c_api` macro wraps the raw `duckdb_connection` in a `Connection`. To extract the raw handle:
- Option A: The macro expansion may provide access to the raw handle before wrapping. Check the macro source in duckdb-rs.
- Option B: Use `std::mem::transmute` or pointer arithmetic on the Connection struct to access the inner `duckdb_connection`. The Connection struct layout has the raw handle as the first field of InnerConnection.
- Option C: Use `libduckdb_sys::duckdb_connect` to create a new connection to the same database. This is the safest approach -- call `duckdb_connect(db_handle, &mut conn_handle)` where `db_handle` comes from `duckdb_connection_get_database(original_conn)`. This creates a new connection on the same database that is independent of the original.

**Strongly prefer Option C** -- creating a new connection via the C API is safe and avoids any struct layout assumptions. The original connection is only used during the entrypoint to get the database handle.

**`src/lib.rs` updates:**
- Add `pub mod query;` (feature-gated like ddl)
- In `extension_entrypoint`, after registering DDL functions:
  - Extract the raw `duckdb_connection` handle (via the approach above)
  - Create a `QueryState` with catalog + connection
  - Register `semantic_query` table function: `con.register_table_function_with_extra_info::<SemanticViewVTab, _>("semantic_query", &query_state)?;`

**Make `suggest_closest` pub in expand.rs** -- it is needed by query/error.rs for fuzzy view name matching. Add `pub` visibility modifier.

**Feature gate:** All query module code must be behind `#[cfg(feature = "extension")]` like the ddl module, because it uses `duckdb::vtab` APIs only available in the extension build.

**IMPORTANT: If re-entrant SQL execution does not work in bind() or func():**
- Try executing in `init()` instead -- the init phase may have a different lock context
- If none work, materialize a temporary table in the entrypoint or use a completely different connection
- Document the approach taken as a key decision

**WHERE composition (QUERY-02):** No additional code is needed. The CTE architecture embeds view filters inside `WITH "_base" AS (...)`. User WHERE applies to the outer result set from the table function. DuckDB naturally AND-composes them.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo build --no-default-features --features extension 2>&1 | tail -10</automated>
    <manual>Extension builds without errors. Verify via `just test-sql` that `semantic_query` function is registered and callable.</manual>
  </verify>
  <done>The semantic_query table function is registered in the extension entrypoint, accepts named LIST(VARCHAR) parameters, looks up view definitions from CatalogState, calls expand(), executes the expanded SQL via raw FFI, and returns results with correctly inferred column types. WHERE composition works via CTE architecture. Fuzzy view name matching with actionable hints is implemented.</done>
</task>

</tasks>

<verification>
1. `cargo test` passes (all unit tests including updated expand tests)
2. `cargo build --no-default-features --features extension` succeeds (extension compiles)
3. `just test-sql` passes (SQLLogicTest LOAD smoke test still works)
4. Manual test via DuckDB CLI:
   ```sql
   LOAD 'build/release/extension/semantic_views/semantic_views.duckdb_extension';
   CREATE TABLE orders (region VARCHAR, amount DECIMAL(10,2), status VARCHAR);
   INSERT INTO orders VALUES ('EMEA', 100, 'completed'), ('APAC', 200, 'completed'), ('EMEA', 150, 'pending');
   SELECT define_semantic_view('test_orders', '{"base_table":"orders","dimensions":[{"name":"region","expr":"region"}],"metrics":[{"name":"total","expr":"sum(amount)"}],"filters":["status = ''completed''"]}');
   FROM semantic_query('test_orders', dimensions := ['region'], metrics := ['total']);
   -- Should return: EMEA | 100.00, APAC | 200.00
   ```
</verification>

<success_criteria>
- `FROM semantic_query('view_name', dimensions := [...], metrics := [...])` returns correct aggregated results
- Dimensions-only queries return DISTINCT values
- Metrics-only queries return global aggregates
- User WHERE clauses compose with view filters
- SELECT * returns correctly named and typed columns
- Missing view names trigger fuzzy suggestions
- Empty parameters trigger helpful error messages
- Extension compiles and loads without error
</success_criteria>

<output>
After completion, create `.planning/phases/04-query-interface/04-01-SUMMARY.md`
</output>
