use pg_query::NodeEnum;
use pg_query::protobuf::{CreateTrigStmt, Node};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::constraint::Deferrable;
use crate::ir::default_expr::NormalizedExpr;
use crate::ir::trigger::{
TransitionKind, TransitionTable, Trigger, TriggerEvent, TriggerLevel, TriggerTiming,
};
use crate::parse::error::{ParseError, SourceLocation};
use crate::parse::normalize_expr;
pub(crate) fn build_trigger(
stmt: &CreateTrigStmt,
location: &SourceLocation,
) -> Result<Trigger, ParseError> {
let name = Identifier::from_unquoted(&stmt.trigname).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("CREATE TRIGGER: invalid name '{}': {e}", stmt.trigname),
})?;
let table = range_var_to_qname(stmt.relation.as_ref(), location, "trigger target table")?;
let qname = QualifiedName::new(table.schema.clone(), name);
if stmt.constrrel.is_some() {
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"{qname}: CREATE CONSTRAINT TRIGGER ... FROM ref_table is not supported."
),
});
}
let timing = parse_timing(stmt.timing, location, &qname)?;
let events = parse_events(stmt.events, &stmt.columns, location, &qname)?;
let level = if stmt.row {
TriggerLevel::Row
} else {
TriggerLevel::Statement
};
let when_clause = match stmt.when_clause.as_deref() {
Some(node) => Some(node_to_normalized_expr(node, location, &qname)?),
None => None,
};
let transition_tables = parse_transition_rels(&stmt.transition_rels, location, &qname)?;
let function_qname = qualified_name_from_list(&stmt.funcname, location, "trigger function")?;
let function_args = string_list(&stmt.args);
let is_constraint = stmt.isconstraint;
let deferrable = if is_constraint {
if stmt.deferrable {
Deferrable::Deferrable {
initially_deferred: stmt.initdeferred,
}
} else {
Deferrable::NotDeferrable
}
} else {
Deferrable::NotDeferrable
};
if is_constraint {
if !matches!(timing, TriggerTiming::After) {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{qname}: CONSTRAINT TRIGGER must be AFTER."),
});
}
if !matches!(level, TriggerLevel::Row) {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{qname}: CONSTRAINT TRIGGER must be FOR EACH ROW."),
});
}
}
Ok(Trigger {
qname,
table,
timing,
events,
level,
when_clause,
transition_tables,
function_qname,
function_args,
is_constraint,
deferrable,
comment: None,
})
}
fn parse_timing(
timing: i32,
location: &SourceLocation,
qname: &QualifiedName,
) -> Result<TriggerTiming, ParseError> {
const TRIGGER_TYPE_BEFORE: i32 = 1 << 1; const TRIGGER_TYPE_INSTEAD: i32 = 1 << 6;
let bits = timing & (TRIGGER_TYPE_BEFORE | TRIGGER_TYPE_INSTEAD);
match bits {
b if b == TRIGGER_TYPE_BEFORE => Ok(TriggerTiming::Before),
b if b == TRIGGER_TYPE_INSTEAD => Ok(TriggerTiming::InsteadOf),
0 => Ok(TriggerTiming::After),
_ => Err(ParseError::Structural {
location: location.clone(),
message: format!("{qname}: invalid trigger timing bits 0x{timing:x}"),
}),
}
}
fn parse_events(
events: i32,
update_columns: &[pg_query::protobuf::Node],
location: &SourceLocation,
qname: &QualifiedName,
) -> Result<Vec<TriggerEvent>, ParseError> {
const TRIGGER_TYPE_INSERT: i32 = 1 << 2; const TRIGGER_TYPE_DELETE: i32 = 1 << 3; const TRIGGER_TYPE_UPDATE: i32 = 1 << 4; const TRIGGER_TYPE_TRUNCATE: i32 = 1 << 5;
let mut out = Vec::new();
if events & TRIGGER_TYPE_INSERT != 0 {
out.push(TriggerEvent::Insert);
}
if events & TRIGGER_TYPE_UPDATE != 0 {
let cols = update_columns
.iter()
.filter_map(|n| match n.node.as_ref() {
Some(NodeEnum::String(s)) => Identifier::from_unquoted(&s.sval).ok(),
_ => None,
})
.collect();
out.push(TriggerEvent::Update { columns: cols });
}
if events & TRIGGER_TYPE_DELETE != 0 {
out.push(TriggerEvent::Delete);
}
if events & TRIGGER_TYPE_TRUNCATE != 0 {
out.push(TriggerEvent::Truncate);
}
if out.is_empty() {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{qname}: trigger declares no events (bits=0x{events:x})"),
});
}
Ok(out)
}
fn parse_transition_rels(
rels: &[pg_query::protobuf::Node],
location: &SourceLocation,
qname: &QualifiedName,
) -> Result<Vec<TransitionTable>, ParseError> {
let mut out = Vec::new();
for node in rels {
let Some(NodeEnum::TriggerTransition(trans)) = node.node.as_ref() else {
continue;
};
let name = Identifier::from_unquoted(&trans.name).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!(
"{qname}: invalid transition table name '{}': {e}",
trans.name
),
})?;
let kind = if trans.is_new {
TransitionKind::NewTable
} else {
TransitionKind::OldTable
};
if !trans.is_table {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{qname}: REFERENCING must use NEW/OLD TABLE form."),
});
}
out.push(TransitionTable { name, kind });
}
Ok(out)
}
fn node_to_normalized_expr(
node: &Node,
location: &SourceLocation,
qname: &QualifiedName,
) -> Result<NormalizedExpr, ParseError> {
let inner_enum = node.node.as_ref().ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: format!("{qname}: WHEN clause node has no inner node"),
})?;
normalize_expr::from_pg_node(inner_enum, None, location).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("{qname}: failed to canonicalize WHEN clause: {e}"),
})
}
fn range_var_to_qname(
rv: Option<&pg_query::protobuf::RangeVar>,
location: &SourceLocation,
context: &str,
) -> Result<QualifiedName, ParseError> {
let rv = rv.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: format!("{context}: missing relation"),
})?;
if rv.schemaname.is_empty() {
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"{context}: relation '{}' has no schema qualifier",
rv.relname
),
});
}
let schema = Identifier::from_unquoted(&rv.schemaname).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("{context}: invalid schema '{}': {e}", rv.schemaname),
})?;
let name = Identifier::from_unquoted(&rv.relname).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("{context}: invalid name '{}': {e}", rv.relname),
})?;
Ok(QualifiedName::new(schema, name))
}
fn qualified_name_from_list(
nodes: &[pg_query::protobuf::Node],
location: &SourceLocation,
context: &str,
) -> Result<QualifiedName, ParseError> {
let parts: Vec<String> = nodes
.iter()
.filter_map(|n| match n.node.as_ref() {
Some(NodeEnum::String(s)) => Some(s.sval.clone()),
_ => None,
})
.collect();
let (schema_str, name_str) = match parts.as_slice() {
[s, n] => (s.clone(), n.clone()),
[n] => {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{context}: '{n}' must be schema-qualified"),
});
}
_ => {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("{context}: unexpected name shape {parts:?}"),
});
}
};
let schema = Identifier::from_unquoted(&schema_str).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("{context}: invalid schema '{schema_str}': {e}"),
})?;
let name = Identifier::from_unquoted(&name_str).map_err(|e| ParseError::Structural {
location: location.clone(),
message: format!("{context}: invalid name '{name_str}': {e}"),
})?;
Ok(QualifiedName::new(schema, name))
}
fn string_list(nodes: &[pg_query::protobuf::Node]) -> Vec<String> {
nodes
.iter()
.filter_map(|n| match n.node.as_ref() {
Some(NodeEnum::String(s)) => Some(s.sval.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn loc() -> SourceLocation {
SourceLocation::new(std::path::PathBuf::from("<test>"), 1, 1)
}
fn parse_trigger(sql: &str) -> Result<Trigger, ParseError> {
let parsed = pg_query::parse(sql).expect("pg_query");
let stmt = parsed
.protobuf
.stmts
.into_iter()
.next()
.expect("at least one statement")
.stmt
.expect("stmt")
.node
.expect("node");
match stmt {
NodeEnum::CreateTrigStmt(s) => build_trigger(&s, &loc()),
other => panic!("expected CreateTrigStmt, got {other:?}"),
}
}
#[test]
fn parses_simple_before_insert_row_trigger() {
let t = parse_trigger(
"CREATE TRIGGER t1 BEFORE INSERT ON app.users \
FOR EACH ROW EXECUTE FUNCTION app.f();",
)
.unwrap();
assert_eq!(t.qname.to_string(), "app.t1");
assert_eq!(t.table.to_string(), "app.users");
assert!(matches!(t.timing, TriggerTiming::Before));
assert_eq!(t.events, vec![TriggerEvent::Insert]);
assert!(matches!(t.level, TriggerLevel::Row));
assert!(t.when_clause.is_none());
assert_eq!(t.function_qname.to_string(), "app.f");
assert!(!t.is_constraint);
}
#[test]
fn parses_after_statement_trigger() {
let t = parse_trigger(
"CREATE TRIGGER t2 AFTER INSERT ON app.users \
FOR EACH STATEMENT EXECUTE FUNCTION app.f();",
)
.unwrap();
assert!(matches!(t.timing, TriggerTiming::After));
assert!(matches!(t.level, TriggerLevel::Statement));
}
#[test]
fn parses_multi_event_trigger() {
let t = parse_trigger(
"CREATE TRIGGER t BEFORE INSERT OR UPDATE OR DELETE ON app.users \
FOR EACH ROW EXECUTE FUNCTION app.f();",
)
.unwrap();
assert_eq!(t.events.len(), 3);
assert!(t.events.contains(&TriggerEvent::Insert));
assert!(t.events.contains(&TriggerEvent::Delete));
assert!(matches!(t.events[1], TriggerEvent::Update { .. }));
}
#[test]
fn parses_update_of_columns() {
let t = parse_trigger(
"CREATE TRIGGER t BEFORE UPDATE OF a, b ON app.users \
FOR EACH ROW EXECUTE FUNCTION app.f();",
)
.unwrap();
let cols = match &t.events[0] {
TriggerEvent::Update { columns } => columns,
other => panic!("expected Update, got {other:?}"),
};
assert_eq!(cols.len(), 2);
assert_eq!(cols[0].as_str(), "a");
assert_eq!(cols[1].as_str(), "b");
}
#[test]
fn parses_constraint_trigger_deferrable_initially_deferred() {
let t = parse_trigger(
"CREATE CONSTRAINT TRIGGER t AFTER INSERT ON app.users \
DEFERRABLE INITIALLY DEFERRED \
FOR EACH ROW EXECUTE FUNCTION app.f();",
)
.unwrap();
assert!(t.is_constraint);
assert!(matches!(
t.deferrable,
Deferrable::Deferrable {
initially_deferred: true
}
));
}
#[test]
fn rejects_constraint_trigger_with_before_timing() {
use pg_query::protobuf::{CreateTrigStmt, RangeVar};
let stmt = CreateTrigStmt {
replace: false,
isconstraint: true,
trigname: "t".into(),
relation: Some(RangeVar {
catalogname: String::new(),
schemaname: "app".into(),
relname: "users".into(),
inh: true,
relpersistence: "p".into(),
alias: None,
location: -1,
}),
funcname: {
let s1 = pg_query::protobuf::Node {
node: Some(NodeEnum::String(pg_query::protobuf::String {
sval: "app".into(),
})),
};
let s2 = pg_query::protobuf::Node {
node: Some(NodeEnum::String(pg_query::protobuf::String {
sval: "f".into(),
})),
};
vec![s1, s2]
},
args: vec![],
row: true,
timing: 2, events: 4, columns: vec![],
when_clause: None,
transition_rels: vec![],
deferrable: false,
initdeferred: false,
constrrel: None,
};
let err = build_trigger(&stmt, &loc()).unwrap_err();
assert!(
err.to_string().contains("CONSTRAINT TRIGGER must be AFTER"),
"got {err}"
);
}
#[test]
fn parses_when_clause() {
let t = parse_trigger(
"CREATE TRIGGER t BEFORE INSERT ON app.users \
FOR EACH ROW WHEN (NEW.id > 0) EXECUTE FUNCTION app.f();",
)
.unwrap();
assert!(t.when_clause.is_some());
}
#[test]
fn parses_transition_tables() {
let t = parse_trigger(
"CREATE TRIGGER t AFTER INSERT ON app.users \
REFERENCING NEW TABLE AS new_rows \
FOR EACH STATEMENT EXECUTE FUNCTION app.f();",
)
.unwrap();
assert_eq!(t.transition_tables.len(), 1);
assert_eq!(t.transition_tables[0].name.as_str(), "new_rows");
assert!(matches!(
t.transition_tables[0].kind,
TransitionKind::NewTable
));
}
#[test]
fn execute_procedure_is_synonym_for_execute_function() {
let t = parse_trigger(
"CREATE TRIGGER t BEFORE INSERT ON app.users \
FOR EACH ROW EXECUTE PROCEDURE app.f();",
)
.unwrap();
assert_eq!(t.function_qname.to_string(), "app.f");
}
}