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