Skip to main content

pgevolve_core/parse/builder/
create_range_stmt.rs

1//! Source-side parser for `CREATE TYPE … AS RANGE (…)`.
2//!
3//! Produces a [`UserType`] with `kind = UserTypeKind::Range { … }`.
4//! The `pg_query` AST for this statement is [`pg_query::protobuf::CreateRangeStmt`]
5//! with a `type_name: Vec<Node>` (String nodes) and a `params: Vec<Node>` list of
6//! `DefElem` entries — one per `option = value` clause.
7
8use pg_query::NodeEnum;
9use pg_query::protobuf::{CreateRangeStmt, DefElem, Node, TypeName};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::user_type::{UserType, UserTypeKind};
13use crate::parse::builder::shared;
14use crate::parse::error::{ParseError, SourceLocation};
15
16/// Build a [`UserType`] from a `CREATE TYPE … AS RANGE` AST node.
17///
18/// * `default_schema` — filled in when the source omits the schema prefix.
19/// * Requires `subtype = …`; rejects unknown option names with a clear
20///   error naming the bad key.
21/// * Accepts (all optional): `subtype_opclass`, `collation`, `canonical`,
22///   `subtype_diff`, `multirange_type_name`.
23#[allow(clippy::too_many_lines)] // option-list dispatch — splitting would scatter per-option decoding.
24pub(crate) fn build_range(
25    stmt: &CreateRangeStmt,
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    let mut subtype: Option<QualifiedName> = None;
32    let mut subtype_opclass: Option<QualifiedName> = None;
33    let mut collation: Option<QualifiedName> = None;
34    let mut canonical: Option<QualifiedName> = None;
35    let mut subtype_diff: Option<QualifiedName> = None;
36    let mut multirange_type_name: Option<Identifier> = None;
37
38    for node in &stmt.params {
39        let Some(NodeEnum::DefElem(de)) = node.node.as_ref() else {
40            return Err(ParseError::Structural {
41                location: location.clone(),
42                message: format!(
43                    "CREATE TYPE {qname} AS RANGE: unexpected non-DefElem in option list",
44                ),
45            });
46        };
47        match de.defname.as_str() {
48            "subtype" => {
49                if subtype.is_some() {
50                    return Err(ParseError::Structural {
51                        location: location.clone(),
52                        message: format!(
53                            "CREATE TYPE {qname} AS RANGE: duplicate `subtype` option",
54                        ),
55                    });
56                }
57                subtype = Some(qualified_name_from_defelem_typename(
58                    de,
59                    "subtype",
60                    &qname,
61                    default_schema,
62                    location,
63                )?);
64            }
65            "subtype_opclass" => {
66                subtype_opclass = Some(qualified_name_from_defelem_typename(
67                    de,
68                    "subtype_opclass",
69                    &qname,
70                    default_schema,
71                    location,
72                )?);
73            }
74            "collation" => {
75                collation = Some(qualified_name_from_defelem_typename(
76                    de,
77                    "collation",
78                    &qname,
79                    default_schema,
80                    location,
81                )?);
82            }
83            "canonical" => {
84                canonical = Some(qualified_name_from_defelem_typename(
85                    de,
86                    "canonical",
87                    &qname,
88                    default_schema,
89                    location,
90                )?);
91            }
92            "subtype_diff" => {
93                subtype_diff = Some(qualified_name_from_defelem_typename(
94                    de,
95                    "subtype_diff",
96                    &qname,
97                    default_schema,
98                    location,
99                )?);
100            }
101            "multirange_type_name" => {
102                // PG syntax allows `multirange_type_name = schema.name`. The IR
103                // stores the bare identifier; the schema (when present) is
104                // forced to match the range type's schema by PG itself, so we
105                // reject mismatches at parse time and store just the name.
106                let qn = qualified_name_from_defelem_typename(
107                    de,
108                    "multirange_type_name",
109                    &qname,
110                    default_schema,
111                    location,
112                )?;
113                if qn.schema != qname.schema {
114                    return Err(ParseError::Structural {
115                        location: location.clone(),
116                        message: format!(
117                            "CREATE TYPE {qname} AS RANGE: multirange_type_name must live in \
118                             the same schema as the range type ({})",
119                            qname.schema.as_str(),
120                        ),
121                    });
122                }
123                multirange_type_name = Some(qn.name);
124            }
125            other => {
126                return Err(ParseError::Structural {
127                    location: location.clone(),
128                    message: format!("CREATE TYPE {qname} AS RANGE: unknown option `{other}`"),
129                });
130            }
131        }
132    }
133
134    let subtype = subtype.ok_or_else(|| ParseError::Structural {
135        location: location.clone(),
136        message: format!("CREATE TYPE {qname} AS RANGE: missing required `subtype` option"),
137    })?;
138
139    Ok(UserType {
140        qname,
141        kind: UserTypeKind::Range {
142            subtype,
143            subtype_opclass,
144            collation,
145            canonical,
146            subtype_diff,
147            multirange_type_name,
148        },
149        comment: None,
150        owner: None,
151        grants: vec![],
152    })
153}
154
155/// Decode a `DefElem` whose argument is a `TypeName` (a list of String nodes)
156/// into a [`QualifiedName`].
157///
158/// `option_name` is for error messages; `range_qname` and `default_schema` are
159/// forwarded to provide context for unqualified references.
160fn qualified_name_from_defelem_typename(
161    de: &DefElem,
162    option_name: &str,
163    range_qname: &QualifiedName,
164    default_schema: Option<&Identifier>,
165    location: &SourceLocation,
166) -> Result<QualifiedName, ParseError> {
167    let arg_node =
168        de.arg.as_ref().ok_or_else(|| ParseError::Structural {
169            location: location.clone(),
170            message: format!(
171                "CREATE TYPE {range_qname} AS RANGE: option `{option_name}` has no value",
172            ),
173        })?;
174    let inner = arg_node
175        .node
176        .as_ref()
177        .ok_or_else(|| ParseError::Structural {
178            location: location.clone(),
179            message: format!(
180                "CREATE TYPE {range_qname} AS RANGE: option `{option_name}` value node empty",
181            ),
182        })?;
183    let typename: &TypeName = match inner {
184        NodeEnum::TypeName(t) => t,
185        _ => {
186            return Err(ParseError::Structural {
187                location: location.clone(),
188                message: format!(
189                    "CREATE TYPE {range_qname} AS RANGE: option `{option_name}` value must be a \
190                     type/function name, not {kind}",
191                    kind = node_kind_label(inner),
192                ),
193            });
194        }
195    };
196    qname_from_typename_strings(&typename.names, default_schema, location)
197}
198
199/// Decode a sequence of `String` nodes (a `TypeName.names` list) into a
200/// [`QualifiedName`]. Built-in names that arrive unqualified (e.g. `int4`)
201/// are placed in the `pg_catalog` schema, mirroring PG's own resolution.
202fn qname_from_typename_strings(
203    nodes: &[Node],
204    default_schema: Option<&Identifier>,
205    location: &SourceLocation,
206) -> Result<QualifiedName, ParseError> {
207    let parts: Vec<&str> = nodes
208        .iter()
209        .filter_map(|n| match n.node.as_ref() {
210            Some(NodeEnum::String(s)) => Some(s.sval.as_str()),
211            _ => None,
212        })
213        .collect();
214    match parts.as_slice() {
215        [name] => {
216            // Unqualified: route built-in scalar types to pg_catalog; everything
217            // else uses default_schema (or fails if missing).
218            if is_builtin_scalar(name) {
219                Ok(QualifiedName::new(
220                    shared::ident("pg_catalog", location)?,
221                    shared::ident(name, location)?,
222                ))
223            } else {
224                let schema =
225                    default_schema
226                        .cloned()
227                        .ok_or_else(|| ParseError::UnqualifiedName {
228                            location: location.clone(),
229                        })?;
230                Ok(QualifiedName::new(schema, shared::ident(name, location)?))
231            }
232        }
233        [schema, name] => Ok(QualifiedName::new(
234            shared::ident(schema, location)?,
235            shared::ident(name, location)?,
236        )),
237        _ => Err(ParseError::Structural {
238            location: location.clone(),
239            message: format!(
240                "expected 1 or 2 String nodes in qualified name, got {}",
241                nodes.len(),
242            ),
243        }),
244    }
245}
246
247/// PG built-in scalar type / opclass / collation names that are unambiguous
248/// at parse time. Used only to qualify them under `pg_catalog` so the dep
249/// graph knows they are not user-managed.
250fn is_builtin_scalar(name: &str) -> bool {
251    matches!(
252        name,
253        "int2"
254            | "int4"
255            | "int8"
256            | "smallint"
257            | "integer"
258            | "bigint"
259            | "numeric"
260            | "decimal"
261            | "real"
262            | "double precision"
263            | "float4"
264            | "float8"
265            | "text"
266            | "varchar"
267            | "char"
268            | "bpchar"
269            | "bytea"
270            | "date"
271            | "time"
272            | "timestamp"
273            | "timestamptz"
274            | "interval"
275            | "uuid"
276            | "bool"
277            | "boolean"
278            | "jsonb"
279            | "json"
280            | "int4_ops"
281            | "int8_ops"
282            | "text_ops"
283            | "timestamp_ops"
284            | "timestamptz_ops"
285            | "date_ops"
286            | "numeric_ops"
287            | "C"
288            | "POSIX"
289            | "default"
290    )
291}
292
293const fn node_kind_label(node: &NodeEnum) -> &'static str {
294    match node {
295        NodeEnum::String(_) => "String",
296        NodeEnum::TypeName(_) => "TypeName",
297        NodeEnum::Integer(_) => "Integer",
298        NodeEnum::Float(_) => "Float",
299        NodeEnum::AConst(_) => "AConst",
300        _ => "other",
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::path::PathBuf;
308
309    fn loc() -> SourceLocation {
310        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
311    }
312
313    fn parse_range(sql: &str) -> CreateRangeStmt {
314        let parsed = pg_query::parse(sql).expect("parses");
315        let node = parsed
316            .protobuf
317            .stmts
318            .into_iter()
319            .next()
320            .and_then(|r| r.stmt)
321            .and_then(|n| n.node)
322            .expect("stmt");
323        let NodeEnum::CreateRangeStmt(s) = node else {
324            panic!("expected CreateRangeStmt, got: {node:?}");
325        };
326        s
327    }
328
329    fn build(sql: &str) -> UserType {
330        let stmt = parse_range(sql);
331        build_range(&stmt, None, &loc()).expect("build_range")
332    }
333
334    fn try_build(sql: &str) -> Result<UserType, ParseError> {
335        let stmt = parse_range(sql);
336        build_range(&stmt, None, &loc())
337    }
338
339    #[test]
340    fn parse_range_with_subtype_only() {
341        let ut = build("CREATE TYPE app.tsrange_co AS RANGE (subtype = timestamptz);");
342        assert_eq!(ut.qname.to_string(), "app.tsrange_co");
343        let UserTypeKind::Range {
344            subtype,
345            subtype_opclass,
346            collation,
347            canonical,
348            subtype_diff,
349            multirange_type_name,
350        } = &ut.kind
351        else {
352            panic!("not range kind");
353        };
354        assert_eq!(subtype.to_string(), "pg_catalog.timestamptz");
355        assert!(subtype_opclass.is_none());
356        assert!(collation.is_none());
357        assert!(canonical.is_none());
358        assert!(subtype_diff.is_none());
359        assert!(multirange_type_name.is_none());
360    }
361
362    #[test]
363    fn parse_range_subtype_in_pg_catalog() {
364        let ut = build("CREATE TYPE app.r AS RANGE (subtype = int4);");
365        let UserTypeKind::Range { subtype, .. } = &ut.kind else {
366            panic!("not range");
367        };
368        assert_eq!(subtype.schema.as_str(), "pg_catalog");
369        assert_eq!(subtype.name.as_str(), "int4");
370    }
371
372    #[test]
373    fn parse_range_with_opclass_and_multirange_name() {
374        let ut = build(
375            "CREATE TYPE app.r AS RANGE (subtype = int4, subtype_opclass = int4_ops, multirange_type_name = app.r_mr);",
376        );
377        let UserTypeKind::Range {
378            subtype_opclass,
379            multirange_type_name,
380            ..
381        } = &ut.kind
382        else {
383            panic!("not range");
384        };
385        assert_eq!(
386            subtype_opclass.as_ref().map(ToString::to_string),
387            Some("pg_catalog.int4_ops".into())
388        );
389        assert_eq!(
390            multirange_type_name
391                .as_ref()
392                .map(|i| i.as_str().to_string()),
393            Some("r_mr".into()),
394        );
395    }
396
397    #[test]
398    fn parse_range_rejects_unknown_option() {
399        let err =
400            try_build("CREATE TYPE app.bad AS RANGE (subtype = int4, bogus = 1);").unwrap_err();
401        let msg = match &err {
402            ParseError::Structural { message, .. } => message.clone(),
403            other => panic!("expected Structural, got {other:?}"),
404        };
405        assert!(msg.contains("bogus"), "expected bad key in: {msg}");
406    }
407
408    #[test]
409    fn parse_range_rejects_missing_subtype() {
410        let err = try_build("CREATE TYPE app.bad AS RANGE (multirange_type_name = app.bad_mr);")
411            .unwrap_err();
412        let msg = match &err {
413            ParseError::Structural { message, .. } => message.clone(),
414            other => panic!("expected Structural, got {other:?}"),
415        };
416        assert!(msg.contains("subtype"), "expected subtype in: {msg}");
417    }
418
419    #[test]
420    fn parse_range_rejects_duplicate_subtype() {
421        let err = try_build("CREATE TYPE app.bad AS RANGE (subtype = int4, subtype = int8);")
422            .unwrap_err();
423        assert!(matches!(err, ParseError::Structural { .. }));
424    }
425
426    #[test]
427    fn parse_range_rejects_multirange_in_different_schema() {
428        let err = try_build(
429            "CREATE TYPE app.r AS RANGE (subtype = int4, multirange_type_name = other.r_mr);",
430        )
431        .unwrap_err();
432        assert!(matches!(err, ParseError::Structural { .. }));
433    }
434
435    #[test]
436    fn parse_range_unqualified_with_default_schema() {
437        let stmt = parse_range("CREATE TYPE r AS RANGE (subtype = int4);");
438        let app = Identifier::from_unquoted("app").unwrap();
439        let ut = build_range(&stmt, Some(&app), &loc()).unwrap();
440        assert_eq!(ut.qname.to_string(), "app.r");
441    }
442
443    #[test]
444    fn parse_range_unqualified_without_schema_errors() {
445        let stmt = parse_range("CREATE TYPE r AS RANGE (subtype = int4);");
446        let err = build_range(&stmt, None, &loc()).unwrap_err();
447        assert!(matches!(err, ParseError::UnqualifiedName { .. }));
448    }
449}