Skip to main content

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}