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