Skip to main content

pgevolve_core/parse/builder/
shared.rs

1//! Helpers shared by all AST → IR builders.
2//!
3//! - Schema-qualified name resolution (with `-- @pgevolve schema=` defaulting).
4//! - Type-name stringification (so [`crate::ir::column_type::ColumnType::parse_from_pg_type_string`]
5//!   can decide the canonical form).
6//! - Default-expression classification (literal vs. `nextval` vs. arbitrary expr).
7
8use pg_query::NodeEnum;
9use pg_query::protobuf::{AConst, ColumnRef, DefElem, Node, RangeVar, TypeName, a_const};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::column_type::ColumnType;
13use crate::ir::default_expr::{DefaultExpr, LiteralValue};
14use crate::parse::error::{ParseError, SourceLocation};
15use crate::parse::normalize_expr;
16
17/// Resolve a `RangeVar` into a [`QualifiedName`], filling in `default_schema`
18/// when the source did not qualify the object.
19///
20/// Returns [`ParseError::UnqualifiedName`] if neither the source nor the
21/// directive supply a schema.
22pub fn resolve_qname(
23    range_var: &RangeVar,
24    default_schema: Option<&Identifier>,
25    location: &SourceLocation,
26) -> Result<QualifiedName, ParseError> {
27    let name = ident(&range_var.relname, location)?;
28    if !range_var.schemaname.is_empty() {
29        let schema = ident(&range_var.schemaname, location)?;
30        return Ok(QualifiedName::new(schema, name));
31    }
32    default_schema.map_or_else(
33        || {
34            Err(ParseError::UnqualifiedName {
35                location: location.clone(),
36            })
37        },
38        |s| Ok(QualifiedName::new(s.clone(), name)),
39    )
40}
41
42/// Resolve a `repeated Node` type-name list (as produced by `CreateEnumStmt`,
43/// `CreateDomainStmt`, `CompositeTypeStmt`, etc.) into a [`QualifiedName`].
44///
45/// PG encodes the name as a list of [`pg_query::NodeEnum::String`] nodes:
46/// - one element → unqualified name (requires `default_schema`).
47/// - two elements → `[schema, name]`.
48///
49/// Returns [`ParseError::UnqualifiedName`] if neither the source nor the
50/// directive supply a schema.
51///
52/// This helper is shared by the enum, domain, and composite builders (T2–T4).
53pub fn qname_from_string_list(
54    nodes: &[pg_query::protobuf::Node],
55    default_schema: Option<&Identifier>,
56    location: &SourceLocation,
57) -> Result<QualifiedName, ParseError> {
58    let strings: Vec<&str> = nodes
59        .iter()
60        .map(|n| match n.node.as_ref() {
61            Some(NodeEnum::String(s)) => Ok(s.sval.as_str()),
62            other => Err(ParseError::Structural {
63                location: location.clone(),
64                message: format!(
65                    "expected String node in type-name list, got {:?}",
66                    other.map(std::mem::discriminant),
67                ),
68            }),
69        })
70        .collect::<Result<Vec<_>, _>>()?;
71    match strings.as_slice() {
72        [name] => {
73            let name_id = ident(name, location)?;
74            let schema = default_schema
75                .cloned()
76                .ok_or_else(|| ParseError::UnqualifiedName {
77                    location: location.clone(),
78                })?;
79            Ok(QualifiedName::new(schema, name_id))
80        }
81        [schema, name] => {
82            let schema_id = ident(schema, location)?;
83            let name_id = ident(name, location)?;
84            Ok(QualifiedName::new(schema_id, name_id))
85        }
86        _ => Err(ParseError::Structural {
87            location: location.clone(),
88            message: format!(
89                "unexpected type-name list length {} (expected 1 or 2 String nodes)",
90                nodes.len()
91            ),
92        }),
93    }
94}
95
96/// Extract the string value of a bare `String` [`Node`].
97///
98/// Returns `Some(sval)` when `node` is a [`NodeEnum::String`], otherwise `None`.
99/// Used where `pg_query` encodes a list element (publication name, column name)
100/// directly as a `String` node rather than wrapping it in a `DefElem`.
101pub fn node_string_value(node: &Node) -> Option<String> {
102    match node.node.as_ref()? {
103        NodeEnum::String(s) => Some(s.sval.clone()),
104        _ => None,
105    }
106}
107
108/// Extract a string value from a `DefElem.arg`.
109///
110/// Handles the encodings `pg_query` uses for scalar option values:
111/// - [`NodeEnum::String`] — bare identifier or quoted string.
112/// - [`NodeEnum::Integer`] / [`NodeEnum::Float`] — numeric option values,
113///   stringified to their textual form.
114/// - [`NodeEnum::List`] — dollar-quoted bodies are wrapped in a `List` whose
115///   first item is the body `String` (the optional second item is the
116///   dollar-quote tag); this returns that first `String`'s value.
117///
118/// Returns `None` for an absent arg or any other node kind.
119pub fn def_elem_string(de: &DefElem) -> Option<String> {
120    let arg = de.arg.as_ref()?;
121    match arg.node.as_ref()? {
122        NodeEnum::String(s) => Some(s.sval.clone()),
123        NodeEnum::Integer(i) => Some(i.ival.to_string()),
124        NodeEnum::Float(f) => Some(f.fval.clone()),
125        NodeEnum::List(list) => list
126            .items
127            .first()
128            .and_then(|n| n.node.as_ref())
129            .and_then(|n| {
130                if let NodeEnum::String(s) = n {
131                    Some(s.sval.clone())
132                } else {
133                    None
134                }
135            }),
136        _ => None,
137    }
138}
139
140/// Build an unquoted [`Identifier`], wrapping [`crate::ir::IrError`] into a
141/// source-located [`ParseError::Ir`].
142pub fn ident(s: &str, location: &SourceLocation) -> Result<Identifier, ParseError> {
143    Identifier::from_unquoted(s).map_err(|e| ParseError::Ir {
144        location: location.clone(),
145        source: crate::ir::IrError::InvalidIdentifier(e.to_string()),
146    })
147}
148
149/// Render a `pg_query::TypeName` into a string `ColumnType::parse_from_pg_type_string`
150/// can consume.
151///
152/// Strategy: take the *last* segment of `names` (Postgres prefixes types with
153/// `pg_catalog.` internally; the parser is alias-aware), append a parenthesized
154/// list of typmod arguments, append `[]` for each array dimension.
155///
156/// **Special case — `interval`**: `pg_query` encodes interval typmods as
157/// `[fields_bitmask, precision?]` where the fields bitmask is the PG
158/// `INTERVAL_MASK` value from `datetime.h`.  Blindly joining them produces
159/// `"interval(32767,6)"` which `parse_canonical` cannot round-trip back to a
160/// typed `ColumnType::Interval`.  We decode the bitmask here and emit the same
161/// canonical string that `pg_catalog.format_type` produces so both paths
162/// converge. (issue #41)
163pub fn render_type_name_to_string(type_name: &TypeName) -> Option<String> {
164    let last = type_name.names.last()?.node.as_ref()?;
165    let NodeEnum::String(s) = last else {
166        return None;
167    };
168    let bare = s.sval.as_str();
169
170    // Interval gets special typmod handling — see module doc above.
171    let mut out = if bare == "interval" && !type_name.typmods.is_empty() {
172        render_interval_type_name(type_name)?
173    } else {
174        let mut o = bare.to_string();
175        if !type_name.typmods.is_empty() {
176            let mut args: Vec<String> = Vec::with_capacity(type_name.typmods.len());
177            for n in &type_name.typmods {
178                args.push(typmod_arg_to_string(n.node.as_ref()?)?);
179            }
180            o = format!("{o}({})", args.join(","));
181        }
182        o
183    };
184
185    for _ in 0..type_name.array_bounds.len() {
186        out.push_str("[]");
187    }
188    Some(out)
189}
190
191/// Decode the `pg_query` interval typmod encoding into a canonical string that
192/// `ColumnType::parse_from_pg_type_string` can round-trip.
193///
194/// `pg_query` represents interval typmods as:
195/// - One arg: `[fields_bitmask]` — fields restriction only (e.g. `interval hour to minute`).
196/// - Two args: `[fields_bitmask, precision]` — precision (and maybe fields).
197///
198/// The fields bitmask is the `INTERVAL_MASK` value from PG's `datetime.h`.
199/// `INTERVAL_FULL_RANGE = 0x7FFF = 32767` means "no restriction".
200///
201/// Returns `None` if the typmod nodes cannot be decoded; the caller will then
202/// produce `None` overall, which surfaces as a `Structural` parse error — no
203/// worse than before this fix.
204fn render_interval_type_name(type_name: &TypeName) -> Option<String> {
205    // Extract integer values from the AConst typmod nodes.
206    let mut int_args: Vec<i32> = Vec::with_capacity(2);
207    for n in &type_name.typmods {
208        match n.node.as_ref()? {
209            NodeEnum::AConst(c) => match c.val.as_ref()? {
210                a_const::Val::Ival(i) => int_args.push(i.ival),
211                _ => return None, // unexpected non-integer typmod for interval
212            },
213            _ => return None, // unexpected node kind (e.g. ColumnRef) for interval
214        }
215    }
216
217    let (fields_mask, precision): (i32, Option<u8>) = match int_args.as_slice() {
218        [mask] => (*mask, None),
219        [mask, prec] => {
220            let p = u8::try_from(*prec).ok()?;
221            (*mask, Some(p))
222        }
223        _ => return None,
224    };
225
226    let fields_str = interval_fields_from_mask(fields_mask);
227
228    // Build canonical string matching `format_type` output.
229    Some(match (fields_str, precision) {
230        (None, None) => "interval".to_string(),
231        (None, Some(p)) => format!("interval({p})"),
232        (Some(f), None) => format!("interval {f}"),
233        (Some(f), Some(p)) => format!("interval {f}({p})"),
234    })
235}
236
237/// Map a PG `INTERVAL_MASK` bitmask to the canonical lowercase fields qualifier
238/// that `pg_catalog.format_type` emits, or `None` for the full-range sentinel
239/// (`INTERVAL_FULL_RANGE = 32767 = 0x7FFF`).
240///
241/// Bit positions from PG `src/include/utils/datetime.h`:
242/// `INTERVAL_MASK(b) = 1 << b`, with YEAR=2, MONTH=1, DAY=3, HOUR=10, MINUTE=11,
243/// SECOND=12.
244///
245/// Unrecognized bitmasks fall back to `None` so behavior is no worse than
246/// the pre-fix state (type degrades to `Other` rather than panicking).
247const fn interval_fields_from_mask(mask: i32) -> Option<&'static str> {
248    // INTERVAL_FULL_RANGE — no fields restriction.
249    if mask == 0x7FFF {
250        return None;
251    }
252    // Individual fields and all recognized combinations, ordered to match the
253    // canonical `format_type` output.
254    match mask {
255        // Single fields: YEAR=1<<2, MONTH=1<<1, DAY=1<<3, HOUR=1<<10, MINUTE=1<<11, SECOND=1<<12
256        4 => Some("year"),
257        2 => Some("month"),
258        8 => Some("day"),
259        1024 => Some("hour"),
260        2048 => Some("minute"),
261        4096 => Some("second"),
262        // Ranges
263        6 => Some("year to month"),       // YEAR|MONTH = 4|2
264        1032 => Some("day to hour"),      // DAY|HOUR = 8|1024
265        3080 => Some("day to minute"),    // DAY|HOUR|MINUTE = 8|1024|2048
266        7176 => Some("day to second"),    // DAY|HOUR|MINUTE|SECOND = 8|1024|2048|4096
267        3072 => Some("hour to minute"),   // HOUR|MINUTE = 1024|2048
268        7168 => Some("hour to second"),   // HOUR|MINUTE|SECOND = 1024|2048|4096
269        6144 => Some("minute to second"), // MINUTE|SECOND = 2048|4096
270        // Unrecognized — fall back; no worse than pre-fix behaviour.
271        _ => None,
272    }
273}
274
275/// Stringify a single type-modifier argument node.
276///
277/// Type modifiers are an `expr_list`, so an argument is either a literal
278/// ([`AConst`]) or a bareword. The bareword form is what `PostGIS` uses for the
279/// subtype in `geometry(Point,4326)` — Postgres parses it as a single-field
280/// [`ColumnRef`], not a constant.
281fn typmod_arg_to_string(node: &NodeEnum) -> Option<String> {
282    match node {
283        NodeEnum::AConst(c) => literal_arg_to_string(c),
284        NodeEnum::ColumnRef(cref) => columnref_ident(cref),
285        _ => None,
286    }
287}
288
289/// Convert an [`AConst`] used as a typmod argument to its canonical string form.
290fn literal_arg_to_string(c: &AConst) -> Option<String> {
291    match c.val.as_ref()? {
292        a_const::Val::Ival(i) => Some(i.ival.to_string()),
293        a_const::Val::Sval(s) => Some(s.sval.clone()),
294        _ => None,
295    }
296}
297
298/// Extract the identifier text of a single-field bareword [`ColumnRef`].
299///
300/// A multi-field ref (`a.b`) is not a valid type-modifier shape, so it is
301/// rejected (returns `None`).
302fn columnref_ident(cref: &ColumnRef) -> Option<String> {
303    let [field] = cref.fields.as_slice() else {
304        return None;
305    };
306    match field.node.as_ref()? {
307        NodeEnum::String(s) => Some(s.sval.clone()),
308        _ => None,
309    }
310}
311
312/// Convert a `TypeName` into a [`ColumnType`], propagating parser errors.
313///
314/// When `TypeName.names` contains two String nodes and the first is not
315/// `pg_catalog`, the type is schema-qualified by the user (e.g. `app.order_status`).
316/// In that case we emit `ColumnType::UserDefined(QualifiedName)` so that the
317/// AST resolution pass can validate that the referenced type is declared in source.
318///
319/// Unqualified single-segment names are handled by the usual string path; if they
320/// don't match any built-in they fall through to `ColumnType::Other`, which is
321/// intentional — unqualified user types must be resolved via `default_schema` at
322/// domain/composite parse time rather than here.
323pub fn type_name_to_column_type(
324    type_name: &TypeName,
325    location: &SourceLocation,
326) -> Result<ColumnType, ParseError> {
327    // Collect String nodes from names; fail fast on unexpected kinds.
328    let name_strings: Vec<&str> = type_name
329        .names
330        .iter()
331        .map(|n| match n.node.as_ref() {
332            Some(NodeEnum::String(s)) => Ok(s.sval.as_str()),
333            other => Err(ParseError::Structural {
334                location: location.clone(),
335                message: format!(
336                    "expected String node in type-name list, got {:?}",
337                    other.map(std::mem::discriminant),
338                ),
339            }),
340        })
341        .collect::<Result<Vec<_>, _>>()?;
342
343    // Two-segment, non-pg_catalog prefix → user-defined type reference.
344    //
345    // Special case: PostGIS `geometry`/`geography` with typmods (issue #42).
346    // `public.geometry(Point,4326)` must become `Other { raw: "public.geometry(point,4326)" }`
347    // so it converges with the catalog path, which emits the same schema-qualified string.
348    // For all other schema-qualified types — including non-geo UDTs — keep the
349    // existing `UserDefined` return (no behavior change).
350    if let [schema, name] = name_strings.as_slice()
351        && *schema != "pg_catalog"
352    {
353        let name_lower = name.to_ascii_lowercase();
354        if (name_lower == "geometry" || name_lower == "geography") && !type_name.typmods.is_empty()
355        {
356            // Build `schema.name(arg,arg,…)` and route through the canonical
357            // parse path so casing is normalised (subtype barewords are already
358            // lowercased by pg_query) and `Other` equality holds.
359            let mut args: Vec<String> = Vec::with_capacity(type_name.typmods.len());
360            for n in &type_name.typmods {
361                let arg = typmod_arg_to_string(n.node.as_ref().ok_or_else(|| {
362                    ParseError::Structural {
363                        location: location.clone(),
364                        message: "missing node in typmod list".into(),
365                    }
366                })?)
367                .ok_or_else(|| ParseError::Structural {
368                    location: location.clone(),
369                    message: "could not stringify typmod argument".into(),
370                })?;
371                args.push(arg);
372            }
373            let s = format!("{schema}.{name_lower}({})", args.join(","));
374            return ColumnType::parse_from_pg_type_string(&s).map_err(|e| ParseError::Structural {
375                location: location.clone(),
376                message: format!("invalid column type {s:?}: {e}"),
377            });
378        }
379
380        let schema_id = ident(schema, location)?;
381        let name_id = ident(name, location)?;
382        return Ok(ColumnType::UserDefined(QualifiedName::new(
383            schema_id, name_id,
384        )));
385    }
386
387    let s = render_type_name_to_string(type_name).ok_or_else(|| ParseError::Structural {
388        location: location.clone(),
389        message: "could not stringify type name".into(),
390    })?;
391    ColumnType::parse_from_pg_type_string(&s).map_err(|e| ParseError::Structural {
392        location: location.clone(),
393        message: format!("invalid column type {s:?}: {e}"),
394    })
395}
396
397/// Convert a column-default expression node into a [`DefaultExpr`].
398///
399/// Recognizes:
400/// - Bare [`AConst`] integer/float/string/bool/null literals → [`DefaultExpr::Literal`].
401/// - `nextval('seq')` and `nextval('schema.seq')` → [`DefaultExpr::Sequence`].
402/// - Anything else → [`DefaultExpr::Expr`] via the normalizer.
403///
404/// `target_type` enables redundant-cast stripping in the `Expr` arm.
405pub fn build_default_expr(
406    node: &NodeEnum,
407    target_type: Option<&ColumnType>,
408    default_schema: Option<&Identifier>,
409    location: &SourceLocation,
410) -> Result<DefaultExpr, ParseError> {
411    if let Some(lit) = literal_from_node(node) {
412        return Ok(DefaultExpr::Literal(lit));
413    }
414    if let Some(seq) = nextval_target(node, default_schema, location)? {
415        return Ok(DefaultExpr::Sequence(seq));
416    }
417    let normalized = normalize_expr::from_pg_node(node, target_type, location)?;
418    Ok(DefaultExpr::Expr(normalized))
419}
420
421/// If `node` is a literal `AConst` (possibly wrapped in a `TypeCast` whose target
422/// matches the column type), return the equivalent [`LiteralValue`].
423fn literal_from_node(node: &NodeEnum) -> Option<LiteralValue> {
424    let inner = unwrap_typecast(node);
425    match inner {
426        NodeEnum::AConst(c) => aconst_to_literal(c),
427        _ => None,
428    }
429}
430
431/// Strip a single layer of `TypeCast` wrapping, if any.
432fn unwrap_typecast(node: &NodeEnum) -> &NodeEnum {
433    if let NodeEnum::TypeCast(cast) = node
434        && let Some(arg) = cast.arg.as_ref()
435        && let Some(inner) = arg.node.as_ref()
436    {
437        return inner;
438    }
439    node
440}
441
442fn aconst_to_literal(c: &AConst) -> Option<LiteralValue> {
443    if c.isnull {
444        return Some(LiteralValue::Null);
445    }
446    match c.val.as_ref()? {
447        a_const::Val::Ival(i) => Some(LiteralValue::Integer(i64::from(i.ival))),
448        a_const::Val::Fval(f) => f.fval.parse::<f64>().ok().map(LiteralValue::Float),
449        a_const::Val::Boolval(b) => Some(LiteralValue::Bool(b.boolval)),
450        a_const::Val::Sval(s) => Some(LiteralValue::Text(s.sval.clone())),
451        a_const::Val::Bsval(_) => None,
452    }
453}
454
455/// If `node` is `nextval('seq')` (with optional `::regclass` cast on the arg),
456/// return the referenced sequence's [`QualifiedName`].
457fn nextval_target(
458    node: &NodeEnum,
459    default_schema: Option<&Identifier>,
460    location: &SourceLocation,
461) -> Result<Option<QualifiedName>, ParseError> {
462    let inner = unwrap_typecast(node);
463    let NodeEnum::FuncCall(fc) = inner else {
464        return Ok(None);
465    };
466    let func = match fc.funcname.last().and_then(|n| n.node.as_ref()) {
467        Some(NodeEnum::String(s)) => s.sval.as_str(),
468        _ => return Ok(None),
469    };
470    if !func.eq_ignore_ascii_case("nextval") {
471        return Ok(None);
472    }
473    let arg = fc.args.first().and_then(|n| n.node.as_ref());
474    let Some(arg_node) = arg else {
475        return Ok(None);
476    };
477    // The argument to nextval is normally a string literal cast to regclass:
478    // `nextval('app.seq1'::regclass)`. Strip the cast and look for an AConst Sval.
479    let inner_arg = unwrap_typecast(arg_node);
480    let NodeEnum::AConst(c) = inner_arg else {
481        return Ok(None);
482    };
483    let Some(a_const::Val::Sval(s)) = c.val.as_ref() else {
484        return Ok(None);
485    };
486    Ok(Some(parse_qualified_seq_name(
487        &s.sval,
488        default_schema,
489        location,
490    )?))
491}
492
493/// Parse a possibly-qualified sequence reference like `schema.seq` or `seq`.
494fn parse_qualified_seq_name(
495    s: &str,
496    default_schema: Option<&Identifier>,
497    location: &SourceLocation,
498) -> Result<QualifiedName, ParseError> {
499    let parts: Vec<&str> = s.split('.').collect();
500    match parts.len() {
501        1 => {
502            let name = ident(parts[0], location)?;
503            let schema = default_schema
504                .cloned()
505                .ok_or_else(|| ParseError::UnqualifiedName {
506                    location: location.clone(),
507                })?;
508            Ok(QualifiedName::new(schema, name))
509        }
510        2 => {
511            let schema = ident(parts[0], location)?;
512            let name = ident(parts[1], location)?;
513            Ok(QualifiedName::new(schema, name))
514        }
515        _ => Err(ParseError::Structural {
516            location: location.clone(),
517            message: format!("unsupported qualified sequence reference {s:?}"),
518        }),
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::path::PathBuf;
526
527    fn loc() -> SourceLocation {
528        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
529    }
530
531    fn parse_first(sql: &str) -> NodeEnum {
532        let parsed = pg_query::parse(sql).expect("parses");
533        parsed
534            .protobuf
535            .stmts
536            .into_iter()
537            .next()
538            .and_then(|raw| raw.stmt)
539            .and_then(|n| n.node)
540            .expect("at least one statement")
541    }
542
543    fn parse_select_expr(expr: &str) -> NodeEnum {
544        let n = parse_first(&format!("SELECT {expr};"));
545        let NodeEnum::SelectStmt(s) = n else { panic!() };
546        let target = s.target_list.into_iter().next().unwrap().node.unwrap();
547        let NodeEnum::ResTarget(rt) = target else {
548            panic!()
549        };
550        rt.val.unwrap().node.unwrap()
551    }
552
553    #[test]
554    fn literal_integer_default() {
555        let n = parse_select_expr("42");
556        let d = build_default_expr(&n, Some(&ColumnType::Integer), None, &loc()).unwrap();
557        assert!(matches!(d, DefaultExpr::Literal(LiteralValue::Integer(42))));
558    }
559
560    #[test]
561    fn literal_text_default() {
562        let n = parse_select_expr("'hello'");
563        let d = build_default_expr(&n, Some(&ColumnType::Text), None, &loc()).unwrap();
564        assert!(matches!(d, DefaultExpr::Literal(LiteralValue::Text(s)) if s == "hello"));
565    }
566
567    #[test]
568    fn null_default() {
569        let n = parse_select_expr("NULL");
570        let d = build_default_expr(&n, None, None, &loc()).unwrap();
571        assert!(matches!(d, DefaultExpr::Literal(LiteralValue::Null)));
572    }
573
574    #[test]
575    fn bool_default() {
576        let n = parse_select_expr("true");
577        let d = build_default_expr(&n, Some(&ColumnType::Boolean), None, &loc()).unwrap();
578        // Booleans in pg_query may parse as a function call `'t'::boolean` form
579        // — accept either Bool literal or Expr containing "true".
580        match d {
581            DefaultExpr::Literal(LiteralValue::Bool(true)) => {}
582            DefaultExpr::Expr(e) => assert!(e.canonical_text.contains("true")),
583            other => panic!("unexpected default: {other:?}"),
584        }
585    }
586
587    #[test]
588    fn nextval_qualified_default() {
589        let n = parse_select_expr("nextval('app.seq1'::regclass)");
590        let d = build_default_expr(&n, None, None, &loc()).unwrap();
591        match d {
592            DefaultExpr::Sequence(q) => assert_eq!(q.to_string(), "app.seq1"),
593            other => panic!("expected Sequence, got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn nextval_unqualified_uses_default_schema() {
599        let n = parse_select_expr("nextval('seq1'::regclass)");
600        let app = Identifier::from_unquoted("app").unwrap();
601        let d = build_default_expr(&n, None, Some(&app), &loc()).unwrap();
602        match d {
603            DefaultExpr::Sequence(q) => assert_eq!(q.to_string(), "app.seq1"),
604            other => panic!("expected Sequence, got {other:?}"),
605        }
606    }
607
608    #[test]
609    fn func_call_other_than_nextval_is_expr() {
610        let n = parse_select_expr("now()");
611        let d = build_default_expr(&n, None, None, &loc()).unwrap();
612        assert!(matches!(d, DefaultExpr::Expr(_)));
613    }
614
615    #[test]
616    fn cast_to_target_strips_in_expr_arm() {
617        // `'a' || 'b'` is an expression — cast stripping has nothing to strip
618        // here. This test just checks the Expr arm runs.
619        let n = parse_select_expr("'a' || 'b'");
620        let d = build_default_expr(&n, Some(&ColumnType::Text), None, &loc()).unwrap();
621        assert!(matches!(d, DefaultExpr::Expr(_)));
622    }
623
624    #[test]
625    fn type_name_renders_with_typmod() {
626        // Parse a CREATE TABLE to get a real TypeName.
627        let stmt = parse_first("CREATE TABLE t (c varchar(50));");
628        let NodeEnum::CreateStmt(create) = stmt else {
629            panic!()
630        };
631        let elt = create.table_elts.into_iter().next().unwrap();
632        let NodeEnum::ColumnDef(col) = elt.node.unwrap() else {
633            panic!()
634        };
635        let tn = col.type_name.unwrap();
636        let s = render_type_name_to_string(&tn).unwrap();
637        let ct = ColumnType::parse_from_pg_type_string(&s).unwrap();
638        assert_eq!(ct, ColumnType::Varchar { len: Some(50) });
639    }
640
641    /// Extract the first column's `TypeName` from a one-column `CREATE TABLE`.
642    fn first_column_type_name(sql: &str) -> TypeName {
643        let NodeEnum::CreateStmt(create) = parse_first(sql) else {
644            panic!("expected CREATE TABLE")
645        };
646        let elt = create.table_elts.into_iter().next().unwrap();
647        let NodeEnum::ColumnDef(col) = elt.node.unwrap() else {
648            panic!("expected ColumnDef")
649        };
650        col.type_name.unwrap()
651    }
652
653    #[test]
654    fn parameterized_postgis_types_parse() {
655        // PostGIS parameterized types put the subtype (Point / MultiPolygon / …)
656        // in the typmod list as a *bareword*, which pg_query parses as a ColumnRef
657        // (type modifiers are an expr_list), not an AConst. The renderer must
658        // stringify it; pg_query lowercases the bareword, so the canonical raw is
659        // lowercase. (issue #40)
660        let cases = [
661            ("geometry(Point,4326)", "geometry(point,4326)"),
662            (
663                "geography(MultiPolygon,4326)",
664                "geography(multipolygon,4326)",
665            ),
666            ("geometry(geometry,4326)", "geometry(geometry,4326)"),
667        ];
668        for (decl, expected_raw) in cases {
669            let tn = first_column_type_name(&format!("CREATE TABLE t (c {decl});"));
670            let ct = type_name_to_column_type(&tn, &loc())
671                .unwrap_or_else(|e| panic!("{decl} should parse, got {e:?}"));
672            assert!(
673                matches!(&ct, ColumnType::Other { raw } if raw == expected_raw),
674                "{decl} -> {ct:?}"
675            );
676        }
677    }
678
679    /// Convergence tests for schema-qualified `PostGIS` types (issue #42).
680    ///
681    /// `public.geometry(Point,4326)` must converge with the catalog path that
682    /// emits `Other { raw: "public.geometry(point,4326)" }` — i.e. the subtype
683    /// is lowercased and the schema prefix is preserved.
684    #[test]
685    fn schema_qualified_postgis_types_converge() {
686        // AST (source) path: type_name_to_column_type must NOT return UserDefined.
687        let cases = [
688            ("public.geometry(Point,4326)", "public.geometry(point,4326)"),
689            (
690                "public.geography(Point,4326)",
691                "public.geography(point,4326)",
692            ),
693            (
694                "myschema.geometry(MultiPolygon,4326)",
695                "myschema.geometry(multipolygon,4326)",
696            ),
697        ];
698        for (decl, expected_raw) in cases {
699            let tn = first_column_type_name(&format!("CREATE TABLE t (g {decl});"));
700            let ct = type_name_to_column_type(&tn, &loc())
701                .unwrap_or_else(|e| panic!("{decl} should parse, got {e:?}"));
702            assert!(
703                matches!(&ct, ColumnType::Other { raw } if raw == expected_raw),
704                "{decl} — expected Other{{raw:{expected_raw:?}}}, got {ct:?}"
705            );
706
707            // Catalog path: parse_from_pg_type_string with TitleCase input must
708            // produce the same Other so source == catalog (the actual convergence).
709            let catalog_input = decl; // e.g. "public.geometry(Point,4326)"
710            let from_catalog = ColumnType::parse_from_pg_type_string(catalog_input).unwrap();
711            assert_eq!(
712                ct, from_catalog,
713                "{decl}: source path {ct:?} != catalog path {from_catalog:?}"
714            );
715        }
716    }
717
718    /// No-typmod schema-qualified types must remain `UserDefined` (no regression).
719    #[test]
720    fn schema_qualified_no_typmod_stays_user_defined() {
721        // `public.geometry` without typmods → UserDefined.
722        let tn = first_column_type_name("CREATE TABLE t (g public.geometry);");
723        let ct = type_name_to_column_type(&tn, &loc()).expect("should parse");
724        assert!(
725            matches!(&ct, ColumnType::UserDefined(q) if q.to_string() == "public.geometry"),
726            "expected UserDefined(public.geometry), got {ct:?}"
727        );
728
729        // `public.mytype` → UserDefined.
730        let tn2 = first_column_type_name("CREATE TABLE t (x public.mytype);");
731        let ct2 = type_name_to_column_type(&tn2, &loc()).expect("should parse");
732        assert!(
733            matches!(&ct2, ColumnType::UserDefined(q) if q.to_string() == "public.mytype"),
734            "expected UserDefined(public.mytype), got {ct2:?}"
735        );
736    }
737
738    #[test]
739    fn heterogeneous_typmod_args_render() {
740        // A type modifier list may legally mix string/int literals and barewords.
741        // The renderer dispatches per node kind rather than assuming AConst.
742        let tn = first_column_type_name("CREATE TABLE t (c mytype('foo',1,bar));");
743        let ct = type_name_to_column_type(&tn, &loc()).expect("mixed typmods should parse");
744        assert!(
745            matches!(&ct, ColumnType::Other { raw } if raw == "mytype(foo,1,bar)"),
746            "got {ct:?}"
747        );
748    }
749
750    /// Convergence tests (issue #41): the AST source path must produce the same
751    /// `ColumnType::Interval` that the catalog path (`format_type`) emits.
752    ///
753    /// Before the fix, `interval(6)` → typmods `[AConst(32767), AConst(6)]` →
754    /// `render_type_name_to_string` produced `"interval(32767,6)"` → `parse_canonical`
755    /// failed to parse the precision → fell through to `ColumnType::Other`.
756    #[test]
757    fn interval_precision_source_path_convergence() {
758        // `interval(6)` — precision-only form.
759        let tn = first_column_type_name("CREATE TABLE t (c interval(6));");
760        let ct = type_name_to_column_type(&tn, &loc())
761            .unwrap_or_else(|e| panic!("interval(6) should parse, got {e:?}"));
762        assert_eq!(
763            ct,
764            ColumnType::Interval {
765                fields: None,
766                precision: Some(6),
767            },
768            "interval(6) source path should yield Interval{{fields:None, precision:Some(6)}}, got {ct:?}"
769        );
770    }
771
772    #[test]
773    fn interval_fields_source_path_convergence() {
774        // `interval hour to minute` — fields-only form.
775        let tn = first_column_type_name("CREATE TABLE t (c interval hour to minute);");
776        let ct = type_name_to_column_type(&tn, &loc())
777            .unwrap_or_else(|e| panic!("interval hour to minute should parse, got {e:?}"));
778        assert_eq!(
779            ct,
780            ColumnType::Interval {
781                fields: Some("hour to minute".to_string()),
782                precision: None,
783            },
784            "interval hour to minute source path should yield Interval{{fields:Some(\"hour to minute\"), precision:None}}, got {ct:?}"
785        );
786    }
787
788    #[test]
789    fn interval_fields_and_precision_converges_from_ast() {
790        // Combined `interval <fields>(p)` form: typmods `[7176, 3]` (DAY|HOUR|
791        // MINUTE|SECOND = 7176, precision 3) → render "interval day to second(3)"
792        // → parse. Must converge to a typed Interval, not Other.
793        let tn = first_column_type_name("CREATE TABLE t (c interval day to second(3));");
794        let ct = type_name_to_column_type(&tn, &loc())
795            .unwrap_or_else(|e| panic!("interval day to second(3) should parse, got {e:?}"));
796        assert_eq!(
797            ct,
798            ColumnType::Interval {
799                fields: Some("day to second".to_string()),
800                precision: Some(3),
801            },
802            "interval day to second(3) source path should yield typed Interval, got {ct:?}"
803        );
804    }
805
806    #[test]
807    fn interval_bare_source_path() {
808        // `interval` with no modifiers — must not regress.
809        let tn = first_column_type_name("CREATE TABLE t (c interval);");
810        let ct = type_name_to_column_type(&tn, &loc())
811            .unwrap_or_else(|e| panic!("interval should parse, got {e:?}"));
812        assert_eq!(
813            ct,
814            ColumnType::Interval {
815                fields: None,
816                precision: None,
817            },
818        );
819    }
820}