Skip to main content

pgevolve_core/parse/builder/
create_trigger_stmt.rs

1//! Source parser for `CREATE [CONSTRAINT] TRIGGER` statements.
2//!
3//! Accepts the full PG syntax:
4//!
5//! ```sql
6//! CREATE [ CONSTRAINT ] TRIGGER name { BEFORE | AFTER | INSTEAD OF }
7//!     { event [ OR ... ] }
8//!     ON table_name
9//!     [ NOT DEFERRABLE | DEFERRABLE [ INITIALLY IMMEDIATE | INITIALLY DEFERRED ] ]
10//!     [ REFERENCING { { OLD | NEW } TABLE [ AS ] transition_relation_name } [ ... ] ]
11//!     [ FOR [ EACH ] { ROW | STATEMENT } ]
12//!     [ WHEN ( condition ) ]
13//!     EXECUTE { FUNCTION | PROCEDURE } function_name ( arguments )
14//! ```
15//!
16//! Rejects `FROM referenced_table_name` (the constraint-trigger `FROM` clause)
17//! with [`ParseError::Structural`].
18//!
19//! Validates that `CONSTRAINT TRIGGER` is `AFTER` + `FOR EACH ROW`.
20//!
21//! # WHEN clause canonicalization
22//!
23//! The WHEN expression is canonicalized by wrapping it in a synthetic
24//! `SELECT <expr>` scaffold, deparsing via `pg_query::deparse`, stripping
25//! the `SELECT ` prefix, and feeding the result to
26//! [`NormalizedExpr::from_text`] after keyword lowercasing. This is the
27//! same approach used by [`crate::parse::normalize_expr::from_pg_node`].
28
29use pg_query::NodeEnum;
30use pg_query::protobuf::{CreateTrigStmt, Node};
31
32use crate::identifier::{Identifier, QualifiedName};
33use crate::ir::constraint::Deferrable;
34use crate::ir::default_expr::NormalizedExpr;
35use crate::ir::trigger::{
36    TransitionKind, TransitionTable, Trigger, TriggerEvent, TriggerLevel, TriggerTiming,
37};
38use crate::parse::error::{ParseError, SourceLocation};
39use crate::parse::normalize_expr;
40
41/// Build a [`Trigger`] from a parsed `CreateTrigStmt` AST node.
42pub(crate) fn build_trigger(
43    stmt: &CreateTrigStmt,
44    location: &SourceLocation,
45) -> Result<Trigger, ParseError> {
46    let name = Identifier::from_unquoted(&stmt.trigname).map_err(|e| ParseError::Structural {
47        location: location.clone(),
48        message: format!("CREATE TRIGGER: invalid name '{}': {e}", stmt.trigname),
49    })?;
50
51    let table = range_var_to_qname(stmt.relation.as_ref(), location, "trigger target table")?;
52    // Trigger qname uses the table's schema.
53    let qname = QualifiedName::new(table.schema.clone(), name);
54
55    if stmt.constrrel.is_some() {
56        return Err(ParseError::Structural {
57            location: location.clone(),
58            message: format!(
59                "{qname}: CREATE CONSTRAINT TRIGGER ... FROM ref_table is not supported."
60            ),
61        });
62    }
63
64    let timing = parse_timing(stmt.timing, location, &qname)?;
65    let events = parse_events(stmt.events, &stmt.columns, location, &qname)?;
66    let level = if stmt.row {
67        TriggerLevel::Row
68    } else {
69        TriggerLevel::Statement
70    };
71
72    let when_clause = match stmt.when_clause.as_deref() {
73        Some(node) => Some(node_to_normalized_expr(node, location, &qname)?),
74        None => None,
75    };
76
77    let transition_tables = parse_transition_rels(&stmt.transition_rels, location, &qname)?;
78
79    let function_qname = qualified_name_from_list(&stmt.funcname, location, "trigger function")?;
80    let function_args = string_list(&stmt.args);
81
82    let is_constraint = stmt.isconstraint;
83    let deferrable = if is_constraint {
84        if stmt.deferrable {
85            Deferrable::Deferrable {
86                initially_deferred: stmt.initdeferred,
87            }
88        } else {
89            Deferrable::NotDeferrable
90        }
91    } else {
92        // Non-constraint triggers are always NotDeferrable in PG.
93        Deferrable::NotDeferrable
94    };
95
96    // Constraint triggers must be AFTER + FOR EACH ROW (PG enforces this).
97    if is_constraint {
98        if !matches!(timing, TriggerTiming::After) {
99            return Err(ParseError::Structural {
100                location: location.clone(),
101                message: format!("{qname}: CONSTRAINT TRIGGER must be AFTER."),
102            });
103        }
104        if !matches!(level, TriggerLevel::Row) {
105            return Err(ParseError::Structural {
106                location: location.clone(),
107                message: format!("{qname}: CONSTRAINT TRIGGER must be FOR EACH ROW."),
108            });
109        }
110    }
111
112    Ok(Trigger {
113        qname,
114        table,
115        timing,
116        events,
117        level,
118        when_clause,
119        transition_tables,
120        function_qname,
121        function_args,
122        is_constraint,
123        deferrable,
124        comment: None,
125    })
126}
127
128fn parse_timing(
129    timing: i32,
130    location: &SourceLocation,
131    qname: &QualifiedName,
132) -> Result<TriggerTiming, ParseError> {
133    // PG's CreateTrigStmt.timing is a bitmask. Mask to the timing bits only.
134    const TRIGGER_TYPE_BEFORE: i32 = 1 << 1; // 2
135    const TRIGGER_TYPE_INSTEAD: i32 = 1 << 6; // 64
136
137    let bits = timing & (TRIGGER_TYPE_BEFORE | TRIGGER_TYPE_INSTEAD);
138    match bits {
139        b if b == TRIGGER_TYPE_BEFORE => Ok(TriggerTiming::Before),
140        b if b == TRIGGER_TYPE_INSTEAD => Ok(TriggerTiming::InsteadOf),
141        0 => Ok(TriggerTiming::After),
142        _ => Err(ParseError::Structural {
143            location: location.clone(),
144            message: format!("{qname}: invalid trigger timing bits 0x{timing:x}"),
145        }),
146    }
147}
148
149fn parse_events(
150    events: i32,
151    update_columns: &[pg_query::protobuf::Node],
152    location: &SourceLocation,
153    qname: &QualifiedName,
154) -> Result<Vec<TriggerEvent>, ParseError> {
155    const TRIGGER_TYPE_INSERT: i32 = 1 << 2; // 4
156    const TRIGGER_TYPE_DELETE: i32 = 1 << 3; // 8
157    const TRIGGER_TYPE_UPDATE: i32 = 1 << 4; // 16
158    const TRIGGER_TYPE_TRUNCATE: i32 = 1 << 5; // 32
159
160    let mut out = Vec::new();
161    if events & TRIGGER_TYPE_INSERT != 0 {
162        out.push(TriggerEvent::Insert);
163    }
164    if events & TRIGGER_TYPE_UPDATE != 0 {
165        let cols = update_columns
166            .iter()
167            .filter_map(|n| match n.node.as_ref() {
168                Some(NodeEnum::String(s)) => Identifier::from_unquoted(&s.sval).ok(),
169                _ => None,
170            })
171            .collect();
172        out.push(TriggerEvent::Update { columns: cols });
173    }
174    if events & TRIGGER_TYPE_DELETE != 0 {
175        out.push(TriggerEvent::Delete);
176    }
177    if events & TRIGGER_TYPE_TRUNCATE != 0 {
178        out.push(TriggerEvent::Truncate);
179    }
180    if out.is_empty() {
181        return Err(ParseError::Structural {
182            location: location.clone(),
183            message: format!("{qname}: trigger declares no events (bits=0x{events:x})"),
184        });
185    }
186    Ok(out)
187}
188
189fn parse_transition_rels(
190    rels: &[pg_query::protobuf::Node],
191    location: &SourceLocation,
192    qname: &QualifiedName,
193) -> Result<Vec<TransitionTable>, ParseError> {
194    let mut out = Vec::new();
195    for node in rels {
196        let Some(NodeEnum::TriggerTransition(trans)) = node.node.as_ref() else {
197            continue;
198        };
199        let name = Identifier::from_unquoted(&trans.name).map_err(|e| ParseError::Structural {
200            location: location.clone(),
201            message: format!(
202                "{qname}: invalid transition table name '{}': {e}",
203                trans.name
204            ),
205        })?;
206        let kind = if trans.is_new {
207            TransitionKind::NewTable
208        } else {
209            TransitionKind::OldTable
210        };
211        // `is_table` should always be true (PG only allows TABLE form, not ROW).
212        if !trans.is_table {
213            return Err(ParseError::Structural {
214                location: location.clone(),
215                message: format!("{qname}: REFERENCING must use NEW/OLD TABLE form."),
216            });
217        }
218        out.push(TransitionTable { name, kind });
219    }
220    Ok(out)
221}
222
223/// Convert a raw `Node` from the WHEN clause into a [`NormalizedExpr`].
224///
225/// Uses the same SELECT-scaffold deparse technique as
226/// [`crate::parse::normalize_expr::from_pg_node`]: wraps the expression in
227/// `SELECT <expr>`, deparses the whole statement, strips the `SELECT ` prefix,
228/// then lowercases reserved keywords before hashing.
229fn node_to_normalized_expr(
230    node: &Node,
231    location: &SourceLocation,
232    qname: &QualifiedName,
233) -> Result<NormalizedExpr, ParseError> {
234    let inner_enum = node.node.as_ref().ok_or_else(|| ParseError::Structural {
235        location: location.clone(),
236        message: format!("{qname}: WHEN clause node has no inner node"),
237    })?;
238    normalize_expr::from_pg_node(inner_enum, None, location).map_err(|e| ParseError::Structural {
239        location: location.clone(),
240        message: format!("{qname}: failed to canonicalize WHEN clause: {e}"),
241    })
242}
243
244fn range_var_to_qname(
245    rv: Option<&pg_query::protobuf::RangeVar>,
246    location: &SourceLocation,
247    context: &str,
248) -> Result<QualifiedName, ParseError> {
249    let rv = rv.ok_or_else(|| ParseError::Structural {
250        location: location.clone(),
251        message: format!("{context}: missing relation"),
252    })?;
253    if rv.schemaname.is_empty() {
254        return Err(ParseError::Structural {
255            location: location.clone(),
256            message: format!(
257                "{context}: relation '{}' has no schema qualifier",
258                rv.relname
259            ),
260        });
261    }
262    let schema = Identifier::from_unquoted(&rv.schemaname).map_err(|e| ParseError::Structural {
263        location: location.clone(),
264        message: format!("{context}: invalid schema '{}': {e}", rv.schemaname),
265    })?;
266    let name = Identifier::from_unquoted(&rv.relname).map_err(|e| ParseError::Structural {
267        location: location.clone(),
268        message: format!("{context}: invalid name '{}': {e}", rv.relname),
269    })?;
270    Ok(QualifiedName::new(schema, name))
271}
272
273fn qualified_name_from_list(
274    nodes: &[pg_query::protobuf::Node],
275    location: &SourceLocation,
276    context: &str,
277) -> Result<QualifiedName, ParseError> {
278    let parts: Vec<String> = nodes
279        .iter()
280        .filter_map(|n| match n.node.as_ref() {
281            Some(NodeEnum::String(s)) => Some(s.sval.clone()),
282            _ => None,
283        })
284        .collect();
285    let (schema_str, name_str) = match parts.as_slice() {
286        [s, n] => (s.clone(), n.clone()),
287        [n] => {
288            return Err(ParseError::Structural {
289                location: location.clone(),
290                message: format!("{context}: '{n}' must be schema-qualified"),
291            });
292        }
293        _ => {
294            return Err(ParseError::Structural {
295                location: location.clone(),
296                message: format!("{context}: unexpected name shape {parts:?}"),
297            });
298        }
299    };
300    let schema = Identifier::from_unquoted(&schema_str).map_err(|e| ParseError::Structural {
301        location: location.clone(),
302        message: format!("{context}: invalid schema '{schema_str}': {e}"),
303    })?;
304    let name = Identifier::from_unquoted(&name_str).map_err(|e| ParseError::Structural {
305        location: location.clone(),
306        message: format!("{context}: invalid name '{name_str}': {e}"),
307    })?;
308    Ok(QualifiedName::new(schema, name))
309}
310
311fn string_list(nodes: &[pg_query::protobuf::Node]) -> Vec<String> {
312    nodes
313        .iter()
314        .filter_map(|n| match n.node.as_ref() {
315            Some(NodeEnum::String(s)) => Some(s.sval.clone()),
316            _ => None,
317        })
318        .collect()
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn loc() -> SourceLocation {
326        SourceLocation::new(std::path::PathBuf::from("<test>"), 1, 1)
327    }
328
329    fn parse_trigger(sql: &str) -> Result<Trigger, ParseError> {
330        let parsed = pg_query::parse(sql).expect("pg_query");
331        let stmt = parsed
332            .protobuf
333            .stmts
334            .into_iter()
335            .next()
336            .expect("at least one statement")
337            .stmt
338            .expect("stmt")
339            .node
340            .expect("node");
341        match stmt {
342            NodeEnum::CreateTrigStmt(s) => build_trigger(&s, &loc()),
343            other => panic!("expected CreateTrigStmt, got {other:?}"),
344        }
345    }
346
347    #[test]
348    fn parses_simple_before_insert_row_trigger() {
349        let t = parse_trigger(
350            "CREATE TRIGGER t1 BEFORE INSERT ON app.users \
351             FOR EACH ROW EXECUTE FUNCTION app.f();",
352        )
353        .unwrap();
354        assert_eq!(t.qname.to_string(), "app.t1");
355        assert_eq!(t.table.to_string(), "app.users");
356        assert!(matches!(t.timing, TriggerTiming::Before));
357        assert_eq!(t.events, vec![TriggerEvent::Insert]);
358        assert!(matches!(t.level, TriggerLevel::Row));
359        assert!(t.when_clause.is_none());
360        assert_eq!(t.function_qname.to_string(), "app.f");
361        assert!(!t.is_constraint);
362    }
363
364    #[test]
365    fn parses_after_statement_trigger() {
366        let t = parse_trigger(
367            "CREATE TRIGGER t2 AFTER INSERT ON app.users \
368             FOR EACH STATEMENT EXECUTE FUNCTION app.f();",
369        )
370        .unwrap();
371        assert!(matches!(t.timing, TriggerTiming::After));
372        assert!(matches!(t.level, TriggerLevel::Statement));
373    }
374
375    #[test]
376    fn parses_multi_event_trigger() {
377        let t = parse_trigger(
378            "CREATE TRIGGER t BEFORE INSERT OR UPDATE OR DELETE ON app.users \
379             FOR EACH ROW EXECUTE FUNCTION app.f();",
380        )
381        .unwrap();
382        assert_eq!(t.events.len(), 3);
383        assert!(t.events.contains(&TriggerEvent::Insert));
384        assert!(t.events.contains(&TriggerEvent::Delete));
385        assert!(matches!(t.events[1], TriggerEvent::Update { .. }));
386    }
387
388    #[test]
389    fn parses_update_of_columns() {
390        let t = parse_trigger(
391            "CREATE TRIGGER t BEFORE UPDATE OF a, b ON app.users \
392             FOR EACH ROW EXECUTE FUNCTION app.f();",
393        )
394        .unwrap();
395        let cols = match &t.events[0] {
396            TriggerEvent::Update { columns } => columns,
397            other => panic!("expected Update, got {other:?}"),
398        };
399        assert_eq!(cols.len(), 2);
400        assert_eq!(cols[0].as_str(), "a");
401        assert_eq!(cols[1].as_str(), "b");
402    }
403
404    #[test]
405    fn parses_constraint_trigger_deferrable_initially_deferred() {
406        let t = parse_trigger(
407            "CREATE CONSTRAINT TRIGGER t AFTER INSERT ON app.users \
408             DEFERRABLE INITIALLY DEFERRED \
409             FOR EACH ROW EXECUTE FUNCTION app.f();",
410        )
411        .unwrap();
412        assert!(t.is_constraint);
413        assert!(matches!(
414            t.deferrable,
415            Deferrable::Deferrable {
416                initially_deferred: true
417            }
418        ));
419    }
420
421    #[test]
422    fn rejects_constraint_trigger_with_before_timing() {
423        // PostgreSQL itself rejects CONSTRAINT TRIGGER BEFORE at parse time,
424        // so we construct the AST directly to exercise our validation path.
425        use pg_query::protobuf::{CreateTrigStmt, RangeVar};
426        // timing = BEFORE (bit 1 = 2), events = INSERT (bit 2 = 4)
427        let stmt = CreateTrigStmt {
428            replace: false,
429            isconstraint: true,
430            trigname: "t".into(),
431            relation: Some(RangeVar {
432                catalogname: String::new(),
433                schemaname: "app".into(),
434                relname: "users".into(),
435                inh: true,
436                relpersistence: "p".into(),
437                alias: None,
438                location: -1,
439            }),
440            funcname: {
441                // build app.f node list
442                let s1 = pg_query::protobuf::Node {
443                    node: Some(NodeEnum::String(pg_query::protobuf::String {
444                        sval: "app".into(),
445                    })),
446                };
447                let s2 = pg_query::protobuf::Node {
448                    node: Some(NodeEnum::String(pg_query::protobuf::String {
449                        sval: "f".into(),
450                    })),
451                };
452                vec![s1, s2]
453            },
454            args: vec![],
455            row: true,
456            timing: 2, // BEFORE
457            events: 4, // INSERT
458            columns: vec![],
459            when_clause: None,
460            transition_rels: vec![],
461            deferrable: false,
462            initdeferred: false,
463            constrrel: None,
464        };
465        let err = build_trigger(&stmt, &loc()).unwrap_err();
466        assert!(
467            err.to_string().contains("CONSTRAINT TRIGGER must be AFTER"),
468            "got {err}"
469        );
470    }
471
472    #[test]
473    fn parses_when_clause() {
474        let t = parse_trigger(
475            "CREATE TRIGGER t BEFORE INSERT ON app.users \
476             FOR EACH ROW WHEN (NEW.id > 0) EXECUTE FUNCTION app.f();",
477        )
478        .unwrap();
479        assert!(t.when_clause.is_some());
480    }
481
482    #[test]
483    fn parses_transition_tables() {
484        let t = parse_trigger(
485            "CREATE TRIGGER t AFTER INSERT ON app.users \
486             REFERENCING NEW TABLE AS new_rows \
487             FOR EACH STATEMENT EXECUTE FUNCTION app.f();",
488        )
489        .unwrap();
490        assert_eq!(t.transition_tables.len(), 1);
491        assert_eq!(t.transition_tables[0].name.as_str(), "new_rows");
492        assert!(matches!(
493            t.transition_tables[0].kind,
494            TransitionKind::NewTable
495        ));
496    }
497
498    #[test]
499    fn execute_procedure_is_synonym_for_execute_function() {
500        let t = parse_trigger(
501            "CREATE TRIGGER t BEFORE INSERT ON app.users \
502             FOR EACH ROW EXECUTE PROCEDURE app.f();",
503        )
504        .unwrap();
505        assert_eq!(t.function_qname.to_string(), "app.f");
506    }
507}