---
phase: 46-wildcard-selection-queryable-facts
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/expand/types.rs
  - src/expand/sql_gen.rs
  - src/expand/mod.rs
  - src/expand/test_helpers.rs
  - src/query/table_function.rs
  - src/query/explain.rs
  - src/query/error.rs
  - test/sql/phase46_wildcard.test
  - test/sql/TEST_LIST
autonomous: true
requirements:
  - WILD-01
  - WILD-02
  - WILD-03
  - FACT-03

must_haves:
  truths:
    - "User can pass table_alias.* in dimensions parameter and it expands to all matching dimension names scoped to that alias"
    - "User can pass table_alias.* in metrics parameter and it expands to all matching PUBLIC metric names scoped to that alias"
    - "Wildcard expansion excludes PRIVATE metrics (dimensions always included since they have no access field)"
    - "Unknown table alias in wildcard produces a clear error with the bad alias name"
    - "Duplicate names from wildcard + explicit combined are deduplicated"
    - "Bare * (unqualified wildcard) is rejected with an error"
    - "Combining facts and metrics in the same query produces ExpandError::FactsMetricsMutualExclusion"
  artifacts:
    - path: "src/expand/types.rs"
      provides: "QueryRequest.facts field, FactsMetricsMutualExclusion + UnknownFact + DuplicateFact + FactPathViolation error variants"
      contains: "pub facts: Vec<String>"
    - path: "src/expand/sql_gen.rs"
      provides: "Mutual exclusion check and EmptyRequest update in expand()"
      contains: "FactsMetricsMutualExclusion"
    - path: "src/query/table_function.rs"
      provides: "facts named parameter, expand_wildcards function, wildcard expansion in bind()"
      contains: "expand_wildcards"
    - path: "src/query/explain.rs"
      provides: "facts named parameter on ExplainSemanticViewVTab"
      contains: "facts"
    - path: "test/sql/phase46_wildcard.test"
      provides: "Integration tests for wildcard expansion via sqllogictest"
  key_links:
    - from: "src/query/table_function.rs"
      to: "src/expand/types.rs"
      via: "QueryRequest { dimensions, metrics, facts }"
      pattern: "QueryRequest"
    - from: "src/query/table_function.rs"
      to: "src/expand/sql_gen.rs"
      via: "expand() call with facts-aware QueryRequest"
      pattern: "expand\\("
    - from: "src/query/explain.rs"
      to: "src/expand/types.rs"
      via: "QueryRequest { dimensions, metrics, facts }"
      pattern: "QueryRequest"
---

<objective>
Add wildcard selection (table_alias.*) for dimensions and metrics parameters, the `facts` named parameter to both semantic_view() and explain_semantic_view() table functions, and facts/metrics mutual exclusion enforcement in expand().

Purpose: Enable users to select all dimensions/metrics scoped to a table alias with a single wildcard pattern, respecting PRIVATE visibility. Also establishes the interface infrastructure (QueryRequest.facts, named parameter, error variants) needed by Plan 02's fact query expansion.

Output: Working wildcard expansion in both VTabs, facts/metrics mutual exclusion error, new error variants, integration tests for wildcards.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/46-wildcard-selection-queryable-facts/46-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/expand/types.rs:
```rust
#[derive(Debug, Clone)]
pub struct QueryRequest {
    pub dimensions: Vec<String>,
    pub metrics: Vec<String>,
}

pub enum ExpandError {
    EmptyRequest { 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 },
    FanTrap { ... },
    AmbiguousPath { ... },
    PrivateMetric { view_name: String, name: String },
    PrivateFact { view_name: String, name: String },
}
```

From src/model.rs:
```rust
pub struct Dimension { pub name: String, pub expr: String, pub source_table: Option<String>, ... }
// NOTE: Dimension has NO `access` field — always PUBLIC
pub struct Metric { pub name: String, pub expr: String, pub source_table: Option<String>, pub access: AccessModifier, ... }
pub struct Fact { pub name: String, pub expr: String, pub source_table: Option<String>, pub access: AccessModifier, ... }
pub struct TableRef { pub alias: String, pub table: String, ... }
pub enum AccessModifier { Public, Private }
```

From src/query/table_function.rs:
```rust
pub(crate) unsafe fn extract_list_strings(value: &Value) -> Vec<String>;
fn named_parameters() -> Option<Vec<(String, LogicalTypeHandle)>>; // currently: dimensions, metrics
```

From src/query/error.rs:
```rust
pub enum QueryError {
    ViewNotFound { ... },
    EmptyRequest { view_name: String },
    ExpandFailed { source: ExpandError },
    SqlExecution { ... },
    TypeMismatch { ... },
}
```

From src/expand/mod.rs:
```rust
pub use sql_gen::expand;
pub use types::{ExpandError, QueryRequest};
```

From src/expand/test_helpers.rs:
```rust
pub(super) trait TestFixtureExt {
    fn with_dimension(self, name: &str, expr: &str, source_table: Option<&str>) -> Self;
    fn with_metric(self, name: &str, expr: &str, source_table: Option<&str>) -> Self;
    fn with_fact(self, name: &str, expr: &str, source_table: &str) -> Self;
    fn with_table(self, alias: &str, table: &str, pk_columns: &[&str]) -> Self;
    // ...
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Extend QueryRequest, add error variants, update expand() for facts awareness</name>
  <files>src/expand/types.rs, src/expand/sql_gen.rs, src/expand/test_helpers.rs</files>
  <read_first>
    - src/expand/types.rs (current QueryRequest and ExpandError definitions)
    - src/expand/sql_gen.rs (current expand() function — full file)
    - src/expand/test_helpers.rs (TestFixtureExt trait for builder methods)
    - src/model.rs (Fact, AccessModifier — lines 96-123)
  </read_first>
  <behavior>
    - Test: expand() with non-empty facts + non-empty metrics returns Err(ExpandError::FactsMetricsMutualExclusion)
    - Test: expand() with empty dims + empty metrics + empty facts returns Err(ExpandError::EmptyRequest)
    - Test: expand() with empty dims + empty metrics + non-empty facts does NOT return EmptyRequest (succeeds or returns other errors depending on fact validity — the EmptyRequest guard is what we test here)
    - Test: FactsMetricsMutualExclusion Display format contains "cannot combine facts and metrics"
    - Test: UnknownFact Display format contains "unknown fact" and lists available facts
    - Test: DuplicateFact Display format contains "duplicate fact"
    - Test: FactPathViolation Display format contains "fact query references" and the two table names
  </behavior>
  <action>
**src/expand/types.rs:**

1. Add `pub facts: Vec<String>` field to `QueryRequest` struct. Update doc comment to mention facts mode.

2. Add four new `ExpandError` variants:
   - `FactsMetricsMutualExclusion { view_name: String }` — Display: `"semantic view '{view_name}': cannot combine facts and metrics in the same query. Use facts := [...] OR metrics := [...], not both."`
   - `UnknownFact { view_name: String, name: String, available: Vec<String>, suggestion: Option<String> }` — Display same pattern as UnknownDimension/UnknownMetric
   - `DuplicateFact { view_name: String, name: String }` — Display same pattern as DuplicateDimension
   - `FactPathViolation { view_name: String, table_a: String, table_b: String }` — Display: `"semantic view '{view_name}': fact query references objects from incompatible table paths -- tables '{table_a}' and '{table_b}' are not on the same root-to-leaf path in the relationship tree"`

**src/expand/sql_gen.rs:**

3. At the start of `expand()` (before the existing EmptyRequest check on line 30), add the mutual exclusion check:
   ```rust
   if !req.facts.is_empty() && !req.metrics.is_empty() {
       return Err(ExpandError::FactsMetricsMutualExclusion {
           view_name: view_name.to_string(),
       });
   }
   ```

4. Update the EmptyRequest check (currently line 30) from:
   ```rust
   if req.dimensions.is_empty() && req.metrics.is_empty() {
   ```
   to:
   ```rust
   if req.dimensions.is_empty() && req.metrics.is_empty() && req.facts.is_empty() {
   ```

5. Update ALL existing `QueryRequest` construction sites in `src/expand/sql_gen.rs` tests to include `facts: vec![]`. There are many test functions in `mod tests` at the bottom of the file — every `QueryRequest { dimensions: ..., metrics: ... }` must become `QueryRequest { dimensions: ..., metrics: ..., facts: vec![] }`.

**src/expand/test_helpers.rs:**

6. Add a `with_private_metric` builder method to `TestFixtureExt`:
   ```rust
   fn with_private_metric(self, name: &str, expr: &str, source_table: Option<&str>) -> Self;
   ```
   Implementation: same as `with_metric` but sets `access: AccessModifier::Private`.

7. Add a `with_private_fact` builder method:
   ```rust
   fn with_private_fact(self, name: &str, expr: &str, source_table: &str) -> Self;
   ```
   Implementation: same as `with_fact` but sets `access: AccessModifier::Private`.

**New unit tests in src/expand/sql_gen.rs (add to `mod tests`):**

8. Add test `test_facts_metrics_mutual_exclusion`: Create a minimal def with a fact and metric, construct `QueryRequest { dimensions: vec![], metrics: vec!["m".into()], facts: vec!["f".into()] }`, assert expand returns `ExpandError::FactsMetricsMutualExclusion`.

9. Add test `test_empty_request_with_facts_is_not_empty`: Create a def with a fact, construct `QueryRequest { dimensions: vec![], metrics: vec![], facts: vec!["f".into()] }`. The expand should NOT return `EmptyRequest`. (It may return another error since fact expansion is not yet implemented — the test verifies the guard condition only. Use `assert!(!matches!(result, Err(ExpandError::EmptyRequest { .. })))`.)
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- facts_metrics_mutual_exclusion empty_request_with_facts 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - src/expand/types.rs contains `pub facts: Vec<String>` inside QueryRequest struct
    - src/expand/types.rs contains variant `FactsMetricsMutualExclusion`
    - src/expand/types.rs contains variant `UnknownFact`
    - src/expand/types.rs contains variant `DuplicateFact`
    - src/expand/types.rs contains variant `FactPathViolation`
    - src/expand/types.rs Display for FactsMetricsMutualExclusion contains "cannot combine facts and metrics"
    - src/expand/sql_gen.rs expand() checks `!req.facts.is_empty() && !req.metrics.is_empty()` before EmptyRequest
    - src/expand/sql_gen.rs EmptyRequest check includes `req.facts.is_empty()`
    - All existing tests in sql_gen.rs pass (QueryRequest construction updated with `facts: vec![]`)
    - cargo test facts_metrics_mutual_exclusion passes
    - cargo test empty_request_with_facts passes
  </acceptance_criteria>
  <done>QueryRequest has facts field, four new ExpandError variants with Display impls, expand() enforces mutual exclusion and updated EmptyRequest, all existing tests pass with updated QueryRequest construction</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Wildcard expansion function and VTab parameter registration</name>
  <files>src/query/table_function.rs, src/query/explain.rs, src/query/error.rs</files>
  <read_first>
    - src/query/table_function.rs (full file — bind(), named_parameters(), extract_list_strings())
    - src/query/explain.rs (full file — bind(), named_parameters())
    - src/query/error.rs (QueryError enum and Display)
    - src/expand/types.rs (QueryRequest after Task 1 changes)
    - src/model.rs (Dimension, Metric, Fact, AccessModifier, TableRef — lines 1-123)
  </read_first>
  <behavior>
    - Test: expand_wildcards for dimensions with valid alias expands to all dims with matching source_table
    - Test: expand_wildcards for metrics with valid alias expands to all PUBLIC metrics with matching source_table, excludes PRIVATE
    - Test: expand_wildcards for facts with valid alias expands to all PUBLIC facts with matching source_table, excludes PRIVATE
    - Test: expand_wildcards with unknown alias returns error containing the bad alias
    - Test: expand_wildcards with bare `*` (no alias prefix) returns error about unqualified wildcard
    - Test: expand_wildcards deduplicates when explicit name overlaps with wildcard result
    - Test: expand_wildcards with no wildcards in list returns list unchanged
  </behavior>
  <action>
**src/query/table_function.rs:**

1. Add a `WildcardItemType` enum and `expand_wildcards` function near the top of the file (after the FFI helpers section, before the VTab implementation). This is a pre-processing step that runs BEFORE expand():

```rust
/// Item types for wildcard expansion.
enum WildcardItemType {
    Dimension,
    Metric,
    Fact,
}

/// Expand `table_alias.*` wildcards in an item list to concrete names.
///
/// Only qualified wildcards (`alias.*`) are supported — bare `*` is rejected
/// (matches Snowflake behavior). PRIVATE metrics and facts are excluded from
/// expansion. Dimensions have no access modifier and are always included.
/// Duplicates (from wildcard + explicit overlap) are removed.
fn expand_wildcards(
    items: &[String],
    def: &SemanticViewDefinition,
    item_type: WildcardItemType,
) -> Result<Vec<String>, String> {
    // Quick path: no wildcards at all
    if !items.iter().any(|s| s.contains('*')) {
        return Ok(items.to_vec());
    }

    let mut result = Vec::new();
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();

    for item in items {
        if item == "*" {
            return Err(
                "unqualified wildcard '*' is not supported. Use table_alias.* to select all items for a specific table."
                    .to_string(),
            );
        }
        if item.ends_with(".*") {
            let alias = &item[..item.len() - 2];
            // Validate alias exists in tables
            let alias_exists = def
                .tables
                .iter()
                .any(|t| t.alias.eq_ignore_ascii_case(alias));
            if !alias_exists {
                return Err(format!(
                    "unknown table alias '{alias}' in wildcard '{item}'. Available aliases: [{}]",
                    def.tables.iter().map(|t| t.alias.as_str()).collect::<Vec<_>>().join(", ")
                ));
            }
            match item_type {
                WildcardItemType::Dimension => {
                    for dim in &def.dimensions {
                        if dim
                            .source_table
                            .as_deref()
                            .is_some_and(|st| st.eq_ignore_ascii_case(alias))
                        {
                            let key = dim.name.to_ascii_lowercase();
                            if seen.insert(key) {
                                result.push(dim.name.clone());
                            }
                        }
                    }
                }
                WildcardItemType::Metric => {
                    for met in &def.metrics {
                        if met
                            .source_table
                            .as_deref()
                            .is_some_and(|st| st.eq_ignore_ascii_case(alias))
                            && met.access != AccessModifier::Private
                        {
                            let key = met.name.to_ascii_lowercase();
                            if seen.insert(key) {
                                result.push(met.name.clone());
                            }
                        }
                    }
                }
                WildcardItemType::Fact => {
                    for fact in &def.facts {
                        if fact
                            .source_table
                            .as_deref()
                            .is_some_and(|st| st.eq_ignore_ascii_case(alias))
                            && fact.access != AccessModifier::Private
                        {
                            let key = fact.name.to_ascii_lowercase();
                            if seen.insert(key) {
                                result.push(fact.name.clone());
                            }
                        }
                    }
                }
            }
        } else {
            let key = item.to_ascii_lowercase();
            if seen.insert(key) {
                result.push(item.clone());
            }
        }
    }
    Ok(result)
}
```

2. Add `use crate::model::AccessModifier;` to the imports at the top (it is needed for the wildcard function).

3. In `SemanticViewVTab::bind()`, add extraction of `facts` named parameter (after dimensions and metrics extraction, around line 413):
```rust
let facts = match bind.get_named_parameter("facts") {
    Some(ref val) => unsafe { extract_list_strings(val) },
    None => vec![],
};
```

4. Update the empty request check (around line 417) from:
```rust
if dimensions.is_empty() && metrics.is_empty() {
```
to:
```rust
if dimensions.is_empty() && metrics.is_empty() && facts.is_empty() {
```

5. After the definition is parsed (after `let def = ...` around line 445), add wildcard expansion BEFORE constructing QueryRequest:
```rust
let dimensions = expand_wildcards(&dimensions, &def, WildcardItemType::Dimension)
    .map_err(|e| -> Box<dyn std::error::Error> {
        Box::new(QueryError::ExpandFailed {
            source: crate::expand::ExpandError::EmptyRequest {
                view_name: format!("{view_name}: {e}"),
            },
        })
    })?;
let metrics = expand_wildcards(&metrics, &def, WildcardItemType::Metric)
    .map_err(|e| -> Box<dyn std::error::Error> {
        Box::new(QueryError::ExpandFailed {
            source: crate::expand::ExpandError::EmptyRequest {
                view_name: format!("{view_name}: {e}"),
            },
        })
    })?;
let facts = expand_wildcards(&facts, &def, WildcardItemType::Fact)
    .map_err(|e| -> Box<dyn std::error::Error> {
        Box::new(QueryError::ExpandFailed {
            source: crate::expand::ExpandError::EmptyRequest {
                view_name: format!("{view_name}: {e}"),
            },
        })
    })?;
```
Note: Remove the `mut` on the original `let dimensions` and `let metrics` declarations since we now shadow them. The `let dimensions` and `let metrics` bindings from `extract_list_strings` should NOT be `mut`.

6. Update QueryRequest construction (around line 447) to include facts:
```rust
let req = QueryRequest {
    dimensions: dimensions.clone(),
    metrics: metrics.clone(),
    facts: facts.clone(),
};
```

7. In the type inference section (around line 463-524), update the column_names/column_type_ids derivation to handle fact queries. For now (Plan 02 will implement the full fact expansion), add a conditional branch for facts in the type resolution:

After the existing primary path and fallback path block for metrics, add handling for fact column names and types. In the primary path block, after the metrics loop, add:
```rust
for fact_name in &facts {
    let canonical = def
        .facts
        .iter()
        .find(|f| f.name.eq_ignore_ascii_case(fact_name))
        .map_or_else(|| fact_name.clone(), |f| f.name.clone());
    // Facts use per-fact output_type (stored as SQL type string), not column_types_inferred
    // Type resolution for facts is handled via LIMIT 0 fallback since output_type is a string
    names.push(canonical);
    type_ids.push(0u32); // INVALID = VARCHAR fallback; Plan 02 may refine
}
```
And in the full fallback path, add:
```rust
for fact_name in &facts {
    let canonical = def
        .facts
        .iter()
        .find(|f| f.name.eq_ignore_ascii_case(fact_name))
        .map_or_else(|| fact_name.clone(), |f| f.name.clone());
    names.push(canonical);
}
```

8. Add `"facts"` to `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)),
        ),
        (
            "facts".to_string(),
            LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar)),
        ),
    ])
}
```

**src/query/explain.rs:**

9. Add extraction of `facts` parameter in `ExplainSemanticViewVTab::bind()` (after metrics extraction, around line 129):
```rust
let facts = match bind.get_named_parameter("facts") {
    Some(ref val) => unsafe { extract_list_strings(val) },
    None => vec![],
};
```

10. Update the empty request check (around line 132) from:
```rust
if dimensions.is_empty() && metrics.is_empty() {
```
to:
```rust
if dimensions.is_empty() && metrics.is_empty() && facts.is_empty() {
```

11. After the definition is parsed, add wildcard expansion for explain (same pattern as semantic_view, using `expand_wildcards` imported from table_function):
Since `expand_wildcards` is in `table_function.rs`, either make it `pub(super)` or move it to a shared location. The simplest approach: change `fn expand_wildcards` to `pub(super) fn expand_wildcards` and `pub(super) enum WildcardItemType` in `table_function.rs`. Then import in `explain.rs`:
```rust
use super::table_function::{expand_wildcards, WildcardItemType};
```

Add wildcard expansion after `let def = ...`:
```rust
let dimensions = expand_wildcards(&dimensions, &def, WildcardItemType::Dimension)
    .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
let metrics = expand_wildcards(&metrics, &def, WildcardItemType::Metric)
    .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
let facts = expand_wildcards(&facts, &def, WildcardItemType::Fact)
    .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
```

12. Update QueryRequest construction (around line 159):
```rust
let req = QueryRequest {
    dimensions: dimensions.clone(),
    metrics: metrics.clone(),
    facts: facts.clone(),
};
```

13. Update the metadata header to include facts (around lines 170-173):
```rust
lines.push(format!("-- Semantic View: {view_name}"));
lines.push(format!("-- Dimensions: {}", dimensions.join(", ")));
lines.push(format!("-- Metrics: {}", metrics.join(", ")));
if !facts.is_empty() {
    lines.push(format!("-- Facts: {}", facts.join(", ")));
}
```

14. Add `"facts"` to `ExplainSemanticViewVTab::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)),
        ),
        (
            "facts".to_string(),
            LogicalTypeHandle::list(&LogicalTypeHandle::from(LogicalTypeId::Varchar)),
        ),
    ])
}
```

**src/query/error.rs:**

15. Update `QueryError::EmptyRequest` Display to mention facts:
```rust
Self::EmptyRequest { view_name } => {
    write!(
        f,
        "semantic view '{view_name}': specify at least dimensions := [...], metrics := [...], or facts := [...]."
    )?;
    write!(
        f,
        " Run DESCRIBE SEMANTIC VIEW {view_name} to see available dimensions, metrics, and facts."
    )
}
```

**Unit tests (add at bottom of table_function.rs or in a #[cfg(test)] mod):**

Since `expand_wildcards` is internal, add unit tests in table_function.rs inside a `#[cfg(test)] mod tests` block. Create test fixtures using the model types directly:

16. Test `test_wildcard_expands_dimensions` — build a SemanticViewDefinition with tables and dims with source_table set, call expand_wildcards with ["o.*"], assert returns ["dim1", "dim2"].
17. Test `test_wildcard_expands_metrics_excludes_private` — build def with public and private metrics, call expand_wildcards for metrics, assert private metric excluded.
18. Test `test_wildcard_unknown_alias_error` — call with ["nonexistent.*"], assert returns Err containing "unknown table alias".
19. Test `test_wildcard_bare_star_error` — call with ["*"], assert returns Err containing "unqualified wildcard".
20. Test `test_wildcard_deduplication` — call with ["dim1", "o.*"] where dim1 is in o.*, assert dim1 appears only once.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- wildcard 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
    - src/query/table_function.rs contains `pub(super) fn expand_wildcards(`
    - src/query/table_function.rs contains `pub(super) enum WildcardItemType`
    - src/query/table_function.rs named_parameters() returns 3 entries including "facts"
    - src/query/table_function.rs bind() extracts facts parameter via get_named_parameter("facts")
    - src/query/table_function.rs bind() calls expand_wildcards for dimensions, metrics, and facts
    - src/query/table_function.rs EmptyRequest check includes `facts.is_empty()`
    - src/query/table_function.rs QueryRequest construction includes `facts:`
    - src/query/explain.rs named_parameters() returns 3 entries including "facts"
    - src/query/explain.rs bind() extracts facts parameter
    - src/query/explain.rs bind() calls expand_wildcards for dimensions, metrics, and facts
    - src/query/explain.rs QueryRequest construction includes `facts:`
    - src/query/error.rs EmptyRequest Display contains "facts := [...]"
    - cargo test wildcard passes with at least 5 tests
    - cargo test (full) passes
  </acceptance_criteria>
  <done>expand_wildcards function handles dimension/metric/fact wildcards with PRIVATE filtering, deduplication, and alias validation. Both VTabs register facts parameter and perform wildcard expansion. Unit tests cover positive, negative, and edge cases.</done>
</task>

<task type="auto">
  <name>Task 3: Wildcard integration tests via sqllogictest</name>
  <files>test/sql/phase46_wildcard.test, test/sql/TEST_LIST</files>
  <read_first>
    - test/sql/phase29_facts.test (existing facts test patterns — FACTS DDL syntax)
    - test/sql/phase43_metadata.test (PRIVATE modifier patterns in DDL)
    - test/sql/TEST_LIST (current test manifest)
  </read_first>
  <action>
**test/sql/phase46_wildcard.test:**

Create an integration test file with the following test cases. Follow the existing sqllogictest patterns in the project (require-env, LOAD, CREATE TABLE, CREATE SEMANTIC VIEW, then queries).

```
# Phase 46: Wildcard Selection (table_alias.*)
# Tests WILD-01 (dimension wildcard), WILD-02 (metric wildcard), WILD-03 (PRIVATE exclusion)

require-env DUCKDB_EXTENSION_PATH

statement ok
LOAD '${DUCKDB_EXTENSION_PATH}';

# --- Setup: orders + line_items tables ---

statement ok
CREATE TABLE orders (id INTEGER PRIMARY KEY, region VARCHAR, status VARCHAR, order_date DATE);

statement ok
INSERT INTO orders VALUES (1, 'US', 'shipped', '2024-01-01'), (2, 'EU', 'pending', '2024-01-02'), (3, 'US', 'shipped', '2024-01-03');

statement ok
CREATE TABLE line_items (id INTEGER PRIMARY KEY, order_id INTEGER, product VARCHAR, quantity INTEGER, price DECIMAL(10,2), discount DECIMAL(5,2));

statement ok
INSERT INTO line_items VALUES (1, 1, 'Widget', 10, 100.00, 0.10), (2, 1, 'Gadget', 5, 50.00, 0.00), (3, 2, 'Widget', 3, 100.00, 0.05), (4, 3, 'Gadget', 7, 50.00, 0.15);

# --- Setup: semantic view with multi-table, PRIVATE metrics ---

statement ok
CREATE SEMANTIC VIEW analytics AS
TABLES
    o AS orders PRIMARY KEY (id),
    li AS line_items PRIMARY KEY (id)
RELATIONSHIPS
    li REFERENCES o (order_id -> id)
DIMENSIONS
    o.region AS o.region,
    o.status AS o.status,
    o.order_date AS o.order_date,
    li.product AS li.product
METRICS
    o.order_count AS count(*),
    li.total_quantity AS sum(li.quantity),
    PRIVATE li.raw_revenue AS sum(li.price * li.quantity),
    li.net_revenue AS sum(li.price * li.quantity * (1 - li.discount))
;

# --- WILD-01: Dimension wildcard ---

# o.* should expand to region, status, order_date (all dims on table o)
query III
FROM semantic_view('analytics', dimensions := ['o.*'], metrics := ['o.order_count'])
ORDER BY 1;
----
US	shipped	2024-01-01	1
US	shipped	2024-01-03	1
EU	pending	2024-01-02	1

# li.* should expand to product only
query II
FROM semantic_view('analytics', dimensions := ['li.*'], metrics := ['li.total_quantity'])
ORDER BY 1;
----
Gadget	12
Widget	13

# --- WILD-02: Metric wildcard ---

# li.* in metrics should expand to total_quantity and net_revenue (raw_revenue is PRIVATE)
query III
FROM semantic_view('analytics', dimensions := ['o.region'], metrics := ['li.*'])
ORDER BY 1;
----
EU	3	285.00
US	22	1175.00

# --- WILD-03: PRIVATE exclusion ---

# Verify PRIVATE metric raw_revenue is NOT included in li.* wildcard
# The query above returns 3 columns (region + 2 metrics), not 4
# This is validated by the column count in the query above

# --- Mixed wildcard + explicit ---

# Explicit name + wildcard that includes same name: deduplication
query II
FROM semantic_view('analytics', dimensions := ['o.region', 'o.*'], metrics := ['o.order_count'])
ORDER BY 1, 2;
----
EU	pending	1
US	shipped	2

# --- Error cases ---

# Unknown alias
statement error
FROM semantic_view('analytics', dimensions := ['nonexistent.*'], metrics := ['o.order_count']);
----
unknown table alias 'nonexistent'

# Bare wildcard (unqualified *)
statement error
FROM semantic_view('analytics', dimensions := ['*'], metrics := ['o.order_count']);
----
unqualified wildcard

# --- explain_semantic_view also supports wildcards ---

# Verify explain works with wildcards (just check it doesn't error)
query I
SELECT count(*) FROM explain_semantic_view('analytics', dimensions := ['o.*'], metrics := ['o.order_count'])
WHERE explain_output LIKE '%region%';
----
1

# --- facts/metrics mutual exclusion ---

statement error
FROM semantic_view('analytics', dimensions := ['o.region'], metrics := ['o.order_count'], facts := ['f1']);
----
cannot combine facts and metrics
```

NOTE: The query result row counts and column types may need adjustment based on actual output. The executor should run the tests and fix assertion values. The key structure and error patterns are what matter.

**test/sql/TEST_LIST:**

Add the new test file at the end:
```
test/sql/phase46_wildcard.test
```
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && just build && just test-sql 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - test/sql/phase46_wildcard.test exists and is non-empty
    - test/sql/TEST_LIST contains "test/sql/phase46_wildcard.test"
    - just test-sql passes with phase46_wildcard.test included
    - Test covers WILD-01 (dimension wildcard), WILD-02 (metric wildcard), WILD-03 (PRIVATE exclusion)
    - Test covers error cases: unknown alias, bare wildcard
    - Test covers explain_semantic_view with wildcards
    - Test covers facts/metrics mutual exclusion
  </acceptance_criteria>
  <done>sqllogictest integration tests for wildcard expansion pass, covering all three WILD requirements plus error cases and explain support</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| User query params -> expand_wildcards | Untrusted alias string in wildcard pattern |
| Wildcard result -> expand() | Expanded names must not include PRIVATE items |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-46-01 | Tampering | expand_wildcards alias | mitigate | Alias validated against def.tables allowlist; never interpolated into SQL raw. Invalid alias returns error. |
| T-46-02 | Information Disclosure | expand_wildcards PRIVATE | mitigate | Metrics/facts filtered by `access != AccessModifier::Private` before returning names. Dimensions have no access field (always PUBLIC). |
</threat_model>

<verification>
1. `cargo test` passes — all unit tests including new wildcard and mutual exclusion tests
2. `just build && just test-sql` passes — sqllogictest integration tests including phase46_wildcard.test
3. Wildcard expansion correctly filters PRIVATE items
4. Unknown alias and bare wildcard produce clear error messages
5. facts/metrics mutual exclusion enforced early in expand()
</verification>

<success_criteria>
- table_alias.* in dimensions expands to all dims scoped to that alias
- table_alias.* in metrics expands to all PUBLIC metrics scoped to that alias
- PRIVATE metrics excluded from wildcard expansion
- Bare * rejected with clear error
- Unknown alias rejected with clear error
- facts named parameter registered on both VTabs
- facts/metrics mutual exclusion produces ExpandError::FactsMetricsMutualExclusion
- All existing tests pass with updated QueryRequest (facts: vec![])
</success_criteria>

<output>
After completion, create `.planning/phases/46-wildcard-selection-queryable-facts/46-01-SUMMARY.md`
</output>
