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