Skip to main content

pgevolve_core/parse/
statement.rs

1//! Classify a top-level `pg_query` statement node into the v0.1 whitelist.
2//!
3//! Anything outside the whitelist is rejected with [`ParseError::UnsupportedObjectKind`]
4//! so that source-loading fails loudly instead of silently dropping unsupported DDL.
5
6use pg_query::NodeEnum;
7use pg_query::protobuf;
8use pg_query::protobuf::ObjectType;
9
10use crate::parse::error::{ParseError, SourceLocation};
11
12/// One supported top-level statement kind. Each variant carries the typed protobuf
13/// message that the corresponding builder will consume.
14#[derive(Debug, Clone)]
15pub enum Statement {
16    /// `CREATE SCHEMA ...`.
17    CreateSchema(protobuf::CreateSchemaStmt),
18    /// `CREATE TABLE ...`.
19    CreateTable(protobuf::CreateStmt),
20    /// `CREATE SEQUENCE ...`.
21    CreateSequence(protobuf::CreateSeqStmt),
22    /// `CREATE [UNIQUE] INDEX ...`.
23    CreateIndex(protobuf::IndexStmt),
24    /// `ALTER TABLE ...` — only `ADD CONSTRAINT FOREIGN KEY` is allowed in source DDL.
25    AlterTable(protobuf::AlterTableStmt),
26    /// `COMMENT ON ...`.
27    Comment(protobuf::CommentStmt),
28    /// `CREATE [OR REPLACE] VIEW ...`.
29    CreateView(protobuf::ViewStmt),
30    /// `CREATE MATERIALIZED VIEW ...`.
31    CreateMaterializedView(protobuf::CreateTableAsStmt),
32    /// `CREATE TYPE ... AS ENUM (...)`.
33    CreateEnum(protobuf::CreateEnumStmt),
34    /// `CREATE DOMAIN ... AS ...`.
35    CreateDomain(protobuf::CreateDomainStmt),
36    /// `CREATE TYPE ... AS (...)` (composite).
37    CreateCompositeType(protobuf::CompositeTypeStmt),
38    /// `CREATE [OR REPLACE] FUNCTION ...`.
39    CreateFunction(protobuf::CreateFunctionStmt),
40    /// `CREATE [OR REPLACE] PROCEDURE ...`.
41    CreateProcedure(protobuf::CreateFunctionStmt),
42    /// `CREATE EXTENSION ...`.
43    CreateExtension(protobuf::CreateExtensionStmt),
44    /// `CREATE [CONSTRAINT] TRIGGER ...`.
45    CreateTrigger(protobuf::CreateTrigStmt),
46    /// `ALTER TABLE parent ATTACH PARTITION child FOR VALUES ...`.
47    AlterTableAttachPartition(protobuf::AlterTableStmt),
48    /// `GRANT priv ON obj TO grantee` — object-level grant (not REVOKE, not role grant).
49    Grant(protobuf::GrantStmt),
50    /// `ALTER <kind> name OWNER TO role`.
51    AlterOwner(protobuf::AlterOwnerStmt),
52    /// `ALTER DEFAULT PRIVILEGES ... GRANT ...`.
53    AlterDefaultPrivileges(protobuf::AlterDefaultPrivilegesStmt),
54    /// `CREATE POLICY name ON table ...`.
55    CreatePolicy(protobuf::CreatePolicyStmt),
56    /// `CREATE PUBLICATION name ...`.
57    CreatePublication(protobuf::CreatePublicationStmt),
58    /// `ALTER PUBLICATION name ...`.
59    AlterPublication(protobuf::AlterPublicationStmt),
60}
61
62impl Statement {
63    /// Classify a node into the supported whitelist, or return
64    /// [`ParseError::UnsupportedObjectKind`] for anything else.
65    pub fn classify(node: NodeEnum, location: SourceLocation) -> Result<Self, ParseError> {
66        match node {
67            NodeEnum::CreateSchemaStmt(s) => Ok(Self::CreateSchema(s)),
68            NodeEnum::CreateStmt(s) => Ok(Self::CreateTable(s)),
69            NodeEnum::CreateSeqStmt(s) => Ok(Self::CreateSequence(s)),
70            NodeEnum::IndexStmt(s) => Ok(Self::CreateIndex(*s)),
71            NodeEnum::AlterTableStmt(s) => {
72                if is_attach_partition_stmt(&s) {
73                    Ok(Self::AlterTableAttachPartition(s))
74                } else {
75                    Ok(Self::AlterTable(s))
76                }
77            }
78            NodeEnum::CommentStmt(s) => Ok(Self::Comment(*s)),
79            NodeEnum::ViewStmt(s) => Ok(Self::CreateView(*s)),
80            NodeEnum::CreateEnumStmt(s) => Ok(Self::CreateEnum(s)),
81            NodeEnum::CreateDomainStmt(s) => Ok(Self::CreateDomain(*s)),
82            NodeEnum::CompositeTypeStmt(s) => Ok(Self::CreateCompositeType(s)),
83            NodeEnum::CreateFunctionStmt(s) => {
84                if s.is_procedure {
85                    Ok(Self::CreateProcedure(*s))
86                } else {
87                    Ok(Self::CreateFunction(*s))
88                }
89            }
90            NodeEnum::CreateExtensionStmt(s) => Ok(Self::CreateExtension(s)),
91            NodeEnum::CreateTrigStmt(s) => Ok(Self::CreateTrigger(*s)),
92            NodeEnum::GrantStmt(s) => Ok(Self::Grant(s)),
93            NodeEnum::AlterOwnerStmt(s) => Ok(Self::AlterOwner(*s)),
94            NodeEnum::AlterDefaultPrivilegesStmt(s) => Ok(Self::AlterDefaultPrivileges(s)),
95            NodeEnum::CreatePolicyStmt(s) => Ok(Self::CreatePolicy(*s)),
96            NodeEnum::CreatePublicationStmt(s) => Ok(Self::CreatePublication(s)),
97            NodeEnum::AlterPublicationStmt(s) => Ok(Self::AlterPublication(s)),
98            NodeEnum::RenameStmt(s) => {
99                use pg_query::protobuf::ObjectType;
100                let rename_type =
101                    ObjectType::try_from(s.rename_type).unwrap_or(ObjectType::Undefined);
102                if matches!(rename_type, ObjectType::ObjectPublication) {
103                    return Err(ParseError::Structural {
104                        location,
105                        message: "ALTER PUBLICATION … RENAME TO is not supported in pgevolve \
106                                  (pgevolve never models renames)"
107                            .into(),
108                    });
109                }
110                Err(unsupported(location, "RENAME"))
111            }
112            NodeEnum::AlterPolicyStmt(_) => Err(ParseError::Structural {
113                location,
114                message: "ALTER POLICY in source is not supported — policy modifications \
115                          happen via diff; use CREATE POLICY in source"
116                    .into(),
117            }),
118            NodeEnum::DropStmt(s) => {
119                let kind = ObjectType::try_from(s.remove_type).unwrap_or(ObjectType::Undefined);
120                if matches!(kind, ObjectType::ObjectPolicy) {
121                    return Err(ParseError::Structural {
122                        location,
123                        message: "DROP POLICY in source is not supported — drops happen \
124                                  via diff"
125                            .into(),
126                    });
127                }
128                Err(unsupported(location, "DROP"))
129            }
130            NodeEnum::AlterExtensionStmt(_) => Err(ParseError::Structural {
131                location,
132                message: "ALTER EXTENSION is not supported in source files — \
133                          declare the desired state via CREATE EXTENSION"
134                    .into(),
135            }),
136            NodeEnum::CreateTableAsStmt(s) => {
137                // `CREATE TABLE ... AS SELECT` and `CREATE MATERIALIZED VIEW`
138                // both arrive as CreateTableAsStmt. Route by the objtype field.
139                match ObjectType::try_from(s.objtype) {
140                    Ok(ObjectType::ObjectMatview) => Ok(Self::CreateMaterializedView(*s)),
141                    _ => Err(unsupported(location, "CREATE TABLE AS SELECT")),
142                }
143            }
144            other => Err(unsupported(location, friendly_kind(&other))),
145        }
146    }
147}
148
149/// Returns `true` when the `AlterTableStmt` is exactly one `ATTACH PARTITION`
150/// sub-command so the classifier can route it to the dedicated variant.
151fn is_attach_partition_stmt(stmt: &protobuf::AlterTableStmt) -> bool {
152    use pg_query::protobuf::AlterTableType;
153    if stmt.cmds.len() != 1 {
154        return false;
155    }
156    match &stmt.cmds[0].node {
157        Some(NodeEnum::AlterTableCmd(c)) => AlterTableType::try_from(c.subtype)
158            .ok()
159            .is_some_and(|t| matches!(t, AlterTableType::AtAttachPartition)),
160        _ => false,
161    }
162}
163
164const fn unsupported(location: SourceLocation, kind: &'static str) -> ParseError {
165    ParseError::UnsupportedObjectKind { location, kind }
166}
167
168/// Translate non-whitelisted node kinds to a human-readable label for diagnostics.
169#[allow(clippy::match_same_arms)]
170const fn friendly_kind(node: &NodeEnum) -> &'static str {
171    match node {
172        NodeEnum::ViewStmt(_) => "CREATE VIEW", // never reached — routed above
173        NodeEnum::CreateEnumStmt(_) => "CREATE TYPE ... AS ENUM", // never reached — routed above
174        NodeEnum::CreateFunctionStmt(_) => "CREATE FUNCTION/PROCEDURE", // never reached — routed above
175        NodeEnum::CreateTrigStmt(_) => "CREATE TRIGGER", // never reached — routed above
176        NodeEnum::CreateRangeStmt(_) => "CREATE TYPE ... AS RANGE",
177        NodeEnum::CreateExtensionStmt(_) => "CREATE EXTENSION", // never reached — routed above
178        NodeEnum::CreatePolicyStmt(_) => "CREATE POLICY",       // never reached — routed above
179        NodeEnum::CreateForeignTableStmt(_) => "CREATE FOREIGN TABLE",
180        NodeEnum::CreateFdwStmt(_) => "CREATE FOREIGN DATA WRAPPER",
181        NodeEnum::CreateForeignServerStmt(_) => "CREATE FOREIGN SERVER",
182        NodeEnum::CreateRoleStmt(_) => "CREATE ROLE",
183        NodeEnum::GrantStmt(_) => "GRANT/REVOKE", // never reached — routed above
184        NodeEnum::GrantRoleStmt(_) => "GRANT/REVOKE ROLE",
185        NodeEnum::CreateTableAsStmt(_) => "CREATE TABLE AS",
186        NodeEnum::CreateAmStmt(_) => "CREATE ACCESS METHOD",
187        NodeEnum::CreateEventTrigStmt(_) => "CREATE EVENT TRIGGER",
188        NodeEnum::CreateOpClassStmt(_) => "CREATE OPERATOR CLASS",
189        NodeEnum::CreateOpFamilyStmt(_) => "CREATE OPERATOR FAMILY",
190        NodeEnum::CreatePlangStmt(_) => "CREATE LANGUAGE",
191        NodeEnum::CreateStatsStmt(_) => "CREATE STATISTICS",
192        NodeEnum::CreateUserMappingStmt(_) => "CREATE USER MAPPING",
193        NodeEnum::CreateTableSpaceStmt(_) => "CREATE TABLESPACE",
194        NodeEnum::CreatedbStmt(_) => "CREATE DATABASE",
195        NodeEnum::DefineStmt(_) => "CREATE AGGREGATE/OPERATOR/etc.",
196        NodeEnum::RuleStmt(_) => "CREATE RULE",
197        NodeEnum::DropStmt(_) => "DROP",
198        NodeEnum::TruncateStmt(_) => "TRUNCATE",
199        NodeEnum::SelectStmt(_) => "SELECT",
200        NodeEnum::InsertStmt(_) => "INSERT",
201        NodeEnum::UpdateStmt(_) => "UPDATE",
202        NodeEnum::DeleteStmt(_) => "DELETE",
203        NodeEnum::CopyStmt(_) => "COPY",
204        NodeEnum::TransactionStmt(_) => "BEGIN/COMMIT/ROLLBACK",
205        _ => "this statement kind",
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::path::PathBuf;
213
214    fn loc() -> SourceLocation {
215        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
216    }
217
218    fn first_node(sql: &str) -> NodeEnum {
219        let parsed = pg_query::parse(sql).expect("pg_query parses");
220        parsed
221            .protobuf
222            .stmts
223            .into_iter()
224            .next()
225            .and_then(|raw| raw.stmt)
226            .and_then(|n| n.node)
227            .expect("at least one statement")
228    }
229
230    #[test]
231    fn create_table_classifies() {
232        let node = first_node("CREATE TABLE app.users (id integer);");
233        let stmt = Statement::classify(node, loc()).unwrap();
234        assert!(matches!(stmt, Statement::CreateTable(_)));
235    }
236
237    #[test]
238    fn create_schema_classifies() {
239        let node = first_node("CREATE SCHEMA app;");
240        let stmt = Statement::classify(node, loc()).unwrap();
241        assert!(matches!(stmt, Statement::CreateSchema(_)));
242    }
243
244    #[test]
245    fn create_index_classifies() {
246        let node = first_node("CREATE INDEX users_email_idx ON app.users (email);");
247        let stmt = Statement::classify(node, loc()).unwrap();
248        assert!(matches!(stmt, Statement::CreateIndex(_)));
249    }
250
251    #[test]
252    fn create_sequence_classifies() {
253        let node = first_node("CREATE SEQUENCE app.seq1;");
254        let stmt = Statement::classify(node, loc()).unwrap();
255        assert!(matches!(stmt, Statement::CreateSequence(_)));
256    }
257
258    #[test]
259    fn alter_table_classifies() {
260        let node = first_node(
261            "ALTER TABLE app.invoices ADD CONSTRAINT fk1 FOREIGN KEY (cid) REFERENCES app.c(id);",
262        );
263        let stmt = Statement::classify(node, loc()).unwrap();
264        assert!(matches!(stmt, Statement::AlterTable(_)));
265    }
266
267    #[test]
268    fn comment_classifies() {
269        let node = first_node("COMMENT ON TABLE app.users IS 'people';");
270        let stmt = Statement::classify(node, loc()).unwrap();
271        assert!(matches!(stmt, Statement::Comment(_)));
272    }
273
274    #[test]
275    fn create_view_classifies() {
276        let node = first_node("CREATE VIEW app.v AS SELECT 1;");
277        let stmt = Statement::classify(node, loc()).unwrap();
278        assert!(matches!(stmt, Statement::CreateView(_)));
279    }
280
281    #[test]
282    fn create_materialized_view_classifies() {
283        let node = first_node(
284            "CREATE MATERIALIZED VIEW app.mv AS SELECT count(*) FROM app.t WITH NO DATA;",
285        );
286        let stmt = Statement::classify(node, loc()).unwrap();
287        assert!(matches!(stmt, Statement::CreateMaterializedView(_)));
288    }
289
290    #[test]
291    fn create_table_as_select_unsupported() {
292        let node = first_node("CREATE TABLE app.t2 AS SELECT 1;");
293        let err = Statement::classify(node, loc()).unwrap_err();
294        assert!(matches!(err, ParseError::UnsupportedObjectKind { .. }));
295    }
296
297    #[test]
298    fn create_function_classifies() {
299        let node =
300            first_node("CREATE FUNCTION app.f() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql;");
301        let stmt = Statement::classify(node, loc()).unwrap();
302        assert!(matches!(stmt, Statement::CreateFunction(_)));
303    }
304
305    #[test]
306    fn create_procedure_classifies() {
307        let node =
308            first_node("CREATE PROCEDURE app.p() LANGUAGE plpgsql AS $$ BEGIN NULL; END $$;");
309        let stmt = Statement::classify(node, loc()).unwrap();
310        assert!(matches!(stmt, Statement::CreateProcedure(_)));
311    }
312
313    #[test]
314    fn create_extension_classifies() {
315        let node = first_node("CREATE EXTENSION pgcrypto;");
316        let stmt = Statement::classify(node, loc()).unwrap();
317        assert!(matches!(stmt, Statement::CreateExtension(_)));
318    }
319
320    #[test]
321    fn alter_extension_rejected() {
322        let node = first_node("ALTER EXTENSION pgcrypto UPDATE;");
323        let err = Statement::classify(node, loc()).unwrap_err();
324        assert!(matches!(err, ParseError::Structural { .. }));
325    }
326
327    #[test]
328    fn select_unsupported() {
329        let node = first_node("SELECT 1;");
330        let err = Statement::classify(node, loc()).unwrap_err();
331        assert!(matches!(err, ParseError::UnsupportedObjectKind { .. }));
332    }
333}