feat(sql-files): create, rename, and delete SQL files in tree

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-30 12:26:06 +08:00
1 parent 9491ab08df
commit 7ba30ae761
22 files changed
+590 -8

No files matched your search

+194 -1
View File
@@ -1,6 +1,6 @@
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
use std::path::{Path, PathBuf};
const MAX_SCAN_DEPTH: usize = 10;
@@ -33,6 +33,60 @@ pub struct SqlFileEntry {
pub children: Vec<SqlFileEntry>,
}
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
let file_name = file_name.trim();
if file_name.is_empty() || file_name == "." || file_name == ".." || file_name.contains(['/', '\\']) {
return Err("SQL file name must not contain a path".to_string());
}
if !file_name.to_ascii_lowercase().ends_with(".sql") || file_name.len() <= 4 {
return Err("Only .sql files can be managed here".to_string());
}
Ok(file_name)
}
fn renamed_sql_file_display_path(file_path: &str, file_name: &str) -> Result<String, String> {
#[cfg(windows)]
let parent_end = file_path.rfind(['/', '\\']).map(|index| index + 1);
#[cfg(not(windows))]
let parent_end = file_path.rfind('/').map(|index| index + 1);
Ok(parent_end.map_or_else(|| file_name.to_owned(), |index| format!("{}{}", &file_path[..index], file_name)))
}
fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
let path = std::fs::canonicalize(path).map_err(|error| format!("Failed to resolve {label}: {error}"))?;
if !path.is_dir() {
return Err(format!("{label} is not a directory"));
}
Ok(path)
}
fn managed_directory(root_path: &str, directory_path: &str) -> Result<PathBuf, String> {
let root = canonical_directory(Path::new(root_path), "SQL root folder")?;
let directory = canonical_directory(Path::new(directory_path), "SQL folder")?;
if !directory.starts_with(&root) {
return Err("SQL folder is outside the opened root folder".to_string());
}
Ok(directory)
}
fn managed_sql_file(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
let root = canonical_directory(Path::new(root_path), "SQL root folder")?;
let file = std::fs::canonicalize(file_path).map_err(|error| format!("Failed to resolve SQL file: {error}"))?;
if !file.starts_with(&root) {
return Err("SQL file is outside the opened root folder".to_string());
}
if !file.is_file()
|| !file
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("sql"))
{
return Err("Only .sql files can be managed here".to_string());
}
Ok(file)
}
fn is_pruned_dir(name: &str) -> bool {
PRUNED_DIRS.iter().any(|d| name.eq_ignore_ascii_case(d))
}
@@ -117,6 +171,65 @@ pub async fn list_sql_files_in_folder(folder_path: String) -> Result<Vec<SqlFile
.map_err(|e| format!("Failed to scan folder: {e}"))?
}
#[tauri::command]
pub async fn create_sql_file_in_folder(
root_path: String,
directory_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let file_name = validate_sql_file_name(&file_name)?.to_owned();
let directory = managed_directory(&root_path, &directory_path)?;
let path = directory.join(&file_name);
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| format!("Failed to create SQL file: {error}"))?;
// Keep the path form used by the file tree. `canonicalize` returns a
// Windows extended-length path, which is a different frontend identity.
Ok(Path::new(&directory_path).join(file_name).to_string_lossy().into_owned())
})
.await
.map_err(|error| format!("Failed to create SQL file: {error}"))?
}
#[tauri::command]
pub async fn rename_sql_file_in_folder(
root_path: String,
file_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let file_name = validate_sql_file_name(&file_name)?.to_owned();
let display_path = renamed_sql_file_display_path(&file_path, &file_name)?;
let file = managed_sql_file(&root_path, &file_path)?;
let target =
file.parent().ok_or_else(|| "Failed to resolve SQL file parent folder".to_string())?.join(&file_name);
if target.exists() {
let existing = std::fs::canonicalize(&target)
.map_err(|error| format!("Failed to resolve SQL file target: {error}"))?;
if existing != file {
return Err("A SQL file with this name already exists".to_string());
}
}
std::fs::rename(&file, &target).map_err(|error| format!("Failed to rename SQL file: {error}"))?;
Ok(display_path)
})
.await
.map_err(|error| format!("Failed to rename SQL file: {error}"))?
}
#[tauri::command]
pub async fn delete_sql_file_in_folder(root_path: String, file_path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let file = managed_sql_file(&root_path, &file_path)?;
std::fs::remove_file(file).map_err(|error| format!("Failed to delete SQL file: {error}"))
})
.await
.map_err(|error| format!("Failed to delete SQL file: {error}"))?
}
#[cfg(test)]
mod tests {
use super::*;
@@ -152,4 +265,84 @@ mod tests {
assert_eq!(names, vec!["nested.SQL", "root.sql"]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn validates_single_sql_file_names() {
assert_eq!(validate_sql_file_name("query.sql").unwrap(), "query.sql");
assert!(validate_sql_file_name("query.txt").is_err());
assert!(validate_sql_file_name("nested/query.sql").is_err());
assert!(validate_sql_file_name("nested\\query.sql").is_err());
assert_eq!(renamed_sql_file_display_path("draft.sql", "report.sql").unwrap(), "report.sql");
}
#[tokio::test]
async fn manages_sql_files_within_the_opened_root() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-manage-{}", uuid::Uuid::new_v4()));
let nested = root.join("queries");
std::fs::create_dir_all(&nested).unwrap();
let created = create_sql_file_in_folder(
root.to_string_lossy().into_owned(),
nested.to_string_lossy().into_owned(),
"draft.sql".to_string(),
)
.await
.unwrap();
assert!(Path::new(&created).is_file());
let renamed = rename_sql_file_in_folder(root.to_string_lossy().into_owned(), created, "report.sql".to_string())
.await
.unwrap();
assert!(Path::new(&renamed).is_file());
delete_sql_file_in_folder(root.to_string_lossy().into_owned(), renamed.clone()).await.unwrap();
assert!(!Path::new(&renamed).exists());
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn keeps_the_file_tree_path_representation_for_managed_files() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-path-{}", uuid::Uuid::new_v4()));
let nested = root.join("queries");
let displayed_root = root.join(".");
let displayed_directory = nested.join(".");
std::fs::create_dir_all(&nested).unwrap();
let created = create_sql_file_in_folder(
displayed_root.to_string_lossy().into_owned(),
displayed_directory.to_string_lossy().into_owned(),
"draft.sql".to_string(),
)
.await
.unwrap();
assert_eq!(created, displayed_directory.join("draft.sql").to_string_lossy());
let renamed =
rename_sql_file_in_folder(displayed_root.to_string_lossy().into_owned(), created, "report.sql".to_string())
.await
.unwrap();
assert_eq!(renamed, displayed_directory.join("report.sql").to_string_lossy());
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(windows)]
#[tokio::test]
async fn renames_files_when_only_the_case_changes() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-case-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let source = root.join("draft.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let renamed = rename_sql_file_in_folder(
root.to_string_lossy().into_owned(),
source.to_string_lossy().into_owned(),
"DRAFT.sql".to_string(),
)
.await
.unwrap();
assert!(Path::new(&renamed).is_file());
assert_eq!(Path::new(&renamed).file_name().unwrap(), "DRAFT.sql");
std::fs::remove_dir_all(root).unwrap();
}
}
+3
View File
@@ -1941,6 +1941,9 @@ pub fn run() {
commands::external_sql::write_external_sql_file,
commands::external_sql::save_external_sql_file,
commands::list_sql_files::list_sql_files_in_folder,
commands::list_sql_files::create_sql_file_in_folder,
commands::list_sql_files::rename_sql_file_in_folder,
commands::list_sql_files::delete_sql_file_in_folder,
commands::external_db::pending_open_db_files,
commands::keychain::read_keychain_password,
commands::keychain::read_keychain_passwords,