fix(sql-file): open and save custom-filtered text files
This commit is contained in:
8 files changed
+113
-58
No files matched your search
@@ -85,9 +85,19 @@ pub async fn save_external_sql_file(
|
||||
window: tauri::Window,
|
||||
default_file_name: String,
|
||||
content: String,
|
||||
filter_extension: Option<String>,
|
||||
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
window.dialog().file().set_file_name(default_file_name).add_filter("SQL", &["sql"]).save_file(move |file_path| {
|
||||
// Non-SQL external tabs (custom-filtered text files) keep their own
|
||||
// extension when saving a copy; without a usable extension leave the
|
||||
// dialog unfiltered instead of forcing the SQL filter.
|
||||
let dialog = window.dialog().file().set_file_name(default_file_name);
|
||||
let dialog = match filter_extension.as_deref().map(str::trim).filter(|extension| !extension.is_empty()) {
|
||||
Some("sql") => dialog.add_filter("SQL", &["sql"]),
|
||||
Some(extension) => dialog.add_filter(extension.to_uppercase(), &[extension]),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.save_file(move |file_path| {
|
||||
let _ = sender.send(file_path);
|
||||
});
|
||||
let path = receiver
|
||||
@@ -171,10 +181,10 @@ fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> Exte
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResult, String> {
|
||||
if !is_sql_file_path(path) {
|
||||
return Err("Only .sql files can be opened this way".to_string());
|
||||
}
|
||||
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Err("Only files can be opened this way".to_string());
|
||||
}
|
||||
if exceeds_external_sql_editor_limit(metadata.len()) {
|
||||
return Ok(ExternalSqlFileReadResult::TooLarge {
|
||||
size_bytes: metadata.len(),
|
||||
@@ -187,11 +197,11 @@ fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResu
|
||||
}
|
||||
|
||||
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalSqlFileReadResult, String> {
|
||||
if !is_sql_file_path(&path) {
|
||||
return Err("Only .sql files can be opened this way".to_string());
|
||||
}
|
||||
let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
|
||||
let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Err("Only files can be opened this way".to_string());
|
||||
}
|
||||
if exceeds_external_sql_editor_limit(metadata.len()) {
|
||||
return Ok(ExternalSqlFileReadResult::TooLarge {
|
||||
size_bytes: metadata.len(),
|
||||
@@ -214,13 +224,11 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalS
|
||||
}
|
||||
|
||||
async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFileStatus, String> {
|
||||
if !is_sql_file_path(&path) {
|
||||
return Err("Only .sql files can be inspected this way".to_string());
|
||||
}
|
||||
match tokio::fs::metadata(&path).await {
|
||||
Ok(metadata) => {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
Ok(ExternalSqlFileStatus::Present { size_bytes: metadata.len(), modified_ns: modified_ns(&metadata) })
|
||||
}
|
||||
Ok(_) => Err("Only files can be inspected this way".to_string()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ExternalSqlFileStatus::Missing),
|
||||
Err(error) => Err(format!("Failed to inspect SQL file: {error}")),
|
||||
}
|
||||
@@ -228,9 +236,6 @@ async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFil
|
||||
|
||||
#[cfg(test)]
|
||||
fn write_external_sql_file_content(path: &Path, content: &str) -> Result<(), String> {
|
||||
if !is_sql_file_path(path) {
|
||||
return Err("Only .sql files can be saved this way".to_string());
|
||||
}
|
||||
std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}"))
|
||||
}
|
||||
|
||||
@@ -266,10 +271,6 @@ async fn write_external_sql_file_checked_async(
|
||||
expected_missing: bool,
|
||||
force: bool,
|
||||
) -> Result<ExternalSqlFileWriteResult, String> {
|
||||
if !is_sql_file_path(&path) {
|
||||
return Err("Only .sql files can be saved this way".to_string());
|
||||
}
|
||||
|
||||
if !force {
|
||||
match external_sql_file_version_async(&path).await? {
|
||||
Some(current_version) => {
|
||||
@@ -397,14 +398,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_external_non_sql_file_content() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, "select 1;").unwrap();
|
||||
fn reads_external_filtered_text_file_content() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-test-{}.py", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, "print('hello')").unwrap();
|
||||
|
||||
let result = read_external_sql_file_content(&path);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(result.unwrap_err().contains(".sql"));
|
||||
let ExternalSqlFileReadResult::Content { content, .. } = result.unwrap() else {
|
||||
panic!("expected text file content");
|
||||
};
|
||||
assert_eq!(content, "print('hello')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -474,9 +478,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspects_present_and_missing_external_sql_files() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, "select 1;").unwrap();
|
||||
async fn inspects_present_and_missing_external_text_files() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-test-{}.sh", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&path, "echo test").unwrap();
|
||||
|
||||
let present = inspect_external_sql_file_async(path.clone()).await.unwrap();
|
||||
assert!(matches!(present, ExternalSqlFileStatus::Present { size_bytes: 9, .. }));
|
||||
@@ -583,12 +587,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_external_non_sql_file_save() {
|
||||
fn writes_external_filtered_text_file_content() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4()));
|
||||
|
||||
let result = write_external_sql_file_content(&path, "select 2;");
|
||||
let result = write_external_sql_file_content(&path, "plain text");
|
||||
|
||||
assert!(result.unwrap_err().contains(".sql"));
|
||||
assert!(!path.exists());
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "plain text");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,16 @@ pub struct SqlFileEntry {
|
||||
pub children: Vec<SqlFileEntry>,
|
||||
}
|
||||
|
||||
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
|
||||
fn validate_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());
|
||||
return Err("File name must not contain a path".to_string());
|
||||
}
|
||||
Ok(file_name)
|
||||
}
|
||||
|
||||
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
|
||||
let file_name = validate_file_name(file_name)?;
|
||||
if !file_name.to_ascii_lowercase().ends_with(".sql") || file_name.len() <= 4 {
|
||||
return Err("Only .sql files can be managed here".to_string());
|
||||
}
|
||||
@@ -72,19 +77,14 @@ fn managed_directory(root_path: &str, directory_path: &str) -> Result<PathBuf, S
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn managed_sql_file(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
|
||||
fn managed_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}"))?;
|
||||
let file = std::fs::canonicalize(file_path).map_err(|error| format!("Failed to resolve file: {error}"))?;
|
||||
if !file.starts_with(&root) {
|
||||
return Err("SQL file is outside the opened root folder".to_string());
|
||||
return Err("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());
|
||||
if !file.is_file() {
|
||||
return Err("Only files can be managed here".to_string());
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
@@ -248,9 +248,9 @@ pub async fn rename_sql_file_in_folder(
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let file_name = validate_sql_file_name(&file_name)?.to_owned();
|
||||
let file_name = validate_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 file = managed_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() {
|
||||
@@ -270,7 +270,7 @@ pub async fn rename_sql_file_in_folder(
|
||||
#[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)?;
|
||||
let file = managed_file(&root_path, &file_path)?;
|
||||
std::fs::remove_file(file).map_err(|error| format!("Failed to delete SQL file: {error}"))
|
||||
})
|
||||
.await
|
||||
@@ -367,9 +367,31 @@ mod tests {
|
||||
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!(validate_file_name("script.py").unwrap(), "script.py");
|
||||
assert_eq!(renamed_sql_file_display_path("draft.sql", "report.sql").unwrap(), "report.sql");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renames_and_deletes_filtered_text_files_within_the_opened_root() {
|
||||
let root = std::env::temp_dir().join(format!("dbx-text-file-manage-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = root.join("script.sh");
|
||||
std::fs::write(&source, "echo hello").unwrap();
|
||||
|
||||
let renamed = rename_sql_file_in_folder(
|
||||
root.to_string_lossy().into_owned(),
|
||||
source.to_string_lossy().into_owned(),
|
||||
"script.py".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 manages_sql_files_within_the_opened_root() {
|
||||
let root = std::env::temp_dir().join(format!("dbx-sql-file-manage-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
Reference in new issue
Block a user