Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ plugins/**/target
.gitnexus
.pi/
.atl/
.opencode/
67 changes: 67 additions & 0 deletions src-tauri/src/ai_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,12 @@ pub fn classify_query_kind(sql: &str) -> &'static str {
if trimmed.is_empty() {
return "unknown";
}
let trimmed_upper = trimmed.to_uppercase();

if is_create_compound_routine(&trimmed_upper) {
return "ddl";
}

// Fail closed for multi-statement payloads: a leading `SELECT 1; DROP …`
// must NOT be tagged as a clean read just because the first keyword is
// SELECT — the read-only and approval gates rely on this classification.
Expand Down Expand Up @@ -402,6 +408,67 @@ pub fn classify_query_kind(sql: &str) -> &'static str {
}
}

fn is_create_compound_routine(upper_sql: &str) -> bool {
if first_keyword(upper_sql) != "CREATE" {
return false;
}

let header = upper_sql.split("BEGIN").next().unwrap_or(upper_sql);
let is_routine = ["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]
.iter()
.any(|keyword| contains_keyword(header, keyword));
if !is_routine {
return false;
}

let without_terminal_semicolon = upper_sql.trim_end().trim_end_matches(';').trim_end();
if !without_terminal_semicolon.ends_with("END") {
return false;
}

!has_complete_end_before_terminal_statement(without_terminal_semicolon)
}

fn has_complete_end_before_terminal_statement(upper_sql: &str) -> bool {
let terminal_end = match upper_sql.rfind("END") {
Some(index) => index,
None => return false,
};
let before_terminal = &upper_sql[..terminal_end];
let bytes = before_terminal.as_bytes();
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';

for i in 0..bytes.len().saturating_sub(2) {
if &bytes[i..i + 3] != b"END" {
continue;
}
let prev_ok = i == 0 || !is_word(bytes[i - 1]);
let next_ok = i + 3 == bytes.len() || !is_word(bytes[i + 3]);
if !prev_ok || !next_ok {
continue;
}

let mut cursor = i + 3;
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() || bytes[cursor] != b';' {
continue;
}
cursor += 1;
while cursor < bytes.len()
&& (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b';')
{
cursor += 1;
}
if cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() {
return true;
}
}

false
}

/// Returns true when `stripped` contains a semicolon followed by additional
/// non-whitespace SQL content — i.e., more than one statement.
///
Expand Down
66 changes: 66 additions & 0 deletions src-tauri/src/ai_activity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,72 @@ mod tests {
#[test]
fn classify_ddl() {
assert_eq!(classify_query_kind("CREATE TABLE t (id INT)"), "ddl");
assert_eq!(
classify_query_kind(
"CREATE PROCEDURE tabularis_fix_test_proc()\n\
BEGIN\n\
SELECT 1 AS ok;\n\
END"
),
"ddl"
);
assert_eq!(
classify_query_kind(
"CREATE FUNCTION tabularis_fix_test_fn() RETURNS INT\n\
BEGIN\n\
RETURN 1;\n\
END;"
),
"ddl"
);
assert_eq!(
classify_query_kind(
"CREATE TRIGGER tabularis_fix_test_trigger BEFORE INSERT ON t\n\
FOR EACH ROW\n\
BEGIN\n\
SET NEW.id = NEW.id;\n\
END;"
),
"ddl"
);
assert_eq!(
classify_query_kind(
"CREATE EVENT tabularis_fix_test_event\n\
ON SCHEDULE EVERY 1 DAY\n\
DO\n\
BEGIN\n\
SELECT 1;\n\
END;"
),
"ddl"
);
assert_eq!(
classify_query_kind(
"CREATE PROCEDURE tabularis_fix_test_proc()\n\
BEGIN\n\
SELECT 1;\n\
END; DROP TABLE t"
),
"unknown"
);
assert_eq!(
classify_query_kind(
"CREATE PROCEDURE tabularis_fix_test_proc()\n\
BEGIN\n\
SELECT 1;\n\
END; DROP TABLE t; END"
),
"unknown"
);
assert_eq!(
classify_query_kind(
"CREATE PROCEDURE tabularis_fix_test_proc()\n\
BEGIN\n\
SELECT 1;\n\
END;; DROP TABLE t; END"
),
"unknown"
);
assert_eq!(classify_query_kind("DROP TABLE t"), "ddl");
assert_eq!(classify_query_kind("ALTER TABLE t ADD COLUMN x INT"), "ddl");
assert_eq!(classify_query_kind("TRUNCATE t"), "ddl");
Expand Down