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