---
phase: 11-create-semantic-view-parser-hook
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/model.rs
  - src/catalog.rs
autonomous: true
requirements: [DDL-03, DDL-04]

must_haves:
  truths:
    - "Old JSON definitions (without facts field) still load via from_json after model changes"
    - "New catalog_upsert() overwrites an existing entry without error"
    - "catalog_delete_if_exists() silently succeeds when view does not exist"
    - "FFI-callable catalog functions (semantic_views_catalog_insert, _upsert, _delete, _delete_if_exists) exist as #[no_mangle] extern C functions in catalog.rs"
  artifacts:
    - path: "src/model.rs"
      provides: "Fact struct; updated Join struct with from_cols + old on field; facts field on SemanticViewDefinition; deny_unknown_fields removed"
    - path: "src/catalog.rs"
      provides: "catalog_upsert, catalog_delete_if_exists; FFI-safe extern C functions for catalog mutation"
  key_links:
    - from: "src/catalog.rs"
      to: "src/model.rs"
      via: "SemanticViewDefinition::from_json used inside catalog_upsert"
      pattern: "catalog_upsert.*from_json"
    - from: "src/shim/shim.cpp (plan 11-02)"
      to: "src/catalog.rs"
      via: "semantic_views_catalog_insert / _upsert / _delete / _delete_if_exists FFI"
      pattern: "semantic_views_catalog_"
---

<objective>
Update the Rust data model and catalog to support Phase 11 requirements.

Purpose: The C++ parser hook (plan 11-02) needs two new catalog operations (upsert and delete-if-exists) and must mutate the in-memory catalog via FFI-safe Rust functions. The data model needs a Fact struct and Join evolution to represent the Snowflake DDL grammar.

Output: Updated model.rs with Fact struct + updated Join; updated catalog.rs with catalog_upsert, catalog_delete_if_exists, and four FFI-callable catalog functions.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-create-semantic-view-parser-hook/11-CONTEXT.md
@.planning/phases/11-create-semantic-view-parser-hook/11-RESEARCH.md

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

From src/model.rs (current state):
```rust
pub struct Dimension { pub name: String, pub expr: String, pub source_table: Option<String>, ... }
pub struct Metric { pub name: String, pub expr: String, pub source_table: Option<String> }
pub struct Join { pub table: String, pub on: String }  // <-- CHANGING
#[serde(deny_unknown_fields)]
pub struct SemanticViewDefinition {
    pub base_table: String,
    pub dimensions: Vec<Dimension>,
    pub metrics: Vec<Metric>,
    pub filters: Vec<String>,
    pub joins: Vec<Join>,
    // facts: Vec<Fact>  <-- ADDING
}
```

From src/catalog.rs (current state):
```rust
pub type CatalogState = Arc<RwLock<HashMap<String, String>>>;

pub fn catalog_insert(state: &CatalogState, name: &str, json: &str)
    -> Result<(), Box<dyn std::error::Error>>
pub fn catalog_delete(state: &CatalogState, name: &str)
    -> Result<(), Box<dyn std::error::Error>>
// catalog_upsert  <-- ADDING
// catalog_delete_if_exists  <-- ADDING
// FFI functions  <-- ADDING
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Update model.rs — add Fact struct, evolve Join, add facts field</name>
  <files>src/model.rs</files>
  <behavior>
    - New Fact struct deserializes correctly from JSON: `{"name":"rev","expr":"amount","source_table":"orders"}`
    - Fact with no source_table deserializes with `source_table: None` (serde default)
    - Old Join with `on: String` field (`{"table":"t","on":"a.id=b.id"}`) still deserializes into Join with `from_cols: []` and the old `on` field populated
    - New Join with `from_cols` (`{"table":"customers","from_cols":["customer_id"]}`) deserializes correctly with `on` empty
    - SemanticViewDefinition with no `facts` key deserializes with `facts: []` (serde default)
    - SemanticViewDefinition with `facts` array deserializes correctly
    - `deny_unknown_fields` removed from SemanticViewDefinition (so old stored JSON with extra fields loads)
    - The existing test `unknown_fields_are_rejected` must be DELETED (it tested deny_unknown_fields behavior that we are removing)
    - All other existing model.rs tests continue to pass
  </behavior>
  <action>
  Make the following changes to `src/model.rs`:

  1. Add `Fact` struct after `Metric`:
     ```rust
     #[derive(Debug, Clone, Default, Serialize, Deserialize)]
     #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
     pub struct Fact {
         pub name: String,
         pub expr: String,
         /// Which table alias this fact is scoped to.
         #[serde(default)]
         pub source_table: Option<String>,
     }
     ```

  2. Update `Join` struct to support both old (raw SQL `on`) and new (FK `from_cols`) formats:
     ```rust
     #[derive(Debug, Clone, Serialize, Deserialize)]
     #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
     pub struct Join {
         pub table: String,
         /// Legacy field (Phase 10 and earlier): raw SQL ON clause.
         /// Kept for backward compat with stored JSON. Not written by Phase 11 DDL.
         #[serde(default, skip_serializing_if = "String::is_empty")]
         pub on: String,
         /// New field (Phase 11): FK column names from this table to the base table.
         /// Set by CREATE SEMANTIC VIEW RELATIONSHIPS clause.
         #[serde(default)]
         pub from_cols: Vec<String>,
     }
     ```

  3. Update `SemanticViewDefinition`:
     - Remove `#[serde(deny_unknown_fields)]` attribute (backward compat — old JSON must load)
     - Add `facts: Vec<Fact>` field with `#[serde(default)]`

  4. In tests: delete the `unknown_fields_are_rejected` test (it specifically tested deny_unknown_fields behavior that is now removed). Add new tests:
     - `fact_roundtrip`: Fact struct with and without source_table deserializes correctly
     - `join_old_format_backwards_compat`: Join with `on` field deserializes; `from_cols` defaults to `[]`
     - `join_new_format`: Join with `from_cols` deserializes; `on` defaults to `""`
     - `definition_with_facts`: SemanticViewDefinition with facts array deserializes correctly
     - `definition_without_facts_defaults_empty`: SemanticViewDefinition without facts key gets `facts: []`
  </action>
  <verify>
    <automated>cargo nextest run --lib model 2>&1</automated>
  </verify>
  <done>All model.rs tests pass; Fact struct, updated Join, and facts field on SemanticViewDefinition are present; deny_unknown_fields removed</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Update catalog.rs — catalog_upsert, catalog_delete_if_exists, and FFI catalog functions</name>
  <files>src/catalog.rs</files>
  <behavior>
    - `catalog_upsert(state, name, json)` inserts if new, replaces if exists — no error on duplicate
    - `catalog_upsert(state, name, invalid_json)` returns error (validates JSON before writing)
    - `catalog_delete_if_exists(state, name)` removes view when it exists
    - `catalog_delete_if_exists(state, name)` succeeds silently when view does not exist (no error)
    - `semantic_views_catalog_insert(catalog_ptr, name_ptr, json_ptr)` returns 0 on success, -1 on invalid json or duplicate
    - `semantic_views_catalog_upsert(catalog_ptr, name_ptr, json_ptr)` returns 0 on success, -1 on invalid json
    - `semantic_views_catalog_delete(catalog_ptr, name_ptr)` returns 0 on success, -1 if view does not exist
    - `semantic_views_catalog_delete_if_exists(catalog_ptr, name_ptr)` always returns 0
    - FFI functions with null pointer inputs return -1 immediately (safety guard)
    - All existing catalog.rs tests continue to pass
  </behavior>
  <action>
  Add the following to `src/catalog.rs` after the existing `catalog_delete` function:

  1. `catalog_upsert` — like catalog_insert but skips duplicate check:
     ```rust
     pub fn catalog_upsert(
         state: &CatalogState,
         name: &str,
         json: &str,
     ) -> std::result::Result<(), Box<dyn std::error::Error>> {
         SemanticViewDefinition::from_json(name, json)
             .map_err(Box::<dyn std::error::Error>::from)?;
         state.write().unwrap().insert(name.to_string(), json.to_string());
         Ok(())
     }
     ```

  2. `catalog_delete_if_exists` — silent success when absent:
     ```rust
     pub fn catalog_delete_if_exists(state: &CatalogState, name: &str) {
         state.write().unwrap().remove(name);
     }
     ```

  3. FFI-callable catalog functions — these are called from C++ scan function via `shim.h`. Add in a `#[cfg(feature = "extension")]` block at the bottom of the file:
     ```rust
     #[cfg(feature = "extension")]
     mod ffi_catalog {
         use super::*;
         use std::ffi::c_char;

         unsafe fn str_from_ptr<'a>(ptr: *const c_char) -> Option<&'a str> {
             if ptr.is_null() { return None; }
             std::ffi::CStr::from_ptr(ptr).to_str().ok()
         }

         /// Insert a new semantic view into the in-memory catalog.
         /// Returns 0 on success, -1 if pointer null / json invalid / duplicate.
         #[no_mangle]
         pub unsafe extern "C" fn semantic_views_catalog_insert(
             catalog_ptr: *const CatalogState,
             name_ptr: *const c_char,
             json_ptr: *const c_char,
         ) -> i32 {
             let Some(state) = catalog_ptr.as_ref() else { return -1; };
             let (Some(name), Some(json)) = (str_from_ptr(name_ptr), str_from_ptr(json_ptr))
             else { return -1; };
             catalog_insert(state, name, json).map(|_| 0).unwrap_or(-1)
         }

         /// Upsert a semantic view in the in-memory catalog.
         /// Returns 0 on success, -1 if pointer null or json invalid.
         #[no_mangle]
         pub unsafe extern "C" fn semantic_views_catalog_upsert(
             catalog_ptr: *const CatalogState,
             name_ptr: *const c_char,
             json_ptr: *const c_char,
         ) -> i32 {
             let Some(state) = catalog_ptr.as_ref() else { return -1; };
             let (Some(name), Some(json)) = (str_from_ptr(name_ptr), str_from_ptr(json_ptr))
             else { return -1; };
             catalog_upsert(state, name, json).map(|_| 0).unwrap_or(-1)
         }

         /// Delete a semantic view from the in-memory catalog.
         /// Returns 0 on success, -1 if not found.
         #[no_mangle]
         pub unsafe extern "C" fn semantic_views_catalog_delete(
             catalog_ptr: *const CatalogState,
             name_ptr: *const c_char,
         ) -> i32 {
             let Some(state) = catalog_ptr.as_ref() else { return -1; };
             let Some(name) = str_from_ptr(name_ptr) else { return -1; };
             catalog_delete(state, name).map(|_| 0).unwrap_or(-1)
         }

         /// Delete a semantic view if it exists; silently succeeds if absent.
         /// Returns 0 always (unless null pointer, which returns -1).
         #[no_mangle]
         pub unsafe extern "C" fn semantic_views_catalog_delete_if_exists(
             catalog_ptr: *const CatalogState,
             name_ptr: *const c_char,
         ) -> i32 {
             let Some(state) = catalog_ptr.as_ref() else { return -1; };
             let Some(name) = str_from_ptr(name_ptr) else { return -1; };
             catalog_delete_if_exists(state, name);
             0
         }
     }
     ```

  Add unit tests for the new safe Rust functions (not FFI) in the existing `#[cfg(test)]` block:
  - `upsert_inserts_when_absent`
  - `upsert_replaces_when_present`
  - `upsert_rejects_invalid_json`
  - `delete_if_exists_removes`
  - `delete_if_exists_silent_when_absent`

  Note: The FFI functions are `unsafe extern "C"` and cannot be tested in unit tests without the `extension` feature. Cover them via integration tests in plan 11-04.
  </action>
  <verify>
    <automated>cargo nextest run --lib catalog 2>&1</automated>
  </verify>
  <done>catalog_upsert, catalog_delete_if_exists, and four FFI catalog functions exist and all catalog tests pass</done>
</task>

</tasks>

<verification>
Run full Rust test suite to confirm no regressions:

```bash
cargo nextest run 2>&1
```

Expected: all tests pass (model + catalog modules).
</verification>

<success_criteria>
- `cargo nextest run` passes with no test failures
- `src/model.rs` has: Fact struct, updated Join (on + from_cols), facts field on SemanticViewDefinition, deny_unknown_fields removed
- `src/catalog.rs` has: catalog_upsert, catalog_delete_if_exists, four `#[no_mangle] pub unsafe extern "C"` catalog FFI functions inside `#[cfg(feature = "extension")] mod ffi_catalog`
- Old JSON definitions (no `facts` key) still parse without error
- Old Join definitions with `on` field still parse correctly
</success_criteria>

<output>
After completion, create `.planning/phases/11-create-semantic-view-parser-hook/11-01-SUMMARY.md`
</output>
