Skip to main content

pgevolve_core/parse/
mod.rs

1//! Source-side parser: SQL bytes → IR.
2//!
3//! This module accepts a directory of `CREATE`-style DDL files and produces a
4//! [`crate::ir::catalog::Catalog`]. Construction is I/O-free at the type level —
5//! the only I/O is performed by [`parse_directory`] on behalf of callers.
6
7pub mod ast_canon;
8mod ast_resolution;
9pub mod builder;
10pub mod cluster;
11pub mod directives;
12pub mod error;
13pub mod normalize_body;
14pub mod normalize_expr;
15pub mod statement;
16
17use std::collections::{BTreeMap, HashMap};
18use std::path::{Path, PathBuf};
19
20pub use directives::{FileDirectives, extract_file_directives};
21pub use error::{ParseError, SourceLocation};
22pub use statement::Statement;
23
24use crate::identifier::{Identifier, QualifiedName};
25use crate::ir::IrError;
26use crate::ir::aggregate::Aggregate;
27use crate::ir::cast::Cast;
28use crate::ir::catalog::Catalog;
29use crate::ir::event_trigger::EventTrigger;
30use crate::ir::publication::Publication;
31use crate::ir::statistic::Statistic;
32use crate::ir::subscription::Subscription;
33
34/// Parse every `*.sql` file under `root`, recursively, and produce a fully-
35/// populated [`Catalog`]. Files matching any pattern in `ignores` are skipped.
36///
37/// Walking is deterministic (paths are sorted before processing), and each
38/// statement classifies through the v0.1 whitelist; non-MVP DDL kinds raise
39/// [`ParseError::UnsupportedObjectKind`] with a phase-2 message.
40///
41/// After all files have been processed, the catalog is canonicalized (vec-
42/// sorted) and duplicate qnames raise [`ParseError::DuplicateObject`].
43pub fn parse_directory(root: &Path, ignores: &[glob::Pattern]) -> Result<Catalog, ParseError> {
44    parse_directory_with_locations(root, ignores).map(|(c, _)| c)
45}
46
47/// Like [`parse_directory`] but also returns the per-qname source-location
48/// map built during parsing. Used by the lint engine (Phase 10) to know which
49/// file each object was declared in.
50///
51/// The map keys are qname strings as rendered by `Display`: `"schema_name"`
52/// for schemas, `"schema.name"` for tables / indexes / sequences.
53pub fn parse_directory_with_locations(
54    root: &Path,
55    ignores: &[glob::Pattern],
56) -> Result<(Catalog, HashMap<String, SourceLocation>), ParseError> {
57    let mut files: Vec<PathBuf> = Vec::new();
58    for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
59        let entry = entry.map_err(|e| ParseError::Io {
60            path: e.path().map(Path::to_path_buf).unwrap_or_default(),
61            source: e
62                .into_io_error()
63                .unwrap_or_else(|| std::io::Error::other("walkdir error")),
64        })?;
65        if !entry.file_type().is_file() {
66            continue;
67        }
68        let path = entry.into_path();
69        if path.extension().and_then(|e| e.to_str()) != Some("sql") {
70            continue;
71        }
72        if ignores.iter().any(|pat| pat.matches_path(&path)) {
73            continue;
74        }
75        files.push(path);
76    }
77
78    let mut catalog = Catalog::default();
79    let mut locations: HashMap<String, SourceLocation> = HashMap::new();
80    let mut pending_fks: Vec<builder::alter_table_stmt::PendingFk> = Vec::new();
81    let mut pending_column_attrs: Vec<builder::alter_table_stmt::PendingColumnAttr> = Vec::new();
82    let mut pending_owners: Vec<builder::alter_table_stmt::PendingOwner> = Vec::new();
83    let mut pending_rls_toggles: Vec<builder::alter_table_stmt::PendingRlsToggle> = Vec::new();
84    let mut pending_rel_options: Vec<builder::alter_table_stmt::PendingRelOptions> = Vec::new();
85    let mut deferred_comments: Vec<(
86        pg_query::protobuf::CommentStmt,
87        SourceLocation,
88        Option<crate::identifier::Identifier>,
89    )> = Vec::new();
90    // Publications are accumulated in insertion order (keyed by name) and
91    // folded: CREATE ... WITH (...) then subsequent ALTER ... ADD/DROP/SET
92    // all land in the same record.
93    let mut publications: BTreeMap<Identifier, Publication> = BTreeMap::new();
94    // Subscriptions: same fold-accumulate pattern as publications.
95    let mut subscriptions: BTreeMap<Identifier, Subscription> = BTreeMap::new();
96    // Statistics: fold CREATE + ALTER SET STATISTICS + COMMENT into one record per qname.
97    let mut statistics: BTreeMap<QualifiedName, Statistic> = BTreeMap::new();
98    // Event triggers: database-global, accumulated by name. Fold CREATE +
99    // ALTER (ENABLE/DISABLE) + COMMENT + OWNER into one record per name.
100    let mut event_triggers: BTreeMap<Identifier, EventTrigger> = BTreeMap::new();
101    // Aggregates: identity is `(qname, arg_types)` (overloadable), so they are
102    // accumulated in a Vec and ALTER OWNER / COMMENT are folded by matching that
103    // identity. Duplicate identities are rejected at CREATE time.
104    let mut aggregates: Vec<Aggregate> = Vec::new();
105    // Casts: identity is `(source, target)`. Not overloadable. COMMENT ON CAST
106    // is folded inline. Duplicate identities are rejected at CREATE time.
107    let mut casts: Vec<Cast> = Vec::new();
108
109    for path in files {
110        let contents = std::fs::read_to_string(&path).map_err(|e| ParseError::Io {
111            path: path.clone(),
112            source: e,
113        })?;
114        process_file(
115            &path,
116            &contents,
117            &mut catalog,
118            &mut locations,
119            &mut pending_fks,
120            &mut pending_column_attrs,
121            &mut pending_owners,
122            &mut pending_rls_toggles,
123            &mut pending_rel_options,
124            &mut deferred_comments,
125            &mut publications,
126            &mut subscriptions,
127            &mut statistics,
128            &mut event_triggers,
129            &mut aggregates,
130            &mut casts,
131        )?;
132    }
133
134    // Apply deferred comments (the underlying object may be defined in a later
135    // file).
136    for (stmt, location, default_schema) in deferred_comments {
137        builder::comment_stmt::apply_comment(
138            &stmt,
139            &mut catalog,
140            default_schema.as_ref(),
141            &location,
142        )?;
143    }
144
145    // Merge pending FKs and column-attribute updates from ALTER TABLE statements.
146    apply_pending_fks(&mut catalog, pending_fks)?;
147    apply_pending_column_attrs(&mut catalog, pending_column_attrs)?;
148    apply_pending_owners(&mut catalog, pending_owners)?;
149    apply_pending_rls_toggles(&mut catalog, pending_rls_toggles)?;
150    apply_pending_rel_options(&mut catalog, pending_rel_options)?;
151
152    // Flush the publications accumulator into the catalog.
153    catalog.publications = publications.into_values().collect();
154
155    // Flush the subscriptions accumulator into the catalog.
156    catalog.subscriptions = subscriptions.into_values().collect();
157
158    // Flush the statistics accumulator into the catalog.
159    catalog.statistics = statistics.into_values().collect();
160
161    // Flush the event-triggers accumulator into the catalog.
162    catalog.event_triggers = event_triggers.into_values().collect();
163
164    // Flush the aggregates accumulator into the catalog.
165    catalog.aggregates = aggregates;
166
167    // Flush the casts accumulator into the catalog.
168    catalog.casts = casts;
169
170    // AST resolution pass: validate that all structural references (FKs,
171    // sequence defaults) resolve against the declared IR, before any DB touch.
172    ast_resolution::resolve(&catalog, &locations).map_err(ParseError::AstResolution)?;
173
174    // AST canonicalization pass: fill body_canonical, body_dependencies, and
175    // (when needed) columns for all views and materialized views. Skipped when
176    // the catalog has no views, so v0.1 fixtures pay no overhead.
177    if !catalog.views.is_empty() || !catalog.materialized_views.is_empty() {
178        ast_canon::canonicalize_view_bodies(&mut catalog).map_err(ParseError::AstCanon)?;
179    }
180
181    // MV index parent promotion: source-side `CREATE INDEX ON mv_name (...)` is
182    // initially parsed as `IndexParent::Table` because the parser doesn't know
183    // whether the relation is a table or an MV. Now that both the indexes and
184    // the MVs are in the catalog, promote any `IndexParent::Table(q)` where `q`
185    // is actually a materialized view.
186    ast_canon::promote_mv_index_parents(&mut catalog);
187
188    let canonical = catalog
189        .canonicalize()
190        .map_err(|e: IrError| translate_canonicalize_error(e, &locations))?;
191    Ok((canonical, locations))
192}
193
194/// Apply a `COMMENT ON STATISTICS …` to the in-progress statistics accumulator.
195///
196/// Called inline during `process_file` (not deferred) because statistics are
197/// accumulated in a `BTreeMap<QualifiedName, Statistic>` that is flushed into
198/// the catalog *after* all deferred comments are applied.
199fn apply_statistics_comment(
200    stmt: &pg_query::protobuf::CommentStmt,
201    statistics: &mut BTreeMap<QualifiedName, Statistic>,
202    location: &SourceLocation,
203) -> Result<(), ParseError> {
204    use pg_query::NodeEnum;
205
206    // pg_query encodes COMMENT ON STATISTICS as a List of String nodes.
207    let obj = stmt
208        .object
209        .as_ref()
210        .and_then(|o| o.node.as_ref())
211        .ok_or_else(|| ParseError::Structural {
212            location: location.clone(),
213            message: "COMMENT ON STATISTICS missing object reference".into(),
214        })?;
215    let NodeEnum::List(list) = obj else {
216        return Err(ParseError::Structural {
217            location: location.clone(),
218            message: format!(
219                "COMMENT ON STATISTICS expected a List node, got {:?}",
220                std::mem::discriminant(obj)
221            ),
222        });
223    };
224    let parts: Vec<String> = list
225        .items
226        .iter()
227        .filter_map(|n| {
228            if let Some(NodeEnum::String(s)) = n.node.as_ref() {
229                Some(s.sval.clone())
230            } else {
231                None
232            }
233        })
234        .collect();
235    let qname = match parts.as_slice() {
236        [schema, name] => QualifiedName::new(
237            builder::shared::ident(schema, location)?,
238            builder::shared::ident(name, location)?,
239        ),
240        [_name] => {
241            return Err(ParseError::UnqualifiedName {
242                location: location.clone(),
243            });
244        }
245        _ => {
246            return Err(ParseError::Structural {
247                location: location.clone(),
248                message: format!(
249                    "COMMENT ON STATISTICS expected 1-2 qualified components, got {parts:?}"
250                ),
251            });
252        }
253    };
254
255    let comment = if stmt.comment.is_empty() {
256        None
257    } else {
258        Some(stmt.comment.clone())
259    };
260
261    let statistic = statistics.get_mut(&qname).ok_or_else(|| {
262        ParseError::CommentOnStatisticBeforeCreate(qname.clone(), location.clone())
263    })?;
264    statistic.comment = comment;
265    Ok(())
266}
267
268/// Merge accumulated pending FKs onto their target tables.
269fn apply_pending_fks(
270    catalog: &mut Catalog,
271    pending_fks: Vec<builder::alter_table_stmt::PendingFk>,
272) -> Result<(), ParseError> {
273    for pending in pending_fks {
274        let table = catalog
275            .tables
276            .iter_mut()
277            .find(|t| t.qname == pending.target)
278            .ok_or_else(|| ParseError::Structural {
279                location: SourceLocation::new(PathBuf::new(), 0, 0),
280                message: format!("ALTER TABLE referenced unknown table {}", pending.target),
281            })?;
282        table.constraints.push(pending.constraint);
283    }
284    Ok(())
285}
286
287/// Apply accumulated `ALTER COLUMN SET STORAGE / SET COMPRESSION` updates.
288fn apply_pending_column_attrs(
289    catalog: &mut Catalog,
290    pending: Vec<builder::alter_table_stmt::PendingColumnAttr>,
291) -> Result<(), ParseError> {
292    use builder::alter_table_stmt::PendingColumnAttrKind;
293    for attr in pending {
294        let table = catalog
295            .tables
296            .iter_mut()
297            .find(|t| t.qname == attr.target)
298            .ok_or_else(|| ParseError::Structural {
299                location: SourceLocation::new(PathBuf::new(), 0, 0),
300                message: format!(
301                    "ALTER TABLE ALTER COLUMN referenced unknown table {}",
302                    attr.target
303                ),
304            })?;
305        let col = table
306            .columns
307            .iter_mut()
308            .find(|c| c.name == attr.column)
309            .ok_or_else(|| ParseError::Structural {
310                location: SourceLocation::new(PathBuf::new(), 0, 0),
311                message: format!(
312                    "ALTER TABLE ALTER COLUMN referenced unknown column {}.{}",
313                    attr.target, attr.column
314                ),
315            })?;
316        match attr.kind {
317            PendingColumnAttrKind::Storage(s) => {
318                col.storage = Some(s);
319            }
320            PendingColumnAttrKind::Compression(c) => {
321                col.compression = c;
322            }
323        }
324    }
325    Ok(())
326}
327
328/// Apply accumulated `ALTER TABLE/MATERIALIZED VIEW ... SET (...)` reloption
329/// updates to the catalog.
330///
331/// Called after all tables and materialized views are built.
332fn apply_pending_rel_options(
333    catalog: &mut Catalog,
334    pending: Vec<builder::alter_table_stmt::PendingRelOptions>,
335) -> Result<(), ParseError> {
336    let loc = SourceLocation::new(PathBuf::new(), 0, 0);
337    builder::alter_table_stmt::apply_pending_rel_options(catalog, pending, &loc)
338}
339
340/// Apply accumulated RLS mode toggles from ALTER TABLE statements.
341///
342/// Called after all tables are built so that the tables exist in the catalog.
343fn apply_pending_rls_toggles(
344    catalog: &mut Catalog,
345    pending: Vec<builder::alter_table_stmt::PendingRlsToggle>,
346) -> Result<(), ParseError> {
347    let loc = SourceLocation::new(PathBuf::new(), 0, 0);
348    builder::alter_table_stmt::apply_pending_rls_toggles(catalog, pending, &loc)
349}
350
351/// Apply accumulated `ALTER TABLE ... OWNER TO` ownership assignments.
352///
353/// Called after all tables, views, and materialized views are built.
354fn apply_pending_owners(
355    catalog: &mut Catalog,
356    pending: Vec<builder::alter_table_stmt::PendingOwner>,
357) -> Result<(), ParseError> {
358    let loc = SourceLocation::new(PathBuf::new(), 0, 0);
359    for po in pending {
360        builder::alter_table_stmt::apply_pending_owners(catalog, vec![po], &loc)?;
361    }
362    Ok(())
363}
364
365#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
366fn process_file(
367    path: &Path,
368    contents: &str,
369    catalog: &mut Catalog,
370    locations: &mut HashMap<String, SourceLocation>,
371    pending_fks: &mut Vec<builder::alter_table_stmt::PendingFk>,
372    pending_column_attrs: &mut Vec<builder::alter_table_stmt::PendingColumnAttr>,
373    pending_owners: &mut Vec<builder::alter_table_stmt::PendingOwner>,
374    pending_rls_toggles: &mut Vec<builder::alter_table_stmt::PendingRlsToggle>,
375    pending_rel_options: &mut Vec<builder::alter_table_stmt::PendingRelOptions>,
376    deferred_comments: &mut Vec<(
377        pg_query::protobuf::CommentStmt,
378        SourceLocation,
379        Option<crate::identifier::Identifier>,
380    )>,
381    publications: &mut BTreeMap<Identifier, Publication>,
382    subscriptions: &mut BTreeMap<Identifier, Subscription>,
383    statistics: &mut BTreeMap<QualifiedName, Statistic>,
384    event_triggers: &mut BTreeMap<Identifier, EventTrigger>,
385    aggregates: &mut Vec<Aggregate>,
386    casts: &mut Vec<Cast>,
387) -> Result<(), ParseError> {
388    let directives = directives::extract_file_directives(contents, path)?;
389    let parsed = pg_query::parse(contents).map_err(|e| ParseError::PgQuery {
390        location: SourceLocation::new(path.to_path_buf(), 1, 1),
391        message: e.to_string(),
392    })?;
393
394    for raw in parsed.protobuf.stmts {
395        let location = stmt_location(path, contents, raw.stmt_location);
396        let Some(node) = raw.stmt.and_then(|n| n.node) else {
397            continue;
398        };
399        let stmt = Statement::classify(node, location.clone())?;
400        match stmt {
401            Statement::CreateSchema(s) => {
402                let schema = builder::create_schema_stmt::build_schema(&s, &location)?;
403                let schema_qname = QualifiedName::new(schema.name.clone(), schema.name.clone()); // schema has no parent; track by name
404                if let Some(prior) = locations.get(&schema.name.to_string()) {
405                    return Err(ParseError::DuplicateObject {
406                        qname: schema.name.to_string(),
407                        first: prior.clone(),
408                        second: location,
409                    });
410                }
411                locations.insert(schema.name.to_string(), location.clone());
412                catalog.schemas.push(schema);
413                let _ = schema_qname;
414            }
415            Statement::CreateTable(s) => {
416                let mut table =
417                    builder::create_stmt::build_table(&s, directives.schema.as_ref(), &location)?;
418                let serial_seqs =
419                    builder::desugar_serial::desugar_serials_in_table(&mut table, &location)?;
420                if let Some(prior) = locations.get(&table.qname.to_string()) {
421                    return Err(ParseError::DuplicateObject {
422                        qname: table.qname.to_string(),
423                        first: prior.clone(),
424                        second: location,
425                    });
426                }
427                locations.insert(table.qname.to_string(), location.clone());
428                catalog.tables.push(table);
429                for seq in serial_seqs {
430                    if let Some(prior) = locations.get(&seq.qname.to_string()) {
431                        return Err(ParseError::DuplicateObject {
432                            qname: seq.qname.to_string(),
433                            first: prior.clone(),
434                            second: location.clone(),
435                        });
436                    }
437                    locations.insert(seq.qname.to_string(), location.clone());
438                    catalog.sequences.push(seq);
439                }
440            }
441            Statement::CreateSequence(s) => {
442                let seq = builder::create_seq_stmt::build_sequence(
443                    &s,
444                    directives.schema.as_ref(),
445                    &location,
446                )?;
447                if let Some(prior) = locations.get(&seq.qname.to_string()) {
448                    return Err(ParseError::DuplicateObject {
449                        qname: seq.qname.to_string(),
450                        first: prior.clone(),
451                        second: location,
452                    });
453                }
454                locations.insert(seq.qname.to_string(), location.clone());
455                catalog.sequences.push(seq);
456            }
457            Statement::CreateIndex(s) => {
458                let idx =
459                    builder::index_stmt::build_index(&s, directives.schema.as_ref(), &location)?;
460                if let Some(prior) = locations.get(&idx.qname.to_string()) {
461                    return Err(ParseError::DuplicateObject {
462                        qname: idx.qname.to_string(),
463                        first: prior.clone(),
464                        second: location,
465                    });
466                }
467                locations.insert(idx.qname.to_string(), location.clone());
468                catalog.indexes.push(idx);
469            }
470            Statement::AlterTable(s) => {
471                let alter_out = builder::alter_table_stmt::build_alter_table(
472                    &s,
473                    directives.schema.as_ref(),
474                    &location,
475                )?;
476                pending_fks.extend(alter_out.pending_fks);
477                pending_column_attrs.extend(alter_out.pending_column_attrs);
478                pending_owners.extend(alter_out.pending_owners);
479                pending_rls_toggles.extend(alter_out.pending_rls_toggles);
480                pending_rel_options.extend(alter_out.pending_rel_options);
481            }
482            Statement::Comment(s) => {
483                use pg_query::protobuf::ObjectType;
484                let kind = ObjectType::try_from(s.objtype).unwrap_or(ObjectType::Undefined);
485                if matches!(kind, ObjectType::ObjectStatisticExt) {
486                    // COMMENT ON STATISTICS is handled inline against the statistics
487                    // accumulator (not deferred), because statistics are not yet in
488                    // catalog at the deferred-comment application point.
489                    apply_statistics_comment(&s, statistics, &location)?;
490                } else if matches!(kind, ObjectType::ObjectEventTrigger) {
491                    // COMMENT ON EVENT TRIGGER is handled inline against the
492                    // event-trigger accumulator for the same reason.
493                    builder::event_trigger_stmt::apply_event_trigger_comment(
494                        &s,
495                        &location,
496                        event_triggers,
497                    )?;
498                } else if matches!(kind, ObjectType::ObjectAggregate) {
499                    // COMMENT ON AGGREGATE is applied inline by `(qname, arg_types)`
500                    // identity against the aggregate accumulator.
501                    builder::aggregate_stmt::apply_comment(
502                        &s,
503                        directives.schema.as_ref(),
504                        &location,
505                        aggregates,
506                    )?;
507                } else if matches!(kind, ObjectType::ObjectCast) {
508                    // COMMENT ON CAST is applied inline by `(source, target)` identity
509                    // against the cast accumulator.
510                    builder::cast_stmt::apply_comment(
511                        &s,
512                        directives.schema.as_ref(),
513                        &location,
514                        casts,
515                    )?;
516                } else {
517                    deferred_comments.push((s, location, directives.schema.clone()));
518                }
519            }
520            Statement::CreateView(s) => {
521                let view = builder::create_view_stmt::build_view(
522                    &s,
523                    directives.schema.as_ref(),
524                    &location,
525                )?;
526                if let Some(prior) = locations.get(&view.qname.to_string()) {
527                    return Err(ParseError::DuplicateObject {
528                        qname: view.qname.to_string(),
529                        first: prior.clone(),
530                        second: location,
531                    });
532                }
533                locations.insert(view.qname.to_string(), location.clone());
534                catalog.views.push(view);
535            }
536            Statement::CreateMaterializedView(s) => {
537                let mv = builder::create_materialized_view_stmt::build_materialized_view(
538                    &s,
539                    directives.schema.as_ref(),
540                    &location,
541                )?;
542                if let Some(prior) = locations.get(&mv.qname.to_string()) {
543                    return Err(ParseError::DuplicateObject {
544                        qname: mv.qname.to_string(),
545                        first: prior.clone(),
546                        second: location,
547                    });
548                }
549                locations.insert(mv.qname.to_string(), location.clone());
550                catalog.materialized_views.push(mv);
551            }
552            Statement::CreateEnum(s) => {
553                let ut = builder::create_enum_stmt::build_enum(
554                    &s,
555                    directives.schema.as_ref(),
556                    &location,
557                )?;
558                if let Some(prior) = locations.get(&ut.qname.to_string()) {
559                    return Err(ParseError::DuplicateObject {
560                        qname: ut.qname.to_string(),
561                        first: prior.clone(),
562                        second: location,
563                    });
564                }
565                locations.insert(ut.qname.to_string(), location.clone());
566                catalog.types.push(ut);
567            }
568            Statement::CreateDomain(s) => {
569                let ut = builder::create_domain_stmt::build_domain(
570                    &s,
571                    directives.schema.as_ref(),
572                    &location,
573                )?;
574                if let Some(prior) = locations.get(&ut.qname.to_string()) {
575                    return Err(ParseError::DuplicateObject {
576                        qname: ut.qname.to_string(),
577                        first: prior.clone(),
578                        second: location,
579                    });
580                }
581                locations.insert(ut.qname.to_string(), location.clone());
582                catalog.types.push(ut);
583            }
584            Statement::CreateCompositeType(s) => {
585                let ut = builder::create_composite_type_stmt::build_composite(
586                    &s,
587                    directives.schema.as_ref(),
588                    &location,
589                )?;
590                if let Some(prior) = locations.get(&ut.qname.to_string()) {
591                    return Err(ParseError::DuplicateObject {
592                        qname: ut.qname.to_string(),
593                        first: prior.clone(),
594                        second: location,
595                    });
596                }
597                locations.insert(ut.qname.to_string(), location.clone());
598                catalog.types.push(ut);
599            }
600            Statement::CreateRange(s) => {
601                let ut = builder::create_range_stmt::build_range(
602                    &s,
603                    directives.schema.as_ref(),
604                    &location,
605                )?;
606                if let Some(prior) = locations.get(&ut.qname.to_string()) {
607                    return Err(ParseError::DuplicateObject {
608                        qname: ut.qname.to_string(),
609                        first: prior.clone(),
610                        second: location,
611                    });
612                }
613                locations.insert(ut.qname.to_string(), location.clone());
614                catalog.types.push(ut);
615            }
616            Statement::CreateFunction(s) => {
617                let routine = builder::create_function_stmt::build_function_or_procedure(
618                    &s,
619                    directives.schema.as_ref(),
620                    &location,
621                )?;
622                let builder::create_function_stmt::Routine::Function(f) = routine else {
623                    return Err(ParseError::Structural {
624                        location,
625                        message: "internal error: expected Function from non-procedure stmt".into(),
626                    });
627                };
628                let arg_sig = f
629                    .args
630                    .iter()
631                    .filter(|a| {
632                        matches!(
633                            a.mode,
634                            crate::ir::function::ArgMode::In
635                                | crate::ir::function::ArgMode::InOut
636                                | crate::ir::function::ArgMode::Variadic
637                        )
638                    })
639                    .map(|a| a.ty.render_sql())
640                    .collect::<Vec<_>>()
641                    .join(",");
642                let key = format!("functions.{}({arg_sig})", f.qname);
643                if let Some(prior) = locations.get(&key) {
644                    return Err(ParseError::DuplicateObject {
645                        qname: key,
646                        first: prior.clone(),
647                        second: location,
648                    });
649                }
650                locations.insert(key, location.clone());
651                catalog.functions.push(f);
652            }
653            Statement::CreateProcedure(s) => {
654                let routine = builder::create_function_stmt::build_function_or_procedure(
655                    &s,
656                    directives.schema.as_ref(),
657                    &location,
658                )?;
659                let builder::create_function_stmt::Routine::Procedure(p) = routine else {
660                    return Err(ParseError::Structural {
661                        location,
662                        message: "internal error: expected Procedure from procedure stmt".into(),
663                    });
664                };
665                // Procedure identity is qname-only per arch Decision 2 — PG
666                // allows procedure overloading at the catalog level, but
667                // pgevolve v0.2 deliberately restricts procedures to a single
668                // signature per qname. Two procedures with the same qname
669                // (even with different arg types) collide.
670                let key = format!("procedures.{}", p.qname);
671                if let Some(prior) = locations.get(&key) {
672                    return Err(ParseError::DuplicateObject {
673                        qname: key,
674                        first: prior.clone(),
675                        second: location,
676                    });
677                }
678                locations.insert(key, location.clone());
679                catalog.procedures.push(p);
680            }
681            Statement::CreateExtension(s) => {
682                let ext = builder::create_extension_stmt::build_extension(&s, &location)?;
683                let key = format!("extensions.{}", ext.name);
684                if let Some(prior) = locations.get(&key) {
685                    return Err(ParseError::DuplicateObject {
686                        qname: key,
687                        first: prior.clone(),
688                        second: location,
689                    });
690                }
691                locations.insert(key, location.clone());
692                catalog.extensions.push(ext);
693            }
694            Statement::CreateTrigger(s) => {
695                let trigger = builder::create_trigger_stmt::build_trigger(&s, &location)?;
696                let key = format!("triggers.{}", trigger.qname);
697                if let Some(prior) = locations.get(&key) {
698                    return Err(ParseError::DuplicateObject {
699                        qname: key,
700                        first: prior.clone(),
701                        second: location,
702                    });
703                }
704                locations.insert(key, location.clone());
705                catalog.triggers.push(trigger);
706            }
707            Statement::AlterTableAttachPartition(s) => {
708                let attach = builder::alter_table_attach_partition::build_attach_partition(
709                    &s,
710                    directives.schema.as_ref(),
711                    &location,
712                )?;
713                let child_table = catalog
714                    .tables
715                    .iter_mut()
716                    .find(|t| t.qname == attach.child)
717                    .ok_or_else(|| ParseError::Structural {
718                        location: location.clone(),
719                        message: format!(
720                            "ATTACH PARTITION {} must follow its CREATE TABLE statement",
721                            attach.child
722                        ),
723                    })?;
724                if child_table.partition_of.is_some() {
725                    return Err(ParseError::Structural {
726                        location,
727                        message: format!("table {} already declared as a partition", attach.child),
728                    });
729                }
730                child_table.partition_of = Some(attach.partition_of);
731            }
732            Statement::Grant(s) => {
733                builder::grants::apply(&s, catalog, &location)?;
734            }
735            Statement::AlterOwner(s) => {
736                use pg_query::protobuf::ObjectType;
737                let objtype = ObjectType::try_from(s.object_type).unwrap_or(ObjectType::Undefined);
738                if matches!(objtype, ObjectType::ObjectEventTrigger) {
739                    // ALTER EVENT TRIGGER … OWNER TO is applied inline against the
740                    // event-trigger accumulator (event triggers are not yet in the
741                    // catalog at this point).
742                    builder::event_trigger_stmt::apply_event_trigger_owner(
743                        &s,
744                        &location,
745                        event_triggers,
746                    )?;
747                } else if matches!(objtype, ObjectType::ObjectAggregate) {
748                    // ALTER AGGREGATE … OWNER TO is applied inline by identity
749                    // against the aggregate accumulator.
750                    builder::aggregate_stmt::apply_owner(
751                        &s,
752                        directives.schema.as_ref(),
753                        &location,
754                        aggregates,
755                    )?;
756                } else {
757                    builder::owner_stmt::apply(&s, catalog, &location)?;
758                }
759            }
760            Statement::AlterDefaultPrivileges(s) => {
761                builder::default_privileges::apply(&s, catalog, &location)?;
762            }
763            Statement::CreatePolicy(s) => {
764                builder::policy_stmt::apply(&s, catalog, &location)?;
765            }
766            Statement::CreatePublication(s) => {
767                builder::publication_stmt::parse_create_publication(&s, location, publications)?;
768            }
769            Statement::AlterPublication(s) => {
770                builder::publication_stmt::parse_alter_publication(&s, location, publications)?;
771            }
772            Statement::CreateSubscription(s) => {
773                builder::subscription_stmt::parse_create_subscription(&s, location, subscriptions)?;
774            }
775            Statement::AlterSubscription(s) => {
776                builder::subscription_stmt::parse_alter_subscription(&s, location, subscriptions)?;
777            }
778            Statement::CreateStatistics(s) => {
779                builder::statistic_stmt::parse_create_statistics(&s, location, statistics)?;
780            }
781            Statement::AlterStatistics(s) => {
782                builder::statistic_stmt::parse_alter_statistics(&s, &location, statistics)?;
783            }
784            Statement::CreateCollation(s) => {
785                let coll = builder::create_collation_stmt::build_collation(
786                    &s,
787                    directives.schema.as_ref(),
788                    &location,
789                )?;
790                if let Some(prior) = locations.get(&coll.qname.to_string()) {
791                    return Err(ParseError::DuplicateObject {
792                        qname: coll.qname.to_string(),
793                        first: prior.clone(),
794                        second: location,
795                    });
796                }
797                locations.insert(coll.qname.to_string(), location.clone());
798                catalog.collations.push(coll);
799            }
800            Statement::CreateAggregate(s) => {
801                builder::aggregate_stmt::parse_create(
802                    &s,
803                    directives.schema.as_ref(),
804                    &location,
805                    aggregates,
806                )?;
807            }
808            Statement::CreateEventTrigger(s) => {
809                builder::event_trigger_stmt::parse_create_event_trigger(
810                    &s,
811                    directives.schema.as_ref(),
812                    location,
813                    event_triggers,
814                )?;
815            }
816            Statement::AlterEventTrigger(s) => {
817                builder::event_trigger_stmt::parse_alter_event_trigger(
818                    &s,
819                    location,
820                    event_triggers,
821                )?;
822            }
823            Statement::CreateCast(s) => {
824                builder::cast_stmt::parse_create(&s, directives.schema.as_ref(), &location, casts)?;
825            }
826        }
827    }
828    Ok(())
829}
830
831/// Convert a `pg_query` byte offset into a 1-based line/column.
832fn stmt_location(path: &Path, contents: &str, byte_offset: i32) -> SourceLocation {
833    let offset = usize::try_from(byte_offset).unwrap_or(0);
834    let mut line = 1usize;
835    let mut col = 1usize;
836    for (i, c) in contents.char_indices() {
837        if i >= offset {
838            break;
839        }
840        if c == '\n' {
841            line += 1;
842            col = 1;
843        } else {
844            col += 1;
845        }
846    }
847    SourceLocation::new(path.to_path_buf(), line, col)
848}
849
850fn translate_canonicalize_error(
851    e: IrError,
852    locations: &HashMap<String, SourceLocation>,
853) -> ParseError {
854    if let IrError::InvalidIdentifier(msg) = &e {
855        // Format is "duplicate <kind>: <qname>" (see `Catalog::canonicalize`).
856        if let Some(rest) = msg.strip_prefix("duplicate ")
857            && let Some((_, qname)) = rest.split_once(": ")
858            && let Some(loc) = locations.get(qname)
859        {
860            return ParseError::DuplicateObject {
861                qname: qname.to_string(),
862                first: loc.clone(),
863                second: loc.clone(),
864            };
865        }
866    }
867    let placeholder = SourceLocation::new(PathBuf::new(), 0, 0);
868    ParseError::Ir {
869        location: placeholder,
870        source: e,
871    }
872}
873
874/// Smoke test: parse a single statement string with `pg_query`.
875#[cfg(test)]
876pub(crate) fn smoke_parse(sql: &str) -> Result<pg_query::ParseResult, pg_query::Error> {
877    pg_query::parse(sql)
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    #[test]
885    fn pg_query_round_trips_a_create_table() {
886        let sql = "CREATE TABLE app.users (id integer);";
887        let result = smoke_parse(sql).expect("pg_query parses");
888        // Smoke check: the parse tree contains at least one statement.
889        assert!(!result.protobuf.stmts.is_empty());
890    }
891
892    #[test]
893    fn pg_query_reports_syntax_errors() {
894        let sql = "CREATE TABLE !bad!;";
895        assert!(smoke_parse(sql).is_err());
896    }
897}