Skip to main content

cratestack_core/schema/
composite_key.rs

1//! Parsing for the model-level `@@id([...])` composite-primary-key
2//! attribute (Prisma's spelling). Mirrors [`crate::events::parse_emit_attribute`]'s
3//! shape: syntax parsing lives here in `cratestack-core` so both the
4//! parser's semantic checker and any other consumer share one
5//! implementation.
6
7/// Parses `@@id([field1, field2, ...])` into its ordered list of local
8/// field names. Callers are responsible for checking that each name
9/// resolves to a real scalar field on the model.
10pub fn parse_composite_id_attribute(raw: &str) -> Result<Vec<String>, String> {
11    let Some(inner) = raw
12        .strip_prefix("@@id(")
13        .and_then(|value| value.strip_suffix(')'))
14    else {
15        return Err(format!("unsupported composite id attribute `{raw}`"));
16    };
17
18    let Some(list) = inner
19        .trim()
20        .strip_prefix('[')
21        .and_then(|value| value.strip_suffix(']'))
22    else {
23        return Err(format!(
24            "composite id attribute `{raw}` must list fields as `@@id([field1, field2])`"
25        ));
26    };
27
28    let mut fields = Vec::new();
29    for part in list.split(',').map(str::trim) {
30        if part.is_empty() {
31            continue;
32        }
33        if !is_valid_field_name(part) {
34            return Err(format!(
35                "composite id attribute `{raw}` lists invalid field name `{part}`"
36            ));
37        }
38        if fields.contains(&part.to_owned()) {
39            return Err(format!(
40                "composite id attribute `{raw}` lists field `{part}` more than once"
41            ));
42        }
43        fields.push(part.to_owned());
44    }
45
46    if fields.len() < 2 {
47        return Err(format!(
48            "composite id attribute `{raw}` must list at least two fields; use a single-field `@id` instead"
49        ));
50    }
51
52    Ok(fields)
53}
54
55fn is_valid_field_name(value: &str) -> bool {
56    let mut chars = value.chars();
57    matches!(chars.next(), Some(first) if first.is_ascii_alphabetic() || first == '_')
58        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
59}
60
61#[cfg(test)]
62mod tests {
63    use super::parse_composite_id_attribute;
64
65    #[test]
66    fn parses_two_fields() {
67        let fields = parse_composite_id_attribute("@@id([accountId, subject])").unwrap();
68        assert_eq!(fields, vec!["accountId".to_string(), "subject".to_string()]);
69    }
70
71    #[test]
72    fn rejects_missing_brackets() {
73        let error = parse_composite_id_attribute("@@id(accountId, subject)").unwrap_err();
74        assert!(error.contains("must list fields as"));
75    }
76
77    #[test]
78    fn rejects_single_field() {
79        let error = parse_composite_id_attribute("@@id([accountId])").unwrap_err();
80        assert!(error.contains("at least two fields"));
81    }
82
83    #[test]
84    fn rejects_duplicate_field() {
85        let error = parse_composite_id_attribute("@@id([accountId, accountId])").unwrap_err();
86        assert!(error.contains("more than once"));
87    }
88
89    #[test]
90    fn rejects_invalid_identifier() {
91        let error = parse_composite_id_attribute("@@id([account-id, subject])").unwrap_err();
92        assert!(error.contains("invalid field name"));
93    }
94}