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