vibesql_executor/trigger_ddl.rs
1//! Trigger DDL execution module
2//!
3//! Handles CREATE TRIGGER, ALTER TRIGGER, and DROP TRIGGER statements
4
5use vibesql_ast::{
6 AlterTriggerAction, AlterTriggerStmt, CreateTriggerStmt, DropTriggerStmt, TriggerTiming,
7};
8use vibesql_catalog::TriggerDefinition;
9use vibesql_storage::Database;
10
11use crate::errors::ExecutorError;
12
13/// Executor for trigger DDL operations
14pub struct TriggerExecutor;
15
16impl TriggerExecutor {
17 /// Execute a CREATE TRIGGER statement.
18 ///
19 /// This is a convenience wrapper around [`TriggerExecutor::create_trigger_with_sql`]
20 /// that does not preserve the original SQL text. Triggers created via this path
21 /// will not survive SQL-dump persistence round-trips.
22 pub fn create_trigger(
23 db: &mut Database,
24 stmt: &CreateTriggerStmt,
25 ) -> Result<String, ExecutorError> {
26 Self::create_trigger_with_sql(db, stmt, None)
27 }
28
29 /// Execute a CREATE TRIGGER statement, optionally preserving the original SQL text
30 /// for SQL-dump persistence.
31 ///
32 /// When `original_sql` is `Some`, the SQL is stored on the catalog
33 /// [`TriggerDefinition`] so it can be re-emitted verbatim by `save_sql_dump`.
34 /// This is the path used by the CLI / Python / WASM frontends, which are the
35 /// only callers that have access to the original textual SQL.
36 pub fn create_trigger_with_sql(
37 db: &mut Database,
38 stmt: &CreateTriggerStmt,
39 original_sql: Option<&str>,
40 ) -> Result<String, ExecutorError> {
41 // `CREATE TRIGGER IF NOT EXISTS <name>` is a no-op success when a
42 // trigger with that name already exists (SQLite semantics, trigger1-1.2.0).
43 // SQLite resolves the existing-trigger check before validating the target
44 // object, so an already-present trigger short-circuits here.
45 if db.catalog.get_trigger(&stmt.trigger_name).is_some() {
46 if stmt.if_not_exists {
47 return Ok(format!("Trigger '{}' already exists", stmt.trigger_name));
48 }
49 // A bare `CREATE TRIGGER` over an existing name is an error. SQLite
50 // echoes the trigger name *exactly as written in the source*,
51 // preserving its quoting form — `trigger "tr1" already exists` /
52 // `trigger [tr1] already exists` (trigger1-1.2.2/1.2.3). The parser
53 // captures that verbatim spelling in `name_source`; fall back to the
54 // normalized name when it is unavailable (e.g. programmatic AST).
55 let echoed = stmt.name_source.clone().unwrap_or_else(|| stmt.trigger_name.clone());
56 return Err(ExecutorError::TriggerAlreadyExists(echoed));
57 }
58
59 // Reject user attempts to create a trigger with a reserved name. Like
60 // tables and indexes, SQLite reserves the `sqlite_` prefix for its own
61 // schema objects and errors `object name reserved for internal use:
62 // <name>` (sqlite3 3.51.0, index-18.4). This is checked *before*
63 // resolving the trigger's target table — index-18.4 names a dropped
64 // table, and sqlite3 still reports the reserved-name error rather than
65 // "no such table". `trigger_name` preserves the user's original spelling
66 // and is echoed verbatim (issue #5614).
67 if crate::sqlite_schema::is_reserved_object_name(&stmt.trigger_name) {
68 return Err(ExecutorError::SqliteCompatError(format!(
69 "object name reserved for internal use: {}",
70 stmt.trigger_name
71 )));
72 }
73
74 // Reject triggers on SQLite system tables (any "sqlite_" prefixed name,
75 // e.g. sqlite_master / sqlite_schema / sqlite_stat1). SQLite emits exactly
76 // "cannot create trigger on system table" (sqlite3 3.51.0, trigger1-1.9).
77 // These messages are produced verbatim via `Other` so the TCL conformance
78 // shim passes them through unchanged.
79 if is_system_table_name(&stmt.table_name) {
80 return Err(ExecutorError::Other(
81 "cannot create trigger on system table".to_string(),
82 ));
83 }
84
85 // sqlite3 reports a missing CREATE TRIGGER target table in the implicit
86 // `main` schema with the `main.` qualifier
87 // (`no such table: main.no_such_table`, trigger1-1.1.1), but leaves a
88 // TEMP trigger's missing target bare (`no such table: no_such_table`,
89 // trigger1-1.1.2). The trigger's schema is `Some("temp")` for
90 // `CREATE TEMP TRIGGER`; in every other case the target resolves against
91 // the main schema and is qualified.
92 let target_is_temp_schema = stmt
93 .schema
94 .as_deref()
95 .is_some_and(|s| s.eq_ignore_ascii_case("temp"));
96 let qualify_missing_table = |err: ExecutorError| -> ExecutorError {
97 if target_is_temp_schema {
98 err
99 } else {
100 err.with_main_schema_qualifier()
101 }
102 };
103
104 // INSTEAD OF triggers can only be created on views;
105 // BEFORE and AFTER triggers can only be created on (non-view) tables.
106 let target_is_view = db.catalog.get_view(&stmt.table_name).is_some();
107 if stmt.timing == TriggerTiming::InsteadOf {
108 // INSTEAD OF on a real table is rejected with SQLite's exact wording
109 // "cannot create INSTEAD OF trigger on table: <name>" (trigger1-1.12).
110 if !target_is_view {
111 if db.catalog.table_exists(&stmt.table_name) {
112 return Err(ExecutorError::Other(format!(
113 "cannot create INSTEAD OF trigger on table: {}",
114 stmt.table_name
115 )));
116 }
117 // Neither a view nor a table: report the missing target.
118 return Err(qualify_missing_table(ExecutorError::TableNotFound(
119 stmt.table_name.clone(),
120 )));
121 }
122 } else {
123 // BEFORE / AFTER on a view is rejected with SQLite's exact wording
124 // "cannot create BEFORE|AFTER trigger on view: <name>" (trigger1-1.13/1.14).
125 if target_is_view {
126 let timing_label = match stmt.timing {
127 TriggerTiming::Before => "BEFORE",
128 TriggerTiming::After => "AFTER",
129 // InsteadOf handled in the branch above.
130 TriggerTiming::InsteadOf => unreachable!(),
131 };
132 return Err(ExecutorError::Other(format!(
133 "cannot create {} trigger on view: {}",
134 timing_label, stmt.table_name
135 )));
136 }
137 // Verify the target table exists.
138 if !db.catalog.table_exists(&stmt.table_name) {
139 return Err(qualify_missing_table(ExecutorError::TableNotFound(
140 stmt.table_name.clone(),
141 )));
142 }
143 }
144
145 // Create trigger definition from statement, preserving original SQL when available.
146 // The trigger's schema (from `CREATE TEMP TRIGGER` or an explicit
147 // `schema.` prefix) is threaded through so it binds to the correct
148 // table when a temp table shadows a main table of the same name.
149 let trigger = match original_sql {
150 Some(sql) => TriggerDefinition::new_with_sql(
151 stmt.trigger_name.clone(),
152 stmt.timing.clone(),
153 stmt.event.clone(),
154 stmt.table_name.clone(),
155 stmt.granularity.clone(),
156 stmt.when_condition.clone(),
157 stmt.triggered_action.clone(),
158 sql.to_string(),
159 ),
160 None => TriggerDefinition::new(
161 stmt.trigger_name.clone(),
162 stmt.timing.clone(),
163 stmt.event.clone(),
164 stmt.table_name.clone(),
165 stmt.granularity.clone(),
166 stmt.when_condition.clone(),
167 stmt.triggered_action.clone(),
168 ),
169 }
170 .with_schema(stmt.schema.clone());
171
172 // Store in catalog
173 db.catalog.create_trigger(trigger)?;
174
175 Ok(format!("Trigger '{}' created successfully", stmt.trigger_name))
176 }
177
178 /// Execute an ALTER TRIGGER statement
179 pub fn alter_trigger(
180 db: &mut Database,
181 stmt: &AlterTriggerStmt,
182 ) -> Result<String, ExecutorError> {
183 // Get the trigger (verify it exists)
184 let mut trigger = db
185 .catalog
186 .get_trigger(&stmt.trigger_name)
187 .ok_or_else(|| ExecutorError::TriggerNotFound(stmt.trigger_name.clone()))?
188 .clone();
189
190 // Apply the action
191 match stmt.action {
192 AlterTriggerAction::Enable => {
193 trigger.enable();
194 db.catalog.update_trigger(trigger)?;
195 Ok(format!("Trigger '{}' enabled successfully", stmt.trigger_name))
196 }
197 AlterTriggerAction::Disable => {
198 trigger.disable();
199 db.catalog.update_trigger(trigger)?;
200 Ok(format!("Trigger '{}' disabled successfully", stmt.trigger_name))
201 }
202 }
203 }
204
205 /// Execute a DROP TRIGGER statement
206 pub fn drop_trigger(
207 db: &mut Database,
208 stmt: &DropTriggerStmt,
209 ) -> Result<String, ExecutorError> {
210 // Check if trigger exists
211 if db.catalog.get_trigger(&stmt.trigger_name).is_none() {
212 // `DROP TRIGGER IF EXISTS` on a missing trigger is a no-op
213 // (SQLite / SQL:2008 semantics); a bare DROP TRIGGER still errors.
214 if stmt.if_exists {
215 return Ok(format!(
216 "Trigger '{}' does not exist, skipping",
217 stmt.trigger_name
218 ));
219 }
220 return Err(ExecutorError::TriggerNotFound(stmt.trigger_name.clone()));
221 }
222
223 // Remove from catalog
224 db.catalog.drop_trigger(&stmt.trigger_name)?;
225
226 // Note: CASCADE is not yet implemented
227 // When CASCADE is implemented, we would need to also drop any
228 // dependent objects (though triggers typically don't have dependents)
229
230 Ok(format!("Trigger '{}' dropped successfully", stmt.trigger_name))
231 }
232}
233
234/// Returns true if `name` refers to a SQLite system table. SQLite reserves any
235/// object whose name begins with the case-insensitive prefix `sqlite_` for
236/// internal use (sqlite_master / sqlite_schema / sqlite_stat* / sqlite_sequence,
237/// etc.), and rejects `CREATE TRIGGER` on such tables.
238fn is_system_table_name(name: &str) -> bool {
239 let bytes = name.as_bytes();
240 bytes.len() >= 7 && bytes[..7].eq_ignore_ascii_case(b"sqlite_")
241}