Skip to main content

cratestack_core/schema/
index_attribute.rs

1//! Parsing for the model-level `@@index([...], using: ..., opclass: "...")`
2//! attribute (cratestack#156) — a general secondary index, optionally
3//! naming a non-default Postgres access method (e.g. `ivfflat`/`hnsw` for
4//! pgvector approximate-nearest-neighbor search) and operator class.
5//!
6//! Shares its bracketed-field-list syntax with `@@id([...])`/
7//! `@@unique([...])` (see [`super::field_list`]), but unlike those,
8//! `@@index(...)` also accepts trailing `using:`/`opclass:` keyword
9//! arguments after the field list — the same shape
10//! `@relation(fields:[...], references:[...])` already uses
11//! (`cratestack-parser::relation_helpers::parse_relation_attribute`),
12//! reimplemented here rather than shared because that helper is private to
13//! the parser crate and this one also needs to be callable from
14//! `cratestack-migrate` (via `crate::schema::project_model`, one layer
15//! below the parser).
16
17#[cfg(test)]
18mod tests;
19
20use super::field_list::is_valid_field_name;
21
22/// The parsed shape of an `@@index([...], using: ..., opclass: "...")`
23/// attribute.
24///
25/// `using`/`opclass` are carried through verbatim — not validated against
26/// a closed list of Postgres access methods/operator classes. Per
27/// `docs/design/extensions.md` §2/§6, the framework deliberately avoids
28/// hardcoding pgvector's own index types as the only supported
29/// similarity-search backend, so any syntactically valid identifier is
30/// accepted here and left for Postgres itself to accept or reject at
31/// `CREATE INDEX` time.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ParsedIndexAttribute {
34    /// Local field names, in declaration order — the index's column
35    /// order.
36    pub fields: Vec<String>,
37    /// `using: <method>` — e.g. `ivfflat`, `hnsw`, `gin`. `None` means no
38    /// access method was named, which renders as a plain `CREATE INDEX`
39    /// with Postgres's own default (`btree`) left implicit.
40    pub using: Option<String>,
41    /// `opclass: "<name>"` — applied to every column listed in `fields`.
42    /// `None` leaves each column's default operator class in place.
43    pub opclass: Option<String>,
44}
45
46/// Parses `@@index([field1, field2, ...])`, optionally followed by
47/// `using: <method>` and/or `opclass: "<name>"`. Callers are responsible
48/// for checking that each field name resolves to a real scalar field on
49/// the model.
50pub fn parse_index_attribute(raw: &str) -> Result<ParsedIndexAttribute, String> {
51    let inner = raw
52        .strip_prefix("@@index(")
53        .and_then(|value| value.strip_suffix(')'))
54        .ok_or_else(|| format!("unsupported index attribute `{raw}`"))?;
55
56    let mut entries = split_top_level_commas(inner);
57    if entries.is_empty() {
58        return Err(format!(
59            "index attribute `{raw}` must list fields as `@@index([field1, field2])`"
60        ));
61    }
62    let fields = parse_bracketed_field_list(raw, entries.remove(0))?;
63    if fields.is_empty() {
64        return Err(format!(
65            "index attribute `{raw}` must list at least one field"
66        ));
67    }
68
69    let mut using = None;
70    let mut opclass = None;
71    for entry in entries {
72        let (key, value) = entry
73            .split_once(':')
74            .ok_or_else(|| format!("index attribute `{raw}` has invalid entry `{entry}`"))?;
75        let value = value.trim();
76        match key.trim() {
77            "using" if using.is_none() => using = Some(parse_using_value(raw, value)?),
78            "using" => {
79                return Err(format!(
80                    "index attribute `{raw}` declares `using` more than once"
81                ));
82            }
83            "opclass" if opclass.is_none() => opclass = Some(parse_opclass_value(raw, value)?),
84            "opclass" => {
85                return Err(format!(
86                    "index attribute `{raw}` declares `opclass` more than once"
87                ));
88            }
89            other => {
90                return Err(format!(
91                    "index attribute `{raw}` has unsupported key `{other}`; expected `using` or `opclass`"
92                ));
93            }
94        }
95    }
96
97    Ok(ParsedIndexAttribute {
98        fields,
99        using,
100        opclass,
101    })
102}
103
104fn parse_bracketed_field_list(raw: &str, entry: &str) -> Result<Vec<String>, String> {
105    let list = entry
106        .strip_prefix('[')
107        .and_then(|value| value.strip_suffix(']'))
108        .ok_or_else(|| {
109            format!("index attribute `{raw}` must list fields as `@@index([field1, field2])`")
110        })?;
111
112    let mut fields = Vec::new();
113    for part in list.split(',').map(str::trim) {
114        if part.is_empty() {
115            continue;
116        }
117        if !is_valid_field_name(part) {
118            return Err(format!(
119                "index attribute `{raw}` lists invalid field name `{part}`"
120            ));
121        }
122        if fields.contains(&part.to_owned()) {
123            return Err(format!(
124                "index attribute `{raw}` lists field `{part}` more than once"
125            ));
126        }
127        fields.push(part.to_owned());
128    }
129    Ok(fields)
130}
131
132fn parse_using_value(raw: &str, value: &str) -> Result<String, String> {
133    if !is_valid_identifier(value) {
134        return Err(format!(
135            "index attribute `{raw}` has invalid `using` value `{value}`; expected a bare \
136             access method name like `ivfflat`"
137        ));
138    }
139    Ok(value.to_owned())
140}
141
142fn parse_opclass_value(raw: &str, value: &str) -> Result<String, String> {
143    let inner = value
144        .strip_prefix('"')
145        .and_then(|value| value.strip_suffix('"'))
146        .ok_or_else(|| {
147            format!(
148                "index attribute `{raw}` has invalid `opclass` value `{value}`; expected a \
149                 quoted string like `\"vector_l2_ops\"`"
150            )
151        })?;
152    if !is_valid_identifier(inner) {
153        return Err(format!(
154            "index attribute `{raw}` has invalid `opclass` value `\"{inner}\"`"
155        ));
156    }
157    Ok(inner.to_owned())
158}
159
160fn is_valid_identifier(value: &str) -> bool {
161    let mut chars = value.chars();
162    matches!(chars.next(), Some(first) if first.is_ascii_alphabetic() || first == '_')
163        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
164}
165
166/// Splits `input` on top-level commas — ones not nested inside `[...]` —
167/// so the bracketed field list's own internal commas aren't mistaken for
168/// separators between it and the trailing `using:`/`opclass:` entries.
169/// Mirrors `cratestack-parser::relation_helpers::split_top_level`
170/// (private to that crate; small enough to not be worth sharing across
171/// the crate boundary).
172fn split_top_level_commas(input: &str) -> Vec<&str> {
173    let mut entries = Vec::new();
174    let mut depth = 0usize;
175    let mut start = 0usize;
176    for (index, ch) in input.char_indices() {
177        match ch {
178            '[' | '(' => depth += 1,
179            ']' | ')' => depth = depth.saturating_sub(1),
180            ',' if depth == 0 => {
181                entries.push(input[start..index].trim());
182                start = index + ch.len_utf8();
183            }
184            _ => {}
185        }
186    }
187    let tail = input[start..].trim();
188    if !tail.is_empty() {
189        entries.push(tail);
190    }
191    entries
192        .into_iter()
193        .filter(|entry| !entry.is_empty())
194        .collect()
195}