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