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