---
phase: 46-wildcard-selection-queryable-facts
plan: 02
type: execute
wave: 2
depends_on:
  - 46-01
files_modified:
  - src/expand/sql_gen.rs
  - src/expand/fan_trap.rs
  - src/expand/mod.rs
  - src/expand/types.rs
  - src/query/table_function.rs
  - test/sql/phase46_fact_query.test
  - test/sql/TEST_LIST
autonomous: true
requirements:
  - FACT-01
  - FACT-02
  - FACT-04

must_haves:
  truths:
    - "User can query semantic_view('v', facts := ['f1', 'f2'], dimensions := ['d1']) and receive row-level results"
    - "Fact queries produce SELECT without GROUP BY (unaggregated, row-level data)"
    - "Fact queries inline fact expressions using toposort_facts + inline_facts (fact-to-fact DAG resolution)"
    - "Fact queries with dimensions referencing incompatible table paths produce FactPathViolation error"
    - "Fact queries validate unknown fact names with did-you-mean suggestions"
    - "Fact queries reject PRIVATE facts with PrivateFact error"
    - "Fact queries reject duplicate fact names with DuplicateFact error"
    - "explain_semantic_view works with facts parameter and shows expanded SQL"
  artifacts:
    - path: "src/expand/sql_gen.rs"
      provides: "expand_facts() function for unaggregated fact SQL generation"
      contains: "fn expand_facts"
    - path: "src/expand/fan_trap.rs"
      provides: "validate_fact_table_path() for FACT-04 path validation"
      contains: "fn validate_fact_table_path"
    - path: "test/sql/phase46_fact_query.test"
      provides: "Integration tests for fact queries via sqllogictest"
  key_links:
    - from: "src/expand/sql_gen.rs"
      to: "src/expand/fan_trap.rs"
      via: "validate_fact_table_path() call from expand_facts()"
      pattern: "validate_fact_table_path"
    - from: "src/expand/sql_gen.rs"
      to: "src/expand/facts.rs"
      via: "toposort_facts() and inline_facts() for fact expression resolution"
      pattern: "toposort_facts|inline_facts"
    - from: "src/query/table_function.rs"
      to: "src/expand/sql_gen.rs"
      via: "expand() dispatches to expand_facts() when req.facts is non-empty"
      pattern: "expand\\("
---

<objective>
Implement the fact query expansion path in the semantic view pipeline, producing unaggregated row-level SQL from fact queries, with table path validation reusing the relationship graph infrastructure.

Purpose: Enable users to query row-level facts (pre-aggregation expressions) alongside dimensions, receiving individual rows instead of aggregated results. This completes the Snowflake-parity gap for the FACTS query interface.

Output: Working fact queries via semantic_view() and explain_semantic_view(), path validation for cross-table fact queries, comprehensive integration tests.
</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
@.planning/phases/46-wildcard-selection-queryable-facts/46-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts from Plan 01 output and existing codebase. -->

From src/expand/types.rs (after Plan 01):
```rust
pub struct QueryRequest {
    pub dimensions: Vec<String>,
    pub metrics: Vec<String>,
    pub facts: Vec<String>,
}

pub enum ExpandError {
    // ... existing variants ...
    FactsMetricsMutualExclusion { view_name: String },
    UnknownFact { view_name: String, name: String, available: Vec<String>, suggestion: Option<String> },
    DuplicateFact { view_name: String, name: String },
    FactPathViolation { view_name: String, table_a: String, table_b: String },
    PrivateFact { view_name: String, name: String },
}
```

From src/expand/fan_trap.rs:
```rust
pub(crate) fn ancestors_to_root(node: &str, parent_map: &HashMap<String, String>) -> Vec<String>;
// Returns chain [node, parent, grandparent, ..., root]
```

From src/expand/facts.rs:
```rust
pub(super) fn toposort_facts(facts: &[Fact]) -> Result<Vec<usize>, String>;
pub(super) fn inline_facts(expr: &str, facts: &[Fact], topo_order: &[usize]) -> String;
// inline_facts replaces fact references in expr with their resolved expressions (parenthesized)
```

From src/expand/resolution.rs:
```rust
pub fn quote_ident(ident: &str) -> String;    // "name" -> "\"name\""
pub fn quote_table_ref(table: &str) -> String; // "schema.table" -> "\"schema\".\"table\""
pub(super) fn find_dimension(def: &SemanticViewDefinition, name: &str) -> Option<&Dimension>;
pub(super) fn find_metric(def: &SemanticViewDefinition, name: &str) -> Option<&Metric>;
```

From src/expand/join_resolver.rs:
```rust
pub(super) fn resolve_joins_pkfk(def: &SemanticViewDefinition, dims: &[&Dimension], mets: &[&Metric]) -> Vec<String>;
pub(super) fn synthesize_on_clause(join: &Join, tables: &[TableRef]) -> String;
pub(super) fn synthesize_on_clause_scoped(join: &Join, tables: &[TableRef], scoped_alias: &str) -> String;
```

From src/model.rs:
```rust
pub struct Fact {
    pub name: String,
    pub expr: String,
    pub source_table: Option<String>,
    pub output_type: Option<String>,
    pub access: AccessModifier,
    // ...
}
```

From src/graph/mod.rs:
```rust
pub struct RelationshipGraph {
    pub forward: HashMap<String, Vec<String>>,
    pub reverse: HashMap<String, Vec<String>>,
}
impl RelationshipGraph {
    pub fn from_definition(def: &SemanticViewDefinition) -> Result<Self, GraphError>;
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Fact expansion path and table path validation</name>
  <files>src/expand/sql_gen.rs, src/expand/fan_trap.rs, src/expand/mod.rs</files>
  <read_first>
    - src/expand/sql_gen.rs (full file — expand() function structure, SELECT/FROM/JOIN/GROUP BY generation patterns)
    - src/expand/fan_trap.rs (full file — ancestors_to_root, parent_map construction, check_fan_traps pattern)
    - src/expand/facts.rs (toposort_facts, inline_facts — fact DAG resolution)
    - src/expand/join_resolver.rs (resolve_joins_pkfk — join resolution for FROM clause)
    - src/expand/resolution.rs (find_dimension, quote_ident, quote_table_ref)
    - src/expand/types.rs (QueryRequest with facts field, ExpandError variants from Plan 01)
    - src/expand/mod.rs (public API re-exports)
    - src/expand/test_helpers.rs (TestFixtureExt builders)
    - src/model.rs (Fact, Dimension, AccessModifier, SemanticViewDefinition)
    - src/graph/mod.rs (RelationshipGraph::from_definition)
  </read_first>
  <behavior>
    - Test: expand with facts-only request produces SELECT without GROUP BY
    - Test: expand with facts + dimensions produces SELECT without GROUP BY
    - Test: expand with facts correctly inlines fact-to-fact dependencies (DAG resolution)
    - Test: expand with unknown fact name returns UnknownFact error with available list and suggestion
    - Test: expand with duplicate fact name returns DuplicateFact error
    - Test: expand with PRIVATE fact returns PrivateFact error
    - Test: validate_fact_table_path with tables on same linear path succeeds
    - Test: validate_fact_table_path with tables on divergent paths returns FactPathViolation
    - Test: validate_fact_table_path with single table succeeds (trivial case)
    - Test: fact query with output_type on fact wraps expression in CAST
  </behavior>
  <action>
**src/expand/fan_trap.rs:**

1. Add `validate_fact_table_path` function (after `ancestors_to_root`). This function reuses the existing `ancestors_to_root` infrastructure and RelationshipGraph:

```rust
/// Validate that all tables referenced by a fact query are on the same
/// root-to-leaf path in the relationship tree.
///
/// Snowflake constraint: "all facts and dimensions used in the query must be
/// defined in the same logical table." For our multi-table model, this means
/// all source_table aliases must be reachable through a single linear path
/// (no fan-out — each pair must have an ancestor/descendant relationship).
///
/// Skips validation when there is only one unique table (trivially valid)
/// or when there are no joins (single-table view).
pub(super) fn validate_fact_table_path(
    view_name: &str,
    def: &SemanticViewDefinition,
    fact_tables: &[String],
    dim_tables: &[String],
) -> Result<(), ExpandError> {
    if def.joins.is_empty() {
        return Ok(());
    }

    // Collect all unique table aliases (case-insensitive)
    let mut all_tables: Vec<String> = Vec::new();
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    for t in fact_tables.iter().chain(dim_tables.iter()) {
        let lower = t.to_ascii_lowercase();
        if seen.insert(lower.clone()) {
            all_tables.push(lower);
        }
    }

    if all_tables.len() <= 1 {
        return Ok(()); // Single table or empty — trivially valid
    }

    let Ok(graph) = crate::graph::RelationshipGraph::from_definition(def) else {
        return Ok(()); // Graph was validated at define time
    };

    // Build parent map from reverse adjacency
    let mut parent_map: HashMap<String, String> = HashMap::new();
    for (child, parents) in &graph.reverse {
        if let Some(parent) = parents.first() {
            parent_map.insert(child.clone(), parent.clone());
        }
    }

    // For each pair, verify one is an ancestor of the other
    for i in 0..all_tables.len() {
        for j in (i + 1)..all_tables.len() {
            let a = &all_tables[i];
            let b = &all_tables[j];
            let a_ancestors = ancestors_to_root(a, &parent_map);
            let b_ancestors = ancestors_to_root(b, &parent_map);
            let a_is_ancestor_of_b = b_ancestors.iter().any(|x| x == a);
            let b_is_ancestor_of_a = a_ancestors.iter().any(|x| x == b);
            if !a_is_ancestor_of_b && !b_is_ancestor_of_a {
                return Err(ExpandError::FactPathViolation {
                    view_name: view_name.to_string(),
                    table_a: a.clone(),
                    table_b: b.clone(),
                });
            }
        }
    }

    Ok(())
}
```

Note: Add `use super::types::ExpandError;` to fan_trap.rs imports (it already imports from model, so add alongside existing imports). Also add `use std::collections::HashSet;` if not already imported (HashMap is already imported).

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

2. Add an `expand_facts` function. Place it BEFORE the existing `expand` function or after the `expand` function but before `#[cfg(test)] mod tests`. The function follows the same SELECT/FROM/JOIN pattern as `expand` but WITHOUT GROUP BY and using fact expressions instead of metric aggregates:

```rust
/// Expand a fact query into unaggregated SQL.
///
/// Facts are row-level expressions — the generated SQL has no GROUP BY and no
/// aggregation. Fact expressions are resolved via `inline_facts` (DAG resolution)
/// just like metric expansion inlines facts into aggregate expressions.
///
/// Dimensions, when present, add columns to SELECT but do NOT trigger GROUP BY
/// (unlike metric queries where dims + metrics => GROUP BY).
#[allow(clippy::result_large_err)]
fn expand_facts(
    view_name: &str,
    def: &SemanticViewDefinition,
    req: &QueryRequest,
) -> Result<String, ExpandError> {
    // 1. Validate + resolve requested facts.
    let mut resolved_facts: Vec<&crate::model::Fact> = Vec::with_capacity(req.facts.len());
    let mut seen_facts = std::collections::HashSet::new();
    for name in &req.facts {
        if !seen_facts.insert(name.to_ascii_lowercase()) {
            return Err(ExpandError::DuplicateFact {
                view_name: view_name.to_string(),
                name: name.clone(),
            });
        }
        let fact = def
            .facts
            .iter()
            .find(|f| f.name.eq_ignore_ascii_case(name))
            .ok_or_else(|| {
                let available: Vec<String> = def.facts.iter().map(|f| f.name.clone()).collect();
                let suggestion = crate::util::suggest_closest(name, &available);
                ExpandError::UnknownFact {
                    view_name: view_name.to_string(),
                    name: name.clone(),
                    available,
                    suggestion,
                }
            })?;
        // PRIVATE access check
        if fact.access == AccessModifier::Private {
            return Err(ExpandError::PrivateFact {
                view_name: view_name.to_string(),
                name: name.clone(),
            });
        }
        resolved_facts.push(fact);
    }

    // 2. Resolve requested dimensions (same logic as expand()).
    let mut resolved_dims: Vec<&crate::model::Dimension> =
        Vec::with_capacity(req.dimensions.len());
    let mut seen_dims = std::collections::HashSet::new();
    for name in &req.dimensions {
        if !seen_dims.insert(name.to_ascii_lowercase()) {
            return Err(ExpandError::DuplicateDimension {
                view_name: view_name.to_string(),
                name: name.clone(),
            });
        }
        let dim = find_dimension(def, name).ok_or_else(|| {
            let available: Vec<String> = def.dimensions.iter().map(|d| d.name.clone()).collect();
            let suggestion = crate::util::suggest_closest(name, &available);
            ExpandError::UnknownDimension {
                view_name: view_name.to_string(),
                name: name.clone(),
                available,
                suggestion,
            }
        })?;
        resolved_dims.push(dim);
    }

    // 3. Validate table path constraint (FACT-04).
    let fact_tables: Vec<String> = resolved_facts
        .iter()
        .filter_map(|f| f.source_table.clone())
        .collect();
    let dim_tables: Vec<String> = resolved_dims
        .iter()
        .filter_map(|d| d.source_table.clone())
        .collect();
    check_fact_table_path(view_name, def, &fact_tables, &dim_tables)?;

    // 4. Resolve fact expressions via DAG inlining (fact-to-fact dependencies).
    let topo_order = toposort_facts(&def.facts).unwrap_or_default();

    // 5. Build SELECT clause (no DISTINCT, no aggregation).
    let mut sql = String::with_capacity(256);
    sql.push_str("SELECT\n");

    let mut select_items: Vec<String> = Vec::new();

    // Dimensions first
    for dim in &resolved_dims {
        let base_expr = dim.expr.clone();
        let final_expr = if let Some(ref type_str) = dim.output_type {
            format!("CAST({base_expr} AS {type_str})")
        } else {
            base_expr
        };
        select_items.push(format!("    {} AS {}", final_expr, quote_ident(&dim.name)));
    }

    // Then facts (inlined expressions, no aggregation)
    for fact in &resolved_facts {
        use super::facts::inline_facts;
        let resolved_expr = inline_facts(&fact.expr, &def.facts, &topo_order);
        let final_expr = if let Some(ref type_str) = fact.output_type {
            format!("CAST({resolved_expr} AS {type_str})")
        } else {
            resolved_expr
        };
        select_items.push(format!("    {} AS {}", final_expr, quote_ident(&fact.name)));
    }
    sql.push_str(&select_items.join(",\n"));

    // 6. FROM clause — same pattern as expand().
    sql.push_str("\nFROM ");
    sql.push_str(&quote_table_ref(&def.base_table));
    if let Some(base_ref) = def.tables.first() {
        sql.push_str(" AS ");
        sql.push_str(&quote_ident(&base_ref.alias));
    }

    // 7. JOIN clauses — resolve required joins for fact + dim tables.
    // Build temporary Metric slice as empty (no metrics in fact queries)
    let empty_mets: Vec<&crate::model::Metric> = vec![];
    let ordered_aliases = resolve_joins_pkfk(def, &resolved_dims, &empty_mets);

    // Also ensure fact source tables are included in join resolution.
    // resolve_joins_pkfk uses dim+met source tables. For facts, we need to
    // check if any fact source tables require additional joins.
    let mut fact_aliases: Vec<String> = Vec::new();
    for fact in &resolved_facts {
        if let Some(ref st) = fact.source_table {
            let lower = st.to_ascii_lowercase();
            if !ordered_aliases.iter().any(|a| a.to_ascii_lowercase() == lower)
                && def.tables.first().map_or(true, |t| t.alias.to_ascii_lowercase() != lower)
            {
                fact_aliases.push(lower);
            }
        }
    }

    // Emit joins from resolve_joins_pkfk
    for alias in &ordered_aliases {
        let Some(join) = def.joins.iter().find(|j| {
            j.table.to_ascii_lowercase() == *alias || j.from_alias.to_ascii_lowercase() == *alias
        }) else {
            continue;
        };
        let table_ref = def
            .tables
            .iter()
            .find(|t| t.alias.to_ascii_lowercase() == *alias);
        let physical_table = table_ref.map_or(alias.as_str(), |t| t.table.as_str());
        sql.push_str("\nLEFT JOIN ");
        sql.push_str(&quote_table_ref(physical_table));
        sql.push_str(" AS ");
        sql.push_str(&quote_ident(alias));
        sql.push_str(" ON ");
        sql.push_str(&synthesize_on_clause(join, &def.tables));
    }

    // Emit additional joins for fact source tables not covered by dim resolution
    for alias in &fact_aliases {
        let Some(join) = def.joins.iter().find(|j| {
            j.table.to_ascii_lowercase() == *alias || j.from_alias.to_ascii_lowercase() == *alias
        }) else {
            continue;
        };
        let table_ref = def
            .tables
            .iter()
            .find(|t| t.alias.to_ascii_lowercase() == *alias);
        let physical_table = table_ref.map_or(alias.as_str(), |t| t.table.as_str());
        sql.push_str("\nLEFT JOIN ");
        sql.push_str(&quote_table_ref(physical_table));
        sql.push_str(" AS ");
        sql.push_str(&quote_ident(alias));
        sql.push_str(" ON ");
        sql.push_str(&synthesize_on_clause(join, &def.tables));
    }

    // NO GROUP BY — fact queries are unaggregated

    Ok(sql)
}
```

3. Import the new function in sql_gen.rs. Add `use super::fan_trap::validate_fact_table_path as check_fact_table_path;` to the imports at the top. Also add `use super::join_resolver::synthesize_on_clause;` if not already imported (check — it may only import `resolve_joins_pkfk` and `synthesize_on_clause` and `synthesize_on_clause_scoped`).

4. In the existing `expand()` function, add a dispatch to `expand_facts()` when facts are requested. After the mutual exclusion check and the EmptyRequest check (which were added in Plan 01), add:

```rust
// Dispatch to fact expansion path when facts are requested.
if !req.facts.is_empty() {
    return expand_facts(view_name, def, req);
}
```

This should go AFTER the EmptyRequest check and BEFORE the dimension resolution loop (currently step 2, around line 37).

**src/expand/mod.rs:**

5. The `validate_fact_table_path` function is `pub(super)` in fan_trap.rs, so it's accessible within the expand module. No re-export needed in mod.rs. However, confirm that `inline_facts` from `facts.rs` is accessible as `super::facts::inline_facts` from within sql_gen.rs (it is `pub(super)`, so yes).

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

6. Add test `test_fact_query_basic`: Build a multi-table def with tables o/li, a dim on o, facts on li. Request `facts: vec!["net_price"]`, `dimensions: vec!["region"]`. Assert SQL has no GROUP BY, has SELECT with dim expr and fact expr, has FROM and LEFT JOIN.

7. Add test `test_fact_query_no_dimensions`: Build a def with a fact, request `facts: vec!["net_price"]`, empty dims. Assert SQL has SELECT with fact expr, FROM, no GROUP BY.

8. Add test `test_fact_query_inline_facts`: Build a def with two facts where fact B references fact A. Request fact B. Assert the SQL inlines A's expression into B's (parenthesized).

9. Add test `test_fact_query_unknown_fact`: Request a nonexistent fact name. Assert UnknownFact error with available list.

10. Add test `test_fact_query_duplicate_fact`: Request same fact twice. Assert DuplicateFact error.

11. Add test `test_fact_query_private_fact`: Build def with PRIVATE fact. Request it. Assert PrivateFact error.

12. Add test `test_fact_path_violation`: Build a def with three tables in a fan shape (o -> li, o -> payments). Put fact on li, dim on payments. Assert FactPathViolation error.

13. Add test `test_fact_path_valid_linear`: Build a def with three tables in a chain (o -> li -> details). Put fact on details, dim on o. Assert success (all on same path).

14. Add test `test_fact_query_with_output_type`: Build def with fact that has `output_type: Some("DECIMAL(10,2)".to_string())`. Assert SQL contains `CAST(... AS DECIMAL(10,2))`.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- fact_query fact_path 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
    - src/expand/sql_gen.rs contains `fn expand_facts(`
    - src/expand/sql_gen.rs expand() dispatches to expand_facts when `!req.facts.is_empty()`
    - src/expand/fan_trap.rs contains `pub(super) fn validate_fact_table_path(`
    - expand_facts produces SQL with no "GROUP BY" clause
    - expand_facts inlines fact expressions via inline_facts
    - expand_facts validates PRIVATE access on facts
    - expand_facts validates unknown/duplicate fact names
    - validate_fact_table_path uses ancestors_to_root for path validation
    - validate_fact_table_path returns FactPathViolation for divergent paths
    - cargo test fact_query passes with at least 7 tests
    - cargo test fact_path passes with at least 2 tests
    - cargo test (full) passes
  </acceptance_criteria>
  <done>expand_facts produces unaggregated SQL for fact queries with fact inlining, PRIVATE filtering, unknown/duplicate validation, and table path constraint enforcement. All unit tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Fact query type resolution and integration tests</name>
  <files>src/query/table_function.rs, test/sql/phase46_fact_query.test, test/sql/TEST_LIST</files>
  <read_first>
    - src/query/table_function.rs (full file — bind() type inference section, lines 448-578)
    - src/expand/sql_gen.rs (expand_facts() from Task 1)
    - src/model.rs (Fact.output_type field — line 109)
    - test/sql/phase29_facts.test (existing FACTS DDL test patterns for reference)
    - test/sql/phase31_fan_trap.test (multi-table relationship patterns for reference)
    - test/sql/phase43_metadata.test (PRIVATE modifier patterns)
  </read_first>
  <action>
**src/query/table_function.rs:**

1. Refine the type resolution for fact queries in `bind()`. In Plan 01 Task 2, fact columns were added to the type inference section with `type_ids.push(0u32)` (VARCHAR fallback). Now improve this to use the LIMIT 0 fallback path for fact queries.

The key insight: the primary type inference path uses `def.inferred_types()` which maps `column_type_names` to `column_types_inferred`. Facts are NOT in this map — they use per-fact `output_type`. So the existing primary path correctly falls through to 0/INVALID for facts, which triggers VARCHAR fallback.

For fact queries, the simplest correct approach is to use the LIMIT 0 fallback for the entire query when facts are present. Update the bind logic:

After the `let req = ...` and `let expanded_sql = ...` blocks, add a check: if `!facts.is_empty()`, skip the primary type inference path and go straight to the LIMIT 0 fallback:

```rust
let (column_names, column_type_ids) = if !facts.is_empty() {
    // Fact queries: use LIMIT 0 for type inference (facts use per-fact output_type
    // strings, not the column_types_inferred map).
    let limit0_sql = format!("{expanded_sql} LIMIT 0");
    if let Some((names, types)) = unsafe { try_infer_schema(state.conn, &limit0_sql) } {
        let type_ids: Vec<u32> = types.iter().map(|t| normalize_type_id(*t as u32)).collect();
        (names, type_ids)
    } else {
        // Full fallback: derive names from definition, all VARCHAR.
        let mut names = Vec::new();
        for dim_name in &dimensions {
            let canonical = def
                .dimensions
                .iter()
                .find(|d| d.name.eq_ignore_ascii_case(dim_name))
                .map_or_else(|| dim_name.clone(), |d| d.name.clone());
            names.push(canonical);
        }
        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);
        }
        let type_ids = vec![0u32; names.len()];
        (names, type_ids)
    }
} else if !type_map.is_empty() {
    // ... existing primary path for metric queries ...
```

This replaces the existing `if !type_map.is_empty()` at the top of the type resolution block. The `else if !type_map.is_empty()` continues into the existing primary path code unchanged. The existing `else` (fallback path) also remains unchanged.

Note: Remove the fact-related additions from Plan 01 Task 2 step 7 (the `for fact_name in &facts` loops inside the primary and fallback paths), since we now handle facts as a separate top-level branch.

**test/sql/phase46_fact_query.test:**

2. Create comprehensive integration tests for fact queries:

```
# Phase 46: Queryable FACTS
# Tests FACT-01 (fact queries), FACT-02 (unaggregated results), FACT-03 (mutual exclusion), FACT-04 (path validation)

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);

statement ok
INSERT INTO orders VALUES (1, 'US', 'shipped'), (2, 'EU', 'pending'), (3, 'US', 'shipped');

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 facts ---

statement ok
CREATE SEMANTIC VIEW sales AS
TABLES
    o AS orders PRIMARY KEY (id),
    li AS line_items PRIMARY KEY (id)
RELATIONSHIPS
    li REFERENCES o (order_id -> id)
FACTS
    li.net_price AS li.price * (1 - li.discount),
    li.line_total AS li.net_price * li.quantity,
    PRIVATE li.raw_price AS li.price
DIMENSIONS
    o.region AS o.region,
    o.status AS o.status,
    li.product AS li.product
METRICS
    li.total_revenue AS sum(li.line_total),
    o.order_count AS count(*)
;

# --- FACT-01: Basic fact query ---

# Facts-only query (no dimensions)
query II
FROM semantic_view('sales', facts := ['net_price', 'line_total'])
ORDER BY 1, 2;
----
45.00	315.00
50.00	250.00
90.00	900.00
95.00	285.00

# --- FACT-02: Unaggregated results with dimensions ---

# Facts + dimensions: row-level, no GROUP BY
query III
FROM semantic_view('sales', facts := ['net_price'], dimensions := ['o.region', 'li.product'])
ORDER BY 1, 2, 3;
----
EU	Widget	95.00
US	Gadget	42.50
US	Gadget	50.00
US	Widget	90.00

# Verify row count matches line_items table (4 rows, unaggregated)
query I
SELECT count(*) FROM semantic_view('sales', facts := ['net_price'], dimensions := ['o.region']);
----
4

# --- FACT-03: Facts + metrics mutual exclusion ---

statement error
FROM semantic_view('sales', facts := ['net_price'], metrics := ['total_revenue']);
----
cannot combine facts and metrics

# --- FACT-04: Path validation (divergent paths) ---

# Setup a view with fan-out structure for path validation test
statement ok
CREATE TABLE payments (id INTEGER PRIMARY KEY, order_id INTEGER, amount DECIMAL(10,2));

statement ok
INSERT INTO payments VALUES (1, 1, 100.00), (2, 2, 50.00);

statement ok
CREATE SEMANTIC VIEW fan_test AS
TABLES
    o AS orders PRIMARY KEY (id),
    li AS line_items PRIMARY KEY (id),
    p AS payments PRIMARY KEY (id)
RELATIONSHIPS
    li REFERENCES o (order_id -> id),
    p REFERENCES o (order_id -> id)
FACTS
    li.net_price AS li.price * (1 - li.discount),
    p.payment_amt AS p.amount
DIMENSIONS
    o.region AS o.region,
    li.product AS li.product,
    p.pay_status AS CAST(p.amount AS VARCHAR)
;

# Fact on li + dim on p = divergent paths (fan from o)
statement error
FROM semantic_view('fan_test', facts := ['net_price'], dimensions := ['pay_status']);
----
incompatible table paths

# Fact on li + dim on o = valid (linear path: o -> li)
query II
FROM semantic_view('fan_test', facts := ['net_price'], dimensions := ['o.region'])
ORDER BY 1, 2;
----
EU	95.00
US	42.50
US	50.00
US	90.00

# --- PRIVATE fact exclusion ---

statement error
FROM semantic_view('sales', facts := ['raw_price']);
----
private

# --- Unknown fact ---

statement error
FROM semantic_view('sales', facts := ['nonexistent']);
----
unknown fact

# --- Fact-to-fact DAG inlining ---

# line_total depends on net_price — both should work
query I
FROM semantic_view('sales', facts := ['line_total'])
ORDER BY 1;
----
250.00
285.00
315.00
900.00

# --- explain_semantic_view with facts ---

query I
SELECT count(*) FROM explain_semantic_view('sales', facts := ['net_price'], dimensions := ['o.region'])
WHERE explain_output LIKE '%net_price%' OR explain_output LIKE '%Facts:%';
----
1

# --- Wildcard in facts parameter ---

# li.* in facts should expand to net_price and line_total (raw_price is PRIVATE)
query II
FROM semantic_view('sales', facts := ['li.*'])
ORDER BY 1, 2;
----
45.00	315.00
50.00	250.00
90.00	900.00
95.00	285.00
```

NOTE: The exact output values for decimal calculations may need adjustment by the executor based on actual query results. The test structure and expected behavior (row counts, column counts, error messages) are what matter. The executor should run the tests and fix decimal formatting if needed.

**test/sql/TEST_LIST:**

3. Add the fact query test file:
```
test/sql/phase46_fact_query.test
```

4. After writing the test file, build and run:
```bash
just build && just test-sql
```
Fix any assertion mismatches in the sqllogictest output values.

5. Run full test suite:
```bash
just test-all
```
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && just build && just test-all 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - src/query/table_function.rs bind() has a separate type inference branch for fact queries using LIMIT 0
    - test/sql/phase46_fact_query.test exists with tests for FACT-01, FACT-02, FACT-03, FACT-04
    - test/sql/TEST_LIST contains "test/sql/phase46_fact_query.test"
    - just test-all passes (cargo test + sqllogictest + DuckLake CI)
    - Fact queries return unaggregated row-level results (4 rows for 4 line_items, not aggregated)
    - Fact + metric mutual exclusion error contains "cannot combine facts and metrics"
    - Divergent path fact query error contains "incompatible table paths"
    - PRIVATE fact error contains "private"
    - Unknown fact error contains "unknown fact"
    - Fact-to-fact DAG inlining produces correct values
    - explain_semantic_view works with facts parameter
    - Wildcard in facts parameter expands to PUBLIC facts for that alias
  </acceptance_criteria>
  <done>Fact queries work end-to-end through semantic_view() and explain_semantic_view(), with correct type inference, path validation, PRIVATE filtering, and comprehensive integration tests. Full test suite passes.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| User fact names -> expand_facts | Untrusted fact names validated against definition |
| Fact expressions -> SQL output | Fact expressions stored at DDL time; not user-supplied at query time |
| Table path validation -> ExpandError | Divergent paths blocked before SQL generation |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-46-03 | Tampering | expand_facts SQL generation | accept | Fact expressions come from stored definitions (DDL-time validated), not runtime input. No injection surface. |
| T-46-04 | Information Disclosure | PRIVATE facts in fact queries | mitigate | PrivateFact check in expand_facts before SQL generation. Wildcard expansion already filters PRIVATE in Plan 01. |
| T-46-05 | Denial of Service | fact path validation | accept | ancestors_to_root is O(depth) per node, total O(n^2 * depth) for n tables. Typical views have <10 tables. No amplification risk. |
</threat_model>

<verification>
1. `cargo test` passes — all unit tests including fact query expansion and path validation
2. `just build && just test-sql` passes — both phase46_wildcard.test and phase46_fact_query.test
3. `just test-all` passes — full suite including DuckLake CI
4. Fact queries return correct row-level (unaggregated) results
5. Fact-to-fact DAG inlining produces correct expressions
6. Path validation blocks divergent table paths
7. PRIVATE facts blocked, unknown facts rejected with suggestions
</verification>

<success_criteria>
- semantic_view('v', facts := ['f1', 'f2'], dimensions := ['d1']) returns unaggregated rows
- Generated SQL has no GROUP BY clause for fact queries
- Fact expressions are resolved via toposort_facts + inline_facts
- Path validation reuses ancestors_to_root from fan_trap.rs
- Divergent table paths produce FactPathViolation error
- PRIVATE facts rejected, unknown facts rejected with did-you-mean
- explain_semantic_view works with facts parameter
- just test-all passes
</success_criteria>

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