Skip to main content

pgevolve_core/parse/builder/
create_enum_stmt.rs

1//! Source-side parser for `CREATE TYPE x AS ENUM (...)`.
2//!
3//! Produces a [`UserType`] with `kind = UserTypeKind::Enum { values }`.
4//! Enum values receive `sort_order` 1.0, 2.0, 3.0, … matching PG's
5//! `pg_enum.enumsortorder` semantics.
6
7use std::collections::BTreeSet;
8
9use pg_query::NodeEnum;
10use pg_query::protobuf::CreateEnumStmt;
11
12use crate::identifier::Identifier;
13use crate::ir::user_type::{EnumValue, UserType, UserTypeKind};
14use crate::parse::builder::shared;
15use crate::parse::error::{ParseError, SourceLocation};
16
17/// Build a [`UserType`] with `kind = Enum` from a `CREATE TYPE … AS ENUM`
18/// AST node.
19///
20/// * `default_schema` — filled in when the source omits the schema prefix
21///   (e.g. a `-- @pgevolve schema=app` directive is in effect).
22/// * Duplicate labels within the same enum are rejected (PG does the same).
23/// * An empty value list is rejected.
24pub(crate) fn build_enum(
25    stmt: &CreateEnumStmt,
26    default_schema: Option<&Identifier>,
27    location: &SourceLocation,
28) -> Result<UserType, ParseError> {
29    let qname = shared::qname_from_string_list(&stmt.type_name, default_schema, location)?;
30
31    if stmt.vals.is_empty() {
32        return Err(ParseError::Structural {
33            location: location.clone(),
34            message: format!("CREATE TYPE {qname} AS ENUM requires at least one value"),
35        });
36    }
37
38    let mut values: Vec<EnumValue> = Vec::with_capacity(stmt.vals.len());
39    let mut seen: BTreeSet<String> = BTreeSet::new();
40
41    for (idx, node) in stmt.vals.iter().enumerate() {
42        let label = string_val_from_node(node, location)?;
43        if !seen.insert(label.clone()) {
44            return Err(ParseError::Structural {
45                location: location.clone(),
46                message: format!("CREATE TYPE {qname} AS ENUM: duplicate label {label:?}"),
47            });
48        }
49        #[allow(clippy::cast_precision_loss)]
50        let sort_order = (idx as f32) + 1.0;
51        values.push(EnumValue {
52            name: label,
53            sort_order,
54        });
55    }
56
57    Ok(UserType {
58        qname,
59        kind: UserTypeKind::Enum { values },
60        comment: None,
61        owner: None,
62        grants: vec![],
63    })
64}
65
66/// Extract a string value from a node that must be `NodeEnum::String`.
67fn string_val_from_node(
68    node: &pg_query::protobuf::Node,
69    location: &SourceLocation,
70) -> Result<String, ParseError> {
71    match node.node.as_ref() {
72        Some(NodeEnum::String(s)) if !s.sval.is_empty() => Ok(s.sval.clone()),
73        Some(NodeEnum::AConst(c)) => {
74            // pg_query sometimes wraps enum labels in AConst Sval nodes.
75            use pg_query::protobuf::a_const;
76            match c.val.as_ref() {
77                Some(a_const::Val::Sval(s)) if !s.sval.is_empty() => Ok(s.sval.clone()),
78                _ => Err(ParseError::Structural {
79                    location: location.clone(),
80                    message: "expected non-empty string AConst for enum label".into(),
81                }),
82            }
83        }
84        _ => Err(ParseError::Structural {
85            location: location.clone(),
86            message: "expected string node for enum label".into(),
87        }),
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::path::PathBuf;
95
96    fn loc() -> SourceLocation {
97        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
98    }
99
100    fn parse_enum(sql: &str) -> CreateEnumStmt {
101        let parsed = pg_query::parse(sql).expect("parses");
102        let node = parsed
103            .protobuf
104            .stmts
105            .into_iter()
106            .next()
107            .and_then(|r| r.stmt)
108            .and_then(|n| n.node)
109            .expect("stmt");
110        let NodeEnum::CreateEnumStmt(s) = node else {
111            panic!("expected CreateEnumStmt, got: {node:?}");
112        };
113        s
114    }
115
116    fn build(sql: &str) -> UserType {
117        let stmt = parse_enum(sql);
118        build_enum(&stmt, None, &loc()).expect("build_enum")
119    }
120
121    #[test]
122    fn qualified_enum_three_values() {
123        let ut = build("CREATE TYPE app.order_status AS ENUM ('pending', 'shipped', 'delivered');");
124        assert_eq!(ut.qname.to_string(), "app.order_status");
125        let crate::ir::user_type::UserTypeKind::Enum { values } = ut.kind else {
126            panic!("not enum kind");
127        };
128        assert_eq!(values.len(), 3);
129        assert_eq!(values[0].name, "pending");
130        assert_eq!(values[0].sort_order.to_bits(), 1.0_f32.to_bits());
131        assert_eq!(values[1].name, "shipped");
132        assert_eq!(values[1].sort_order.to_bits(), 2.0_f32.to_bits());
133        assert_eq!(values[2].name, "delivered");
134        assert_eq!(values[2].sort_order.to_bits(), 3.0_f32.to_bits());
135        assert!(ut.comment.is_none());
136    }
137
138    #[test]
139    fn unqualified_enum_uses_default_schema() {
140        let stmt = parse_enum("CREATE TYPE order_status AS ENUM ('a');");
141        let app = Identifier::from_unquoted("app").unwrap();
142        let ut = build_enum(&stmt, Some(&app), &loc()).unwrap();
143        assert_eq!(ut.qname.to_string(), "app.order_status");
144    }
145
146    #[test]
147    fn unqualified_without_schema_errors() {
148        let stmt = parse_enum("CREATE TYPE order_status AS ENUM ('a');");
149        let err = build_enum(&stmt, None, &loc()).unwrap_err();
150        assert!(matches!(err, ParseError::UnqualifiedName { .. }));
151    }
152
153    #[test]
154    fn single_value_enum() {
155        let ut = build("CREATE TYPE app.singleton AS ENUM ('only');");
156        let crate::ir::user_type::UserTypeKind::Enum { values } = ut.kind else {
157            panic!("not enum kind");
158        };
159        assert_eq!(values.len(), 1);
160        assert_eq!(values[0].name, "only");
161        assert_eq!(values[0].sort_order.to_bits(), 1.0_f32.to_bits());
162    }
163
164    #[test]
165    fn duplicate_label_rejected() {
166        let stmt = parse_enum("CREATE TYPE app.bad AS ENUM ('x', 'y', 'x');");
167        let err = build_enum(&stmt, None, &loc()).unwrap_err();
168        let msg = match &err {
169            ParseError::Structural { message, .. } => message.clone(),
170            other => panic!("expected Structural, got {other:?}"),
171        };
172        assert!(msg.contains("duplicate"), "expected duplicate in: {msg}");
173        assert!(msg.contains("\"x\""), "expected label in: {msg}");
174    }
175
176    #[test]
177    fn empty_enum_rejected() {
178        // pg_query itself rejects empty ENUM lists at parse time, so this
179        // tests that our empty-check path is defensive. We simulate by
180        // constructing a synthetic statement.
181        let synthetic = CreateEnumStmt {
182            type_name: {
183                use pg_query::protobuf::{Node, String as PgString};
184                vec![
185                    Node {
186                        node: Some(NodeEnum::String(PgString { sval: "app".into() })),
187                    },
188                    Node {
189                        node: Some(NodeEnum::String(PgString {
190                            sval: "myenum".into(),
191                        })),
192                    },
193                ]
194            },
195            vals: vec![],
196        };
197        let err = build_enum(&synthetic, None, &loc()).unwrap_err();
198        let msg = match &err {
199            ParseError::Structural { message, .. } => message.clone(),
200            other => panic!("expected Structural, got {other:?}"),
201        };
202        assert!(msg.contains("at least one value"), "unexpected: {msg}");
203    }
204}