---
phase: 09-time-dimensions
plan: 02
type: tdd
wave: 2
depends_on: [09-01]
files_modified:
  - src/expand.rs
  - src/query/table_function.rs
autonomous: true
requirements: [TIME-02, TIME-03, TIME-04]

must_haves:
  truths:
    - "semantic_query with a time dimension generates date_trunc('gran', expr)::DATE in the SQL SELECT, using declared granularity"
    - "semantic_query with granularities := {'order_date': 'month'} override uses 'month' instead of the declared granularity"
    - "date_trunc result is cast to ::DATE so DATE source columns return DATE (not TIMESTAMP)"
    - "granularity override for a non-time dimension produces a clear error at bind time"
    - "unsupported granularity in override map produces a clear error at bind time with valid values listed"
  artifacts:
    - path: "src/expand.rs"
      provides: "date_trunc codegen for time dimensions + granularity_overrides in QueryRequest"
      contains: "granularity_overrides"
    - path: "src/query/table_function.rs"
      provides: "granularities MAP named parameter registration + extraction + validation in bind()"
      contains: "granularities"
  key_links:
    - from: "src/query/table_function.rs bind()"
      to: "src/expand.rs expand()"
      via: "QueryRequest.granularity_overrides passed through"
      pattern: "granularity_overrides"
    - from: "src/expand.rs expand()"
      to: "generated SQL string"
      via: "date_trunc('gran', expr)::DATE format string"
      pattern: "date_trunc"
---

<objective>
Wire time dimension SQL codegen and query-time granularity override into `expand()` and `bind()`.

Purpose: TIME-02 requires `date_trunc` in the generated SQL; TIME-03 requires query-time override via `granularities` MAP parameter; TIME-04 requires `::DATE` cast for correct output type.

Output: Modified `src/expand.rs` (QueryRequest + expand codegen) and `src/query/table_function.rs` (MAP parameter registration + extraction + validation). All existing tests pass. New unit tests for expand() + bind() validation.
</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/STATE.md
@.planning/phases/09-time-dimensions/09-01-SUMMARY.md
@src/expand.rs
@src/query/table_function.rs

<interfaces>
<!-- Key interfaces from codebase — executor reads these before modifying -->

From src/expand.rs (current QueryRequest — Plan 01 adds dim_type/granularity to Dimension):
```rust
#[derive(Debug, Clone)]
pub struct QueryRequest {
    pub dimensions: Vec<String>,
    pub metrics: Vec<String>,
}

pub fn expand(
    view_name: &str,
    def: &SemanticViewDefinition,
    req: &QueryRequest,
) -> Result<String, ExpandError> { ... }
```

Current SELECT item generation (expand.rs ~line 345):
```rust
let mut select_items: Vec<String> = Vec::new();
for dim in &resolved_dims {
    select_items.push(format!("    {} AS {}", dim.expr, quote_ident(&dim.name)));
}
```
This is the loop to modify for time dimension codegen.

From src/query/table_function.rs (current named_parameters):
```rust
fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>> {
    Some(vec![
        ("dimensions".to_string(), LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar))),
        ("metrics".to_string(), LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar))),
    ])
}
```

Current bind() parameter extraction pattern (lines 171-178):
```rust
let dimensions = match bind.get_named_parameter("dimensions") {
    Some(ref val) => unsafe { extract_list_strings(val) },
    None => vec![],
};
```

Existing FFI helper (table_function.rs lines 100-118):
```rust
unsafe fn value_raw_ptr(value: &Value) -> ffi::duckdb_value {
    let ptr_to_value = std::ptr::from_ref(value).cast::<ffi::duckdb_value>();
    std::ptr::read(ptr_to_value)
}
```

Existing list extraction pattern (table_function.rs lines 128-143):
```rust
pub(crate) unsafe fn extract_list_strings(value: &Value) -> Vec<String> {
    let value_ptr = value_raw_ptr(value);
    let size = ffi::duckdb_get_list_size(value_ptr);
    for i in 0..size {
        let child = ffi::duckdb_get_list_child(value_ptr, i);
        let cstr = ffi::duckdb_get_varchar(child);
        // ... free cstr, destroy child
    }
}
```

The MAP extraction follows the same pattern:
- `ffi::duckdb_get_map_size(value_ptr)` → number of entries
- `ffi::duckdb_get_map_key(value_ptr, i)` → key as duckdb_value
- `ffi::duckdb_get_map_value(value_ptr, i)` → value as duckdb_value
- `ffi::duckdb_get_varchar(key)` / `ffi::duckdb_get_varchar(val)` → *mut c_char
- Must call `ffi::duckdb_free(cstr.cast::<c_void>())` on each varchar result
- Must call `ffi::duckdb_destroy_value(&mut { key })` and `duckdb_destroy_value(&mut { val })`

LogicalTypeHandle for MAP — check duckdb-rs 1.4.4 API:
- Try `LogicalTypeHandle::map(&key_type, &val_type)` first
- If compiler error `no method named 'map'`: use `LogicalTypeHandle::from(LogicalTypeId::Map)` as fallback
- The MAP parameter is optional at runtime; if the user doesn't pass it, `bind.get_named_parameter("granularities")` returns None — handle gracefully as empty HashMap

QueryError (src/query/error.rs) — check what variants exist and add new ones or use existing patterns for bind-time errors.

Imports needed in expand.rs: `use std::collections::HashMap;`
Imports needed in table_function.rs: `use std::collections::HashMap;`
</interfaces>
</context>

<feature>
  <name>date_trunc codegen + granularities MAP parameter</name>
  <files>src/expand.rs, src/query/table_function.rs</files>
  <behavior>
    expand.rs unit tests (pure Rust, no DuckDB runtime needed):

    Time dimension with declared granularity:
    - Input: dimension `{name: "order_date", expr: "order_date", dim_type: Some("time"), granularity: Some("month")}`, no overrides
    - Expected SQL SELECT item: `    date_trunc('month', order_date)::DATE AS "order_date"`
    - GROUP BY uses ordinal position (existing behavior unchanged)

    Time dimension with query-time override:
    - Input: same dimension, granularity_overrides: {"order_date": "week"}
    - Expected SQL SELECT item: `    date_trunc('week', order_date)::DATE AS "order_date"`

    Regular dimension unchanged:
    - Input: dimension `{name: "region", expr: "region", dim_type: None}`, any overrides
    - Expected SQL SELECT item: `    region AS "region"` (unchanged from current behavior)

    Mixed: one time dim + one regular dim:
    - Both appear in SELECT with correct expressions

    table_function.rs bind() validation (unit tested via error message assertions):

    Override for non-time dimension:
    - granularities := {'region': 'month'} where 'region' is not a time dimension
    - bind() returns Err containing "is not a time dimension"

    Override with unsupported granularity value:
    - granularities := {'order_date': 'quarter'} where 'order_date' IS a time dimension
    - bind() returns Err containing "'quarter'" and "valid values: day, week, month, year"

    No granularities parameter:
    - bind() succeeds with empty overrides map (existing behavior unchanged)
  </behavior>
  <implementation>
    ### Changes to src/expand.rs

    1. Add `use std::collections::HashMap;` at top of file.

    2. Extend `QueryRequest`:
    ```rust
    #[derive(Debug, Clone)]
    pub struct QueryRequest {
        pub dimensions: Vec<String>,
        pub metrics: Vec<String>,
        /// Keyed by lowercased dimension name, value is the override granularity string.
        pub granularity_overrides: HashMap<String, String>,
    }
    ```

    3. Update all `QueryRequest { dimensions: ..., metrics: ... }` usages in the file to add `granularity_overrides: HashMap::new()` (there are several in tests — grep for `QueryRequest {`).

    4. Modify the SELECT item generation loop in `expand()`:
    Replace:
    ```rust
    for dim in &resolved_dims {
        select_items.push(format!("    {} AS {}", dim.expr, quote_ident(&dim.name)));
    }
    ```
    With:
    ```rust
    for dim in &resolved_dims {
        let expr = if dim.dim_type.as_deref() == Some("time") {
            let effective_gran = req.granularity_overrides
                .get(&dim.name.to_ascii_lowercase())
                .map(String::as_str)
                .or(dim.granularity.as_deref())
                .unwrap_or("day"); // fallback — validation in from_json prevents None reaching here
            format!("date_trunc('{}', {})::DATE", effective_gran, dim.expr)
        } else {
            dim.expr.clone()
        };
        select_items.push(format!("    {} AS {}", expr, quote_ident(&dim.name)));
    }
    ```

    ### Changes to src/query/table_function.rs

    1. Add `use std::collections::HashMap;` at top.

    2. Add `extract_map_strings` function alongside `extract_list_strings`:
    ```rust
    /// Extract key-value pairs from a DuckDB MAP(VARCHAR, VARCHAR) named parameter value.
    ///
    /// # Safety
    /// `value` must represent a MAP(VARCHAR, VARCHAR) value.
    unsafe fn extract_map_strings(value: &Value) -> HashMap<String, String> {
        let value_ptr = value_raw_ptr(value);
        let size = ffi::duckdb_get_map_size(value_ptr);
        let mut result = HashMap::with_capacity(size as usize);
        for i in 0..size {
            let mut key = ffi::duckdb_get_map_key(value_ptr, i);
            let mut val = ffi::duckdb_get_map_value(value_ptr, i);
            let k_cstr = ffi::duckdb_get_varchar(key);
            let v_cstr = ffi::duckdb_get_varchar(val);
            if !k_cstr.is_null() && !v_cstr.is_null() {
                let k = CStr::from_ptr(k_cstr).to_string_lossy().into_owned();
                let v = CStr::from_ptr(v_cstr).to_string_lossy().into_owned();
                ffi::duckdb_free(k_cstr.cast::<c_void>());
                ffi::duckdb_free(v_cstr.cast::<c_void>());
                result.insert(k.to_ascii_lowercase(), v);
            }
            ffi::duckdb_destroy_value(&mut key);
            ffi::duckdb_destroy_value(&mut val);
        }
        result
    }
    ```

    3. In `bind()`, after extracting `dimensions` and `metrics`, add:
    ```rust
    // Extract optional granularities MAP(VARCHAR, VARCHAR)
    let granularity_overrides: HashMap<String, String> = match bind.get_named_parameter("granularities") {
        Some(ref val) => unsafe { extract_map_strings(val) },
        None => HashMap::new(),
    };
    ```

    4. After loading the view definition (`def`) and BEFORE calling `expand()`, add validation of granularity overrides:
    ```rust
    // Validate granularity overrides
    const VALID_GRANULARITIES: &[&str] = &["day", "week", "month", "year"];
    for (override_dim, override_gran) in &granularity_overrides {
        // Find the dimension (case-insensitive)
        let dim_def = def.dimensions.iter()
            .find(|d| d.name.to_ascii_lowercase() == *override_dim);
        match dim_def {
            None => {
                // Dimension doesn't exist in this view — will be caught by expand() UnknownDimension
                // Only validate if dimension exists
            }
            Some(d) if d.dim_type.as_deref() != Some("time") => {
                return Err(format!(
                    "dimension '{}' is not a time dimension and cannot have a granularity override",
                    d.name
                ).into());
            }
            Some(_) => {
                if !VALID_GRANULARITIES.contains(&override_gran.as_str()) {
                    return Err(format!(
                        "granularity override '{}' for dimension '{}' is not supported; valid values: {}",
                        override_gran,
                        override_dim,
                        VALID_GRANULARITIES.join(", ")
                    ).into());
                }
            }
        }
    }
    ```

    5. Update `QueryRequest` construction in `bind()` to include overrides:
    ```rust
    let req = QueryRequest {
        dimensions: dimensions.clone(),
        metrics: metrics.clone(),
        granularity_overrides,
    };
    ```

    6. Register `granularities` in `named_parameters()`:
    ```rust
    fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>> {
        Some(vec![
            ("dimensions".to_string(), LogicalTypeHandle::list(...)),
            ("metrics".to_string(), LogicalTypeHandle::list(...)),
            // NEW — MAP(VARCHAR, VARCHAR) for per-query granularity override
            ("granularities".to_string(), {
                // Try the Rust API first; if unavailable, check duckdb-rs source for correct call
                // LogicalTypeHandle::map returns MAP type in recent duckdb-rs versions
                // If compile error: use unsafe { LogicalTypeHandle::from_raw(ffi::duckdb_create_map_type(...)) }
                LogicalTypeHandle::map(
                    &LogicalTypeHandle::from(LogicalTypeId::Varchar),
                    &LogicalTypeHandle::from(LogicalTypeId::Varchar),
                )
            }),
        ])
    }
    ```

    IMPORTANT: If `LogicalTypeHandle::map()` does not exist in duckdb-rs 1.4.4:
    - Check `duckdb-rs` source in Cargo registry: `~/.cargo/registry/src/*/duckdb-*/src/types/logical_type.rs`
    - Look for `map`, `create_map`, or similar method on `LogicalTypeHandle`
    - If no Rust wrapper exists, use the C API directly:
    ```rust
    unsafe {
        let key_type = ffi::duckdb_create_logical_type(ffi::DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR);
        let val_type = ffi::duckdb_create_logical_type(ffi::DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR);
        let map_type = ffi::duckdb_create_map_type(key_type, val_type);
        ffi::duckdb_destroy_logical_type(&mut { key_type });
        ffi::duckdb_destroy_logical_type(&mut { val_type });
        LogicalTypeHandle::from(map_type)
    }
    ```
    Document the approach chosen in the SUMMARY.
  </implementation>
</feature>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Extend QueryRequest and add date_trunc codegen to expand()</name>
  <files>src/expand.rs</files>
  <behavior>
    RED tests (add to expand_tests module, run — they MUST fail before implementation):

    - `test_time_dimension_uses_date_trunc`:
      Dimension with dim_type=Some("time"), granularity=Some("month"), no overrides.
      SQL SELECT item must be: `    date_trunc('month', order_date)::DATE AS "order_date"`

    - `test_time_dimension_with_granularity_override`:
      Same dimension (declared granularity "month"), granularity_overrides={"order_date": "week"}.
      SQL SELECT item must be: `    date_trunc('week', order_date)::DATE AS "order_date"`

    - `test_regular_dimension_unchanged_with_overrides`:
      Regular dimension (dim_type=None), granularity_overrides={"order_date": "week"} (key doesn't match).
      SQL SELECT item unchanged: `    region AS "region"`

    - `test_date_trunc_cast_to_date`:
      Time dimension codegen must contain `::DATE` (not just `date_trunc(...)`).

    GREEN: Extend QueryRequest struct + modify SELECT item loop. All tests pass.
    REFACTOR: Update all existing `QueryRequest { dimensions, metrics }` struct literals to add `granularity_overrides: HashMap::new()`.
  </behavior>
  <action>
    1. Add `use std::collections::HashMap;` to the imports at the top of src/expand.rs.

    2. Extend `QueryRequest`:
    ```rust
    #[derive(Debug, Clone)]
    pub struct QueryRequest {
        pub dimensions: Vec<String>,
        pub metrics: Vec<String>,
        pub granularity_overrides: HashMap<String, String>,
    }
    ```

    3. Modify the SELECT item generation loop inside `expand()` (around line 345) to wrap time dimensions with `date_trunc`:
    ```rust
    for dim in &resolved_dims {
        let expr = if dim.dim_type.as_deref() == Some("time") {
            let effective_gran = req.granularity_overrides
                .get(&dim.name.to_ascii_lowercase())
                .map(String::as_str)
                .or(dim.granularity.as_deref())
                .unwrap_or("day");
            format!("date_trunc('{}', {})::DATE", effective_gran, dim.expr)
        } else {
            dim.expr.clone()
        };
        select_items.push(format!("    {} AS {}", expr, quote_ident(&dim.name)));
    }
    ```

    4. Find all `QueryRequest {` struct literals in src/expand.rs (in tests) and add `granularity_overrides: HashMap::new()` to each. Use grep: `grep -n "QueryRequest {" src/expand.rs`

    5. Add new tests to the `expand_tests` module:
    ```rust
    #[test]
    fn test_time_dimension_uses_date_trunc() {
        use crate::model::Dimension;
        let def = SemanticViewDefinition {
            base_table: "orders".to_string(),
            dimensions: vec![Dimension {
                name: "order_date".to_string(),
                expr: "order_date".to_string(),
                source_table: None,
                dim_type: Some("time".to_string()),
                granularity: Some("month".to_string()),
            }],
            metrics: vec![],
            filters: vec![],
            joins: vec![],
        };
        let req = QueryRequest {
            dimensions: vec!["order_date".to_string()],
            metrics: vec![],
            granularity_overrides: HashMap::new(),
        };
        let sql = expand("orders", &def, &req).unwrap();
        assert!(sql.contains("date_trunc('month', order_date)::DATE AS \"order_date\""), "Got: {sql}");
    }

    #[test]
    fn test_time_dimension_with_granularity_override() {
        use crate::model::Dimension;
        let def = SemanticViewDefinition {
            base_table: "orders".to_string(),
            dimensions: vec![Dimension {
                name: "order_date".to_string(),
                expr: "order_date".to_string(),
                source_table: None,
                dim_type: Some("time".to_string()),
                granularity: Some("month".to_string()),
            }],
            metrics: vec![],
            filters: vec![],
            joins: vec![],
        };
        let req = QueryRequest {
            dimensions: vec!["order_date".to_string()],
            metrics: vec![],
            granularity_overrides: {
                let mut m = HashMap::new();
                m.insert("order_date".to_string(), "week".to_string());
                m
            },
        };
        let sql = expand("orders", &def, &req).unwrap();
        assert!(sql.contains("date_trunc('week', order_date)::DATE AS \"order_date\""), "Got: {sql}");
        assert!(!sql.contains("'month'"), "Should use override 'week' not declared 'month'");
    }

    #[test]
    fn test_regular_dimension_unaffected_by_time_codegen() {
        use crate::model::Dimension;
        let def = SemanticViewDefinition {
            base_table: "orders".to_string(),
            dimensions: vec![Dimension {
                name: "region".to_string(),
                expr: "region".to_string(),
                source_table: None,
                dim_type: None,
                granularity: None,
            }],
            metrics: vec![],
            filters: vec![],
            joins: vec![],
        };
        let req = QueryRequest {
            dimensions: vec!["region".to_string()],
            metrics: vec![],
            granularity_overrides: HashMap::new(),
        };
        let sql = expand("orders", &def, &req).unwrap();
        assert!(sql.contains("region AS \"region\""), "Got: {sql}");
        assert!(!sql.contains("date_trunc"), "Regular dim must not use date_trunc");
    }

    #[test]
    fn test_date_trunc_includes_date_cast() {
        use crate::model::Dimension;
        let def = SemanticViewDefinition {
            base_table: "orders".to_string(),
            dimensions: vec![Dimension {
                name: "d".to_string(),
                expr: "d".to_string(),
                source_table: None,
                dim_type: Some("time".to_string()),
                granularity: Some("day".to_string()),
            }],
            metrics: vec![],
            filters: vec![],
            joins: vec![],
        };
        let req = QueryRequest {
            dimensions: vec!["d".to_string()],
            metrics: vec![],
            granularity_overrides: HashMap::new(),
        };
        let sql = expand("orders", &def, &req).unwrap();
        assert!(sql.contains("::DATE"), "Must cast to DATE per TIME-04: Got: {sql}");
    }
    ```

    Run: `cargo test --lib expand` to verify all tests pass.
  </action>
  <verify>
    <automated>cargo test --lib 2>&1 | tail -20</automated>
  </verify>
  <done>
    `cargo test --lib` passes. src/expand.rs has `granularity_overrides` in `QueryRequest` and `date_trunc('gran', expr)::DATE` codegen in the SELECT item loop. All 4 new tests pass. All existing expand tests pass (QueryRequest literals updated with `granularity_overrides: HashMap::new()`).
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add granularities MAP named parameter to table_function.rs</name>
  <files>src/query/table_function.rs</files>
  <behavior>
    This task wires the MAP parameter into bind() and validates it.

    Unit-testable behaviors (via error assertions in bind()):
    - granularity override for non-time dimension → Err containing "is not a time dimension"
    - unsupported granularity value in override → Err containing "'quarter'" and "valid values: day, week, month, year"
    - No granularities parameter → bind() succeeds (existing behavior)

    Note: Full integration testing (actual DuckDB table function call with MAP parameter) requires the built extension. The unit tests here focus on the validation logic which is pure Rust.
  </behavior>
  <action>
    1. Add `use std::collections::HashMap;` to the imports in table_function.rs.

    2. Add `extract_map_strings` function after `extract_list_strings` (around line 143):
    ```rust
    /// Extract key-value pairs from a DuckDB MAP(VARCHAR, VARCHAR) named parameter value.
    ///
    /// Returns a HashMap with all keys lowercased for case-insensitive dimension name matching.
    ///
    /// # Safety
    /// `value` must represent a MAP(VARCHAR, VARCHAR) value.
    unsafe fn extract_map_strings(value: &Value) -> HashMap<String, String> {
        let value_ptr = value_raw_ptr(value);
        let size = ffi::duckdb_get_map_size(value_ptr);
        let mut result = HashMap::with_capacity(size as usize);
        for i in 0..size {
            let mut key = ffi::duckdb_get_map_key(value_ptr, i);
            let mut val = ffi::duckdb_get_map_value(value_ptr, i);
            let k_cstr = ffi::duckdb_get_varchar(key);
            let v_cstr = ffi::duckdb_get_varchar(val);
            if !k_cstr.is_null() && !v_cstr.is_null() {
                let k = CStr::from_ptr(k_cstr).to_string_lossy().into_owned();
                let v = CStr::from_ptr(v_cstr).to_string_lossy().into_owned();
                ffi::duckdb_free(k_cstr.cast::<c_void>());
                ffi::duckdb_free(v_cstr.cast::<c_void>());
                result.insert(k.to_ascii_lowercase(), v);
            }
            ffi::duckdb_destroy_value(&mut key);
            ffi::duckdb_destroy_value(&mut val);
        }
        result
    }
    ```

    3. In `bind()`, after the existing metrics extraction (around line 178), add:
    ```rust
    // Extract optional granularities MAP(VARCHAR, VARCHAR) for query-time override (TIME-03)
    let granularity_overrides: HashMap<String, String> = match bind.get_named_parameter("granularities") {
        Some(ref val) => unsafe { extract_map_strings(val) },
        None => HashMap::new(),
    };
    ```

    4. After loading `def` from JSON (around line 209), before calling `expand()`, add validation:
    ```rust
    // Validate granularity overrides against the view definition (TIME-03)
    const VALID_GRANULARITIES: &[&str] = &["day", "week", "month", "year"];
    for (override_dim_lower, override_gran) in &granularity_overrides {
        if let Some(dim_def) = def.dimensions.iter()
            .find(|d| d.name.to_ascii_lowercase() == *override_dim_lower)
        {
            if dim_def.dim_type.as_deref() != Some("time") {
                return Err(format!(
                    "dimension '{}' is not a time dimension and cannot have a granularity override",
                    dim_def.name
                ).into());
            }
            if !VALID_GRANULARITIES.contains(&override_gran.as_str()) {
                return Err(format!(
                    "granularity override '{}' for dimension '{}' is not supported; valid values: {}",
                    override_gran,
                    dim_def.name,
                    VALID_GRANULARITIES.join(", ")
                ).into());
            }
        }
        // If dimension doesn't exist in the view at all, expand() will catch it as UnknownDimension
    }
    ```

    5. Update `QueryRequest` construction (around line 210) to pass overrides:
    ```rust
    let req = QueryRequest {
        dimensions: dimensions.clone(),
        metrics: metrics.clone(),
        granularity_overrides,
    };
    ```

    6. Register `granularities` in `named_parameters()`:
    ```rust
    fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>> {
        Some(vec![
            ("dimensions".to_string(), LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar))),
            ("metrics".to_string(), LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar))),
            // granularities: query-time granularity override map (TIME-03)
            ("granularities".to_string(), {
                // First try the Rust wrapper; fall back to C API if unavailable in duckdb-rs 1.4.4
                // Check: grep -r "fn map" ~/.cargo/registry/src/*/duckdb-*/src/types/logical_type.rs
                LogicalTypeHandle::map(
                    &LogicalTypeHandle::from(LogicalTypeId::Varchar),
                    &LogicalTypeHandle::from(LogicalTypeId::Varchar),
                )
                // If compile error above, replace with:
                // unsafe {
                //     let k = ffi::duckdb_create_logical_type(ffi::DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR);
                //     let v = ffi::duckdb_create_logical_type(ffi::DUCKDB_TYPE_DUCKDB_TYPE_VARCHAR);
                //     let mt = ffi::duckdb_create_map_type(k, v);
                //     ffi::duckdb_destroy_logical_type(&mut { k });
                //     ffi::duckdb_destroy_logical_type(&mut { v });
                //     LogicalTypeHandle::from(mt)
                // }
            }),
        ])
    }
    ```

    7. Run `cargo test --lib` to catch compile errors. Fix any QueryError conversion issues (the `.into()` on String error — ensure `String: Into<Box<dyn std::error::Error>>` works, or use `.into::<Box<dyn std::error::Error>>()` or `e.to_string().into()` as needed).

    8. Run full `cargo test` to verify no regressions.

    After implementation: document which approach was used for LogicalTypeHandle::map in the SUMMARY.
  </action>
  <verify>
    <automated>cargo test 2>&1 | tail -30</automated>
  </verify>
  <done>
    `cargo test` passes with no errors and no warnings. `src/query/table_function.rs` contains `extract_map_strings`, `granularity_overrides` extraction in `bind()`, validation logic for overrides, and `granularities` in `named_parameters()`. The `QueryRequest` struct in `expand.rs` carries `granularity_overrides`. All existing tests pass.
  </done>
</task>

</tasks>

<verification>
Run `cargo test` (full suite). All tests pass.

Spot checks:
- `grep -n "granularity_overrides" src/expand.rs src/query/table_function.rs` shows field in QueryRequest, loop in expand(), extraction in bind()
- `grep -n "date_trunc" src/expand.rs` shows codegen pattern
- `grep -n "::DATE" src/expand.rs` shows cast (TIME-04)
- `grep -n "granularities" src/query/table_function.rs` shows named_parameters registration and bind extraction
- `grep -n "is not a time dimension" src/query/table_function.rs` shows error message
</verification>

<success_criteria>
- `cargo test` clean, all tests pass
- `expand()` generates `date_trunc('gran', expr)::DATE` for time dimensions (TIME-02, TIME-04)
- `expand()` uses granularity_overrides map with override taking precedence over declared granularity (TIME-03)
- `bind()` validates: non-time dimension override → error; unsupported granularity → error
- `granularities` MAP parameter registered in `named_parameters()` (accessible from DuckDB SQL)
- No existing tests broken
</success_criteria>

<output>
After completion, create `.planning/phases/09-time-dimensions/09-02-SUMMARY.md`
</output>
