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