---
phase: 31-fan-trap-detection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/model.rs
  - src/body_parser.rs
  - tests/parse_proptest.rs
autonomous: true
requirements: [FAN-01]

must_haves:
  truths:
    - "Relationships can declare MANY TO ONE, ONE TO ONE, or ONE TO MANY after REFERENCES"
    - "Omitting cardinality defaults to ManyToOne"
    - "Old stored JSON without cardinality field deserializes as ManyToOne (backward compat)"
    - "Cardinality keywords are case-insensitive and space-separated (no underscores)"
  artifacts:
    - path: "src/model.rs"
      provides: "Cardinality enum with ManyToOne, OneToOne, OneToMany variants"
      contains: "pub enum Cardinality"
    - path: "src/body_parser.rs"
      provides: "Cardinality parsing after REFERENCES <to_alias>"
      contains: "parse_cardinality"
  key_links:
    - from: "src/body_parser.rs"
      to: "src/model.rs"
      via: "Cardinality enum used in parse_single_relationship_entry"
      pattern: "Cardinality::"
    - from: "src/model.rs"
      to: "serde"
      via: "#[serde(default)] on Join.cardinality field"
      pattern: "serde.*default"
---

<objective>
Add the Cardinality enum to the relationship model and extend the parser to accept optional cardinality keywords after REFERENCES.

Purpose: FAN-01 requires relationships to optionally declare cardinality type. This plan adds the data model and parser support without changing query behavior (Plan 02 adds the actual fan trap detection).

Output: `Cardinality` enum on `Join`, parser accepts `MANY TO ONE` / `ONE TO ONE` / `ONE TO MANY`, backward-compatible serde, unit tests and proptests.
</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/31-fan-trap-detection/31-CONTEXT.md
@.planning/phases/31-fan-trap-detection/31-RESEARCH.md
@src/model.rs
@src/body_parser.rs
@tests/parse_proptest.rs

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

From src/model.rs (Join struct to modify):
```rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Join {
    pub table: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub on: String,
    #[serde(default)]
    pub from_cols: Vec<String>,
    #[serde(default)]
    pub join_columns: Vec<JoinColumn>,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub from_alias: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fk_columns: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}
```

From src/body_parser.rs (parse_single_relationship_entry return, lines 693-701):
```rust
Ok(Join {
    table: to_alias.to_string(),
    from_alias: from_alias.to_string(),
    fk_columns,
    name: Some(rel_name.to_string()),
    on: String::new(),
    from_cols: vec![],
    join_columns: vec![],
})
```

From src/body_parser.rs (line 683 - current greedy to_alias extraction):
```rust
let to_alias = after_paren[refs_pos + "REFERENCES".len()..].trim();
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add Cardinality enum to model and extend Join struct</name>
  <files>src/model.rs</files>
  <behavior>
    - Test: Cardinality enum serializes/deserializes correctly for all three variants (ManyToOne, OneToOne, OneToMany)
    - Test: Join with cardinality field roundtrips through serde JSON
    - Test: Old JSON without cardinality field deserializes Join with cardinality = ManyToOne (backward compat via #[serde(default)])
    - Test: SemanticViewDefinition with joins containing cardinality roundtrips correctly
  </behavior>
  <action>
    1. Add `Cardinality` enum to `src/model.rs` BEFORE the `Join` struct:
       ```rust
       #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
       #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
       pub enum Cardinality {
           #[default]
           ManyToOne,
           OneToOne,
           OneToMany,
       }
       ```
    2. Add `cardinality` field to `Join` struct:
       ```rust
       /// Phase 31: Cardinality of this relationship.
       /// Defaults to ManyToOne when omitted in DDL (most common FK pattern).
       /// Old stored JSON without this field deserializes as ManyToOne.
       #[serde(default)]
       pub cardinality: Cardinality,
       ```
    3. Add `use` of `Cardinality` in the `model.rs` imports if needed (it is in the same module, so likely just needs the enum defined).
    4. Add unit tests in the existing `mod tests` section of `model.rs`:
       - `cardinality_serde_roundtrip`: serialize/deserialize all three variants
       - `join_with_cardinality_roundtrip`: Join with cardinality field roundtrips
       - `old_json_without_cardinality_defaults_to_many_to_one`: backward compat test
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- cardinality</automated>
  </verify>
  <done>Cardinality enum exists on Join struct with Default=ManyToOne, all serde roundtrip tests pass, backward compat with old JSON confirmed</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Extend relationship parser to accept cardinality keywords</name>
  <files>src/body_parser.rs, tests/parse_proptest.rs</files>
  <behavior>
    - Test: `order_to_customer AS o(customer_id) REFERENCES c MANY TO ONE` parses with cardinality=ManyToOne
    - Test: `rel AS a(fk) REFERENCES b ONE TO ONE` parses with cardinality=OneToOne
    - Test: `rel AS a(fk) REFERENCES b ONE TO MANY` parses with cardinality=OneToMany
    - Test: `rel AS a(fk) REFERENCES b` (no cardinality) parses with cardinality=ManyToOne (default)
    - Test: Case-insensitive: `many to one`, `Many To One`, `MANY TO ONE` all parse identically
    - Test: `to_alias` is correctly extracted as just `b` when followed by cardinality keywords (not `b MANY TO ONE`)
    - Proptest: Arbitrary relationship entries with random cardinality keywords (including case variations and extra whitespace) parse successfully
  </behavior>
  <action>
    1. In `src/body_parser.rs`, modify `parse_single_relationship_entry` (around line 683):
       - Currently: `let to_alias = after_paren[refs_pos + "REFERENCES".len()..].trim();`
       - Change to: extract the text after REFERENCES, split into words, take the first word as `to_alias`, then check if remaining words form a cardinality keyword sequence.
       - Implementation approach:
         ```rust
         let remaining_after_refs = after_paren[refs_pos + "REFERENCES".len()..].trim();
         // Split into whitespace-separated tokens
         let tokens: Vec<&str> = remaining_after_refs.split_whitespace().collect();
         if tokens.is_empty() {
             return Err(ParseError { /* expected target alias */ });
         }
         let to_alias = tokens[0];
         // Parse cardinality from remaining tokens (tokens[1..])
         let cardinality = parse_cardinality_tokens(&tokens[1..]);
         ```
    2. Add a helper function `parse_cardinality_tokens` that accepts a slice of tokens and returns `Cardinality`:
       - `["MANY", "TO", "ONE"]` (case-insensitive) -> `ManyToOne`
       - `["ONE", "TO", "ONE"]` -> `OneToOne`
       - `["ONE", "TO", "MANY"]` -> `OneToMany`
       - Empty slice -> `ManyToOne` (default)
       - Anything else -> return `Err(ParseError)` with descriptive message naming valid options
    3. Update the `Ok(Join { ... })` return at line 693 to include `cardinality`:
       ```rust
       Ok(Join {
           table: to_alias.to_string(),
           from_alias: from_alias.to_string(),
           fk_columns,
           name: Some(rel_name.to_string()),
           cardinality,
           on: String::new(),
           from_cols: vec![],
           join_columns: vec![],
       })
       ```
    4. Add unit tests in `body_parser.rs` existing test module (after the relationship tests around line 1122):
       - `parse_relationship_with_many_to_one`: explicit MANY TO ONE
       - `parse_relationship_with_one_to_one`: ONE TO ONE
       - `parse_relationship_with_one_to_many`: ONE TO MANY
       - `parse_relationship_without_cardinality_defaults`: no cardinality -> ManyToOne
       - `parse_relationship_cardinality_case_insensitive`: lowercase, mixed case variants
       - `parse_relationship_invalid_cardinality_rejected`: `MANY TO MANY` or `FOO` after to_alias gives error
    5. Add proptest in `tests/parse_proptest.rs`:
       - Strategy generates valid relationship entries with random cardinality keyword presence, case variations, and whitespace variations
       - Property: parsed Join always has valid cardinality, to_alias is always correct (never includes cardinality keywords)
    6. Import `Cardinality` from `crate::model::Cardinality` in body_parser.rs.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test -- cardinality relationship</automated>
  </verify>
  <done>Parser accepts MANY TO ONE / ONE TO ONE / ONE TO MANY after REFERENCES, defaults to ManyToOne when omitted, rejects invalid cardinality keywords, case-insensitive, proptests pass</done>
</task>

</tasks>

<verification>
- `cargo test` passes (all unit tests + proptests)
- Cardinality enum serializes/deserializes correctly
- Old JSON without cardinality field still works (backward compat)
- Parser correctly separates to_alias from cardinality keywords
- Invalid cardinality values are rejected with clear error
</verification>

<success_criteria>
- Cardinality enum with ManyToOne (default), OneToOne, OneToMany exists on Join
- Parser accepts `rel AS a(fk) REFERENCES b MANY TO ONE` syntax
- Default cardinality is ManyToOne when omitted
- All existing tests still pass (no regressions)
- `cargo test` green
</success_criteria>

<output>
After completion, create `.planning/phases/31-fan-trap-detection/31-01-SUMMARY.md`
</output>
