v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
* [P1-T1] Change::Ddl Rust variant + execute_change arm * [P1-T2] Frontend ddl change type + objectCrud capability * [P1-T3] use_keychain data model + migration v8 * [P2-T1] object_crud skeleton + validators + build_ddl dispatch * [P2-T2] Sequence builders * [P2-T3] Enum builders (no value removal) * [P2-T4] View / matview / extension builders * [P2-T5] Index + constraint builders * [P2-T6] Function / procedure + trigger builders * [P2-T7] build_object_ddl + get_available_extensions commands + wrappers * [P3-T1] conditional keychain + session passwords * [P3-T2] keychain-off password prompt on connect * [P3-T3] default-ON keychain opt-out + tooltip + modal conditional * [P3-T4] ddl queue card + after-commit refetch * [P4-T1] ObjectCrudDialog shell * [P4-T2] SequenceForm * [P4-T3] EnumForm with no-removal note * [P4-T4] ExtensionForm with available-extensions picker * [P4-T5] ViewForm (view + materialized view) * [P5-T1] IndexForm with column picker * [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker * [P5-T3] FunctionForm (function + procedure) * [P5-T4] TriggerForm with trigger-function picker * [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring * [P6-T1] object tab type + openObjectTab dedup * [P6-T2] extract ObjectDetail for object tabs * [P6-T3] Objects view two-pane sidebar + workspace * [P6-T4] object-tab content + per-type tab icons * [P7-T1] Version bump 0.7.5 -> 0.7.6 * [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6 * [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6 * fix(ui): object tab icon stacks above name (preflight svg block) * fix(ui): optically center object tab icon with name * fix(ui): object tab icon matches query/table icon handling * [UI-POLISH-1] objectForm tab type + openFormTab store action * [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle * [UI-POLISH-3] route create/edit through form tabs; remove modal * docs: create/edit now open as form tabs (AGENTS sync) * [FB-1] follow app styling patterns + schema dropdown in forms * [FB-2] Monaco editor for function body + view definition * [FB-3] form tabs styled like viewers + in-cell editing * [FB-5] focus outline scoped to input area (label excluded) * [FB-6] no amber focus outline on Monaco body/definition rows * [FB-7] header dedupe + schema default + full edit prefill * [FB-8] SQL view in read-only Monaco editor * docs: roadmap — table create/edit + relationships (next) * docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -223,6 +223,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
@@ -254,6 +255,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
@@ -283,6 +285,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
@@ -300,6 +303,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
@@ -331,6 +335,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
@@ -2155,6 +2155,10 @@ pub async fn execute_change(
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { sql, .. } => {
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2247,6 +2251,9 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
@@ -2319,6 +2326,9 @@ pub async fn execute_change(
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2820,6 +2830,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
|
||||
#[test]
|
||||
fn change_ddl_serialization_uses_snake_case_tag() {
|
||||
let change = Change::Ddl {
|
||||
id: "chg-ddl-1".to_string(),
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&change).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"ddl""#),
|
||||
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
|
||||
json
|
||||
);
|
||||
assert!(json.contains(r#""id":"chg-ddl-1""#));
|
||||
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
|
||||
}
|
||||
|
||||
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
|
||||
#[test]
|
||||
fn change_ddl_id_is_accessible() {
|
||||
let change = Change::Ddl {
|
||||
id: "chg-ddl-42".to_string(),
|
||||
sql: "DROP INDEX public.i".to_string(),
|
||||
};
|
||||
assert_eq!(change.id(), "chg-ddl-42");
|
||||
}
|
||||
|
||||
/// Verify that `build_update_sql` produces valid SQL with all required
|
||||
/// clauses.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
use_keychain: true,
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
@@ -214,6 +215,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::build_ddl;
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||
|
||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||
|
||||
@@ -146,6 +147,47 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
|
||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||
}
|
||||
|
||||
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
|
||||
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
||||
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
||||
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
|
||||
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
|
||||
}
|
||||
|
||||
/// List extensions installable on this server (`pg_available_extensions`):
|
||||
/// name + default version + comment. The picker uses name/version only;
|
||||
/// `schema` is left empty (available extensions are schema-wide).
|
||||
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| ExtensionInfo {
|
||||
name: r.get(0),
|
||||
schema: String::new(),
|
||||
version: r.get(1),
|
||||
comment: r.get(2),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
|
||||
get_available_extensions_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
@@ -57,6 +57,53 @@ async fn object_ddl_for_sequence_enum_function() {
|
||||
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_inner_guards_postgresql_only() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap();
|
||||
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_available_extensions_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn object_dependencies_for_table_includes_view() {
|
||||
|
||||
@@ -1060,6 +1060,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Query available (installable) extensions with default version + comment.
|
||||
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
|
||||
pub fn pg_available_extensions_query() -> String {
|
||||
"SELECT name, default_version::text AS version, comment \
|
||||
FROM pg_available_extensions ORDER BY name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Query indexes in a schema.
|
||||
///
|
||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||
@@ -624,4 +632,13 @@ mod tests {
|
||||
let sql = pg_extensions_query();
|
||||
assert!(sql.contains("pg_extension"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_available_extensions_query_has_name_default_version_comment() {
|
||||
let sql = pg_available_extensions_query();
|
||||
assert!(sql.contains("pg_available_extensions"));
|
||||
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
|
||||
assert!(sql.contains("comment"), "should select comment; got: {sql}");
|
||||
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
//! Pure SQL builders for PostgreSQL object CRUD. No DB I/O.
|
||||
|
||||
use crate::db::object_ddl::{quote_ident, validate_object_name};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SequenceParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: SequenceAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum SequenceAction {
|
||||
Create { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, start: Option<String>, cycle: bool },
|
||||
Alter { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, cycle: Option<bool> },
|
||||
Restart { with: Option<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
fn qual(schema: &str, name: &str) -> Result<String, String> {
|
||||
validate_object_name(schema)?;
|
||||
validate_object_name(name)?;
|
||||
Ok(format!("{}.{}", quote_ident(schema), quote_ident(name)))
|
||||
}
|
||||
|
||||
pub fn sequence_ddl(p: &SequenceParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(vec![match &p.action {
|
||||
SequenceAction::Create { increment, min_value, max_value, start, cycle } => {
|
||||
let mut s = format!("CREATE SEQUENCE {}", q);
|
||||
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
|
||||
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
|
||||
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
|
||||
if let Some(v) = start { s.push_str(&format!("\n START WITH {}", v)); }
|
||||
s.push_str(if *cycle { "\n CYCLE" } else { "\n NO CYCLE" });
|
||||
s
|
||||
}
|
||||
SequenceAction::Alter { increment, min_value, max_value, cycle } => {
|
||||
let mut s = format!("ALTER SEQUENCE {}", q);
|
||||
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
|
||||
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
|
||||
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
|
||||
if let Some(c) = cycle { s.push_str(if *c { "\n CYCLE" } else { "\n NO CYCLE" }); }
|
||||
s
|
||||
}
|
||||
SequenceAction::Restart { with } => match with {
|
||||
Some(v) => format!("ALTER SEQUENCE {} RESTART WITH {}", q, v),
|
||||
None => format!("ALTER SEQUENCE {} RESTART", q),
|
||||
},
|
||||
SequenceAction::Drop => format!("DROP SEQUENCE {}", q),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Escape an enum label as a single-quoted SQL literal (empty rejected).
|
||||
pub fn validate_enum_label(label: &str) -> Result<String, String> {
|
||||
let t = label.trim();
|
||||
if t.is_empty() {
|
||||
return Err("Enum label must not be empty".into());
|
||||
}
|
||||
Ok(format!("'{}'", t.replace('\'', "''")))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EnumParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: EnumAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum EnumAction {
|
||||
Create { labels: Vec<String> },
|
||||
RenameType { new_name: String },
|
||||
AddValue { value: String, if_not_exists: bool, before: Option<String>, after: Option<String> },
|
||||
RenameValue { from: String, to: String },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn enum_ddl(p: &EnumParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(vec![match &p.action {
|
||||
EnumAction::Create { labels } => {
|
||||
let mut out = String::new();
|
||||
for l in labels {
|
||||
if !out.is_empty() { out.push_str(", "); }
|
||||
out.push_str(&validate_enum_label(l)?);
|
||||
}
|
||||
format!("CREATE TYPE {} AS ENUM ({})", q, out)
|
||||
}
|
||||
EnumAction::RenameType { new_name } => {
|
||||
validate_object_name(new_name)?;
|
||||
format!("ALTER TYPE {} RENAME TO {}", q, quote_ident(new_name))
|
||||
}
|
||||
EnumAction::AddValue { value, if_not_exists, before, after } => {
|
||||
let v = format!(" {}", validate_enum_label(value)?);
|
||||
let ine = if *if_not_exists { " IF NOT EXISTS" } else { "" };
|
||||
let pos = match (before, after) {
|
||||
(Some(b), None) => format!(" BEFORE {}", validate_enum_label(b)?),
|
||||
(None, Some(a)) => format!(" AFTER {}", validate_enum_label(a)?),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("ALTER TYPE {} ADD VALUE{}{}{}", q, ine, v, pos)
|
||||
}
|
||||
EnumAction::RenameValue { from, to } => {
|
||||
format!("ALTER TYPE {} RENAME VALUE {} TO {}", q, validate_enum_label(from)?, validate_enum_label(to)?)
|
||||
}
|
||||
EnumAction::Drop => format!("DROP TYPE {}", q),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Validate a SQL expression body: non-empty, no trailing semicolon.
|
||||
pub fn validate_expression(expr: &str) -> Result<String, String> {
|
||||
let t = expr.trim();
|
||||
if t.is_empty() {
|
||||
return Err("Expression must not be empty".into());
|
||||
}
|
||||
if t.ends_with(';') {
|
||||
return Err("Expression must not end with ';'".into());
|
||||
}
|
||||
Ok(t.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub materialized: bool,
|
||||
pub action: ViewAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ViewAction {
|
||||
Create { definition: String },
|
||||
Replace { definition: String },
|
||||
Refresh,
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn view_ddl(p: &ViewParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(match &p.action {
|
||||
ViewAction::Create { definition } if !p.materialized =>
|
||||
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Create { definition } =>
|
||||
vec![format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Replace { definition } if !p.materialized =>
|
||||
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Replace { definition } => vec![
|
||||
format!("DROP MATERIALIZED VIEW {}", q),
|
||||
format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition),
|
||||
],
|
||||
ViewAction::Refresh if p.materialized => vec![format!("REFRESH MATERIALIZED VIEW {}", q)],
|
||||
ViewAction::Refresh => return Err("Cannot REFRESH a non-materialized view".into()),
|
||||
ViewAction::Drop if p.materialized => vec![format!("DROP MATERIALIZED VIEW {}", q)],
|
||||
ViewAction::Drop => vec![format!("DROP VIEW {}", q)],
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtensionParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: ExtensionAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ExtensionAction {
|
||||
Create { version: Option<String> },
|
||||
SetSchema { new_schema: String },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn extension_ddl(p: &ExtensionParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let name = quote_ident(&p.name);
|
||||
let schema = quote_ident(&p.schema);
|
||||
Ok(vec![match &p.action {
|
||||
ExtensionAction::Create { version: None } =>
|
||||
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {}", name, schema),
|
||||
ExtensionAction::Create { version: Some(v) } =>
|
||||
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}'", name, schema, v.replace('\'', "''")),
|
||||
ExtensionAction::SetSchema { new_schema } => {
|
||||
validate_object_name(new_schema)?;
|
||||
format!("ALTER EXTENSION {} SET SCHEMA {}", name, quote_ident(new_schema))
|
||||
}
|
||||
ExtensionAction::Drop => format!("DROP EXTENSION {}", name),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Quote a list of identifiers, joined with ", ".
|
||||
fn quoted_cols(v: &[String]) -> String {
|
||||
v.iter().map(|c| quote_ident(c)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IndexParams {
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub name: String,
|
||||
pub action: IndexAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum IndexAction {
|
||||
Create { unique: bool, method: String, columns: Vec<String>, predicate: Option<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn index_ddl(p: &IndexParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.table)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
IndexAction::Create { unique, method, columns, predicate } => {
|
||||
if columns.is_empty() { return Err("Index requires at least one column".into()); }
|
||||
let cols = quoted_cols(columns);
|
||||
let unique = if *unique { "UNIQUE " } else { "" };
|
||||
let method = if method.trim().is_empty() { String::new() } else { format!(" USING {}", method.trim()) };
|
||||
let pred = match predicate {
|
||||
Some(p) if !p.trim().is_empty() => format!(" WHERE {}", validate_expression(p)?),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("CREATE {}INDEX {} ON {}{} ({}){}", unique, name, table, method, cols, pred)
|
||||
}
|
||||
IndexAction::Drop => format!("DROP INDEX {}.{}", quote_ident(&p.schema), name),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConstraintParams {
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub name: String,
|
||||
pub action: ConstraintAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ConstraintAction {
|
||||
Check { expression: String },
|
||||
Unique { columns: Vec<String> },
|
||||
PrimaryKey { columns: Vec<String> },
|
||||
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.table)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
ConstraintAction::Check { expression } =>
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?),
|
||||
ConstraintAction::Unique { columns } => {
|
||||
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::PrimaryKey { columns } => {
|
||||
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => {
|
||||
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
|
||||
validate_object_name(ref_schema)?;
|
||||
validate_object_name(ref_table)?;
|
||||
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns))
|
||||
}
|
||||
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FunctionArg { pub mode: String, pub name: String, #[serde(rename = "type")] pub type_: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FunctionParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub is_procedure: bool,
|
||||
pub action: FunctionAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum FunctionAction {
|
||||
CreateOrReplace { args: Vec<FunctionArg>, return_type: Option<String>, language: String, body: String, volatility: Option<String>, strict: bool },
|
||||
Drop { arg_types: Vec<String> },
|
||||
}
|
||||
|
||||
fn arg_sql(a: &FunctionArg) -> String {
|
||||
let mode = match a.mode.trim().to_lowercase().as_str() {
|
||||
"in" | "" => String::new(),
|
||||
m => format!("{} ", m.to_uppercase()),
|
||||
};
|
||||
format!("{}{} {}", mode, a.name, a.type_)
|
||||
}
|
||||
|
||||
pub fn function_ddl(p: &FunctionParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
let kind = if p.is_procedure { "PROCEDURE" } else { "FUNCTION" };
|
||||
Ok(vec![match &p.action {
|
||||
FunctionAction::CreateOrReplace { args, return_type, language, body, volatility, strict } => {
|
||||
let arglist: Vec<String> = args.iter().map(arg_sql).collect();
|
||||
let ret = match (p.is_procedure, return_type) {
|
||||
(false, Some(r)) => format!(" RETURNS {}", r),
|
||||
_ => String::new(),
|
||||
};
|
||||
let vol = match volatility.as_deref() {
|
||||
Some("IMMUTABLE") => " IMMUTABLE", Some("STABLE") => " STABLE", Some("VOLATILE") => " VOLATILE", _ => "",
|
||||
};
|
||||
let strict = if *strict { " STRICT" } else { "" };
|
||||
format!("CREATE OR REPLACE {} {}({}){} LANGUAGE {}{}{} AS $${}$$", kind, q, arglist.join(", "), ret, language, vol, strict, body)
|
||||
}
|
||||
FunctionAction::Drop { arg_types } => format!("DROP {} {}({})", kind, q, arg_types.join(", ")),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TriggerParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: TriggerAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum TriggerAction {
|
||||
Create { table: String, timing: String, events: Vec<String>, orientation: String, function_schema: String, function_name: String, function_args: Vec<String>, when: Option<String> },
|
||||
Enable { table: String },
|
||||
Disable { table: String },
|
||||
Drop { table: String },
|
||||
}
|
||||
|
||||
pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let schema = quote_ident(&p.schema);
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
TriggerAction::Create { table, timing, events, orientation, function_schema, function_name, function_args, when } => {
|
||||
validate_object_name(table)?;
|
||||
validate_object_name(function_schema)?;
|
||||
validate_object_name(function_name)?;
|
||||
let evs = events.join(" OR ");
|
||||
let orient = match orientation.trim().to_uppercase().as_str() { "STATEMENT" => "FOR EACH STATEMENT", _ => "FOR EACH ROW" };
|
||||
let when_clause = match when {
|
||||
Some(w) if !w.trim().is_empty() => format!(" WHEN ({})", w),
|
||||
_ => String::new(),
|
||||
};
|
||||
let fq = format!("{}.{}", quote_ident(function_schema), quote_ident(function_name));
|
||||
format!("CREATE TRIGGER {} {} {} ON {}.{} {}{} EXECUTE FUNCTION {}({})",
|
||||
name, timing, evs, schema, quote_ident(table), orient, when_clause, fq, function_args.join(", "))
|
||||
}
|
||||
TriggerAction::Enable { table } => format!("ALTER TABLE {}.{} ENABLE TRIGGER {}", schema, quote_ident(table), name),
|
||||
TriggerAction::Disable { table } => format!("ALTER TABLE {}.{} DISABLE TRIGGER {}", schema, quote_ident(table), name),
|
||||
TriggerAction::Drop { table } => format!("DROP TRIGGER {} ON {}.{}", name, schema, quote_ident(table)),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
|
||||
/// Returns one or more single SQL statements.
|
||||
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
match kind {
|
||||
"sequence" => {
|
||||
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
sequence_ddl(&p)
|
||||
}
|
||||
"enum" => {
|
||||
let p: EnumParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
enum_ddl(&p)
|
||||
}
|
||||
"view" => {
|
||||
let p: ViewParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
view_ddl(&p)
|
||||
}
|
||||
"extension" => {
|
||||
let p: ExtensionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
extension_ddl(&p)
|
||||
}
|
||||
"index" => {
|
||||
let p: IndexParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
index_ddl(&p)
|
||||
}
|
||||
"constraint" => {
|
||||
let p: ConstraintParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
constraint_ddl(&p)
|
||||
}
|
||||
"function" | "procedure" => {
|
||||
let mut p: FunctionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
p.is_procedure = kind == "procedure";
|
||||
function_ddl(&p)
|
||||
}
|
||||
"trigger" => {
|
||||
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
trigger_ddl(&p)
|
||||
}
|
||||
other => Err(format!("Unsupported object kind: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_enum_label_quotes_and_doubles() {
|
||||
assert_eq!(validate_enum_label("admin").unwrap(), "'admin'");
|
||||
assert_eq!(validate_enum_label("user's").unwrap(), "'user''s'");
|
||||
assert!(validate_enum_label("").is_err());
|
||||
assert!(validate_enum_label(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_expression_rejects_empty_and_trailing_semicolon() {
|
||||
assert_eq!(validate_expression("amount > 0").unwrap(), "amount > 0");
|
||||
assert!(validate_expression("").is_err());
|
||||
assert!(validate_expression("amount > 0;").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ddl_rejects_unknown_kind() {
|
||||
let res = build_ddl("bogus", serde_json::json!({}));
|
||||
assert!(res.is_err());
|
||||
assert!(res.unwrap_err().contains("Unsupported object kind: bogus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_create_full() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "users_id_seq",
|
||||
"action": { "op": "create", "increment": "1", "min_value": "1", "max_value": "9223372036854775807", "start": "1", "cycle": false }
|
||||
});
|
||||
let sql = build_ddl("sequence", p).unwrap();
|
||||
assert_eq!(sql, vec!["CREATE SEQUENCE \"public\".\"users_id_seq\"\n INCREMENT BY 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START WITH 1\n NO CYCLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_alter_partial_and_cycle_option() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "s",
|
||||
"action": { "op": "alter", "increment": "2", "cycle": true }
|
||||
});
|
||||
let sql = build_ddl("sequence", p).unwrap();
|
||||
assert_eq!(sql, vec!["ALTER SEQUENCE \"public\".\"s\"\n INCREMENT BY 2\n CYCLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_restart_with_value() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "restart", "with": "100" } });
|
||||
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["ALTER SEQUENCE \"public\".\"s\" RESTART WITH 100"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_rejects_bad_name() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "a; DROP", "action": { "op": "drop" } });
|
||||
assert!(build_ddl("sequence", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_create_quotes_labels() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "create", "labels": ["admin", "user's"] } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["CREATE TYPE \"public\".\"role\" AS ENUM ('admin', 'user''s')"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_rename_type() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "rename_type", "new_name": "user_role" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"role\" RENAME TO \"user_role\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_with_position() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "orange", "if_not_exists": true, "before": "red", "after": null } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE IF NOT EXISTS 'orange' BEFORE 'red'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_plain() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "green", "if_not_exists": false, "before": null, "after": null } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE 'green'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_rename_value() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "rename_value", "from": "purple", "to": "mauve" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" RENAME VALUE 'purple' TO 'mauve'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["DROP TYPE \"public\".\"role\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_rejects_empty_label() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "", "if_not_exists": false, "before": null, "after": null } });
|
||||
assert!(build_ddl("enum", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_create_or_replace() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "create", "definition": "SELECT * FROM users" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_create() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "create", "definition": "SELECT count(*) FROM sales" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_replace_is_drop_then_create() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "replace", "definition": "SELECT count(*) FROM sales" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec![
|
||||
"DROP MATERIALIZED VIEW \"public\".\"mv_sales\"",
|
||||
"CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_refresh() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "refresh" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["REFRESH MATERIALIZED VIEW \"public\".\"mv_sales\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_refresh_errors() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "refresh" } });
|
||||
assert!(build_ddl("view", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["DROP VIEW \"public\".\"v_users\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_create_with_version() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": "1.3" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\" VERSION '1.3'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_create_without_version() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": null } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_set_schema() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "set_schema", "new_schema": "utils" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["ALTER EXTENSION \"pgcrypto\" SET SCHEMA \"utils\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["DROP EXTENSION \"pgcrypto\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_unique_btree_with_predicate() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "users", "name": "users_email_key",
|
||||
"action": { "op": "create", "unique": true, "method": "btree", "columns": ["email"], "predicate": "email IS NOT NULL" }
|
||||
});
|
||||
assert_eq!(build_ddl("index", p).unwrap(),
|
||||
vec!["CREATE UNIQUE INDEX \"users_email_key\" ON \"public\".\"users\" USING btree (\"email\") WHERE email IS NOT NULL"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_no_method_no_predicate() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "users", "name": "i_name",
|
||||
"action": { "op": "create", "unique": false, "method": "", "columns": ["a", "b"], "predicate": null }
|
||||
});
|
||||
assert_eq!(build_ddl("index", p).unwrap(),
|
||||
vec!["CREATE INDEX \"i_name\" ON \"public\".\"users\" (\"a\", \"b\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i_name", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("index", p).unwrap(), vec!["DROP INDEX \"public\".\"i_name\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_rejects_empty_columns() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i", "action": { "op": "create", "unique": false, "method": "", "columns": [], "predicate": null } });
|
||||
assert!(build_ddl("index", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_check() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "check", "expression": "amount > 0" } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_unique() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "u_email", "action": { "op": "unique", "columns": ["email"] } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"u_email\" UNIQUE (\"email\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_primary_key() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "pk_users", "action": { "op": "primary_key", "columns": ["id"] } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"pk_users\" PRIMARY KEY (\"id\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_foreign_key() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "orders", "name": "fk_user",
|
||||
"action": { "op": "foreign_key", "columns": ["user_id"], "ref_schema": "public", "ref_table": "users", "ref_columns": ["id"] }
|
||||
});
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"ck_pos\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_create_or_replace_basic() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "add", "is_procedure": false,
|
||||
"action": { "op": "create_or_replace",
|
||||
"args": [ { "mode": "in", "name": "a", "type": "int" }, { "mode": "in", "name": "b", "type": "int" } ],
|
||||
"return_type": "int", "language": "plpgsql", "body": "BEGIN RETURN a+b; END",
|
||||
"volatility": "IMMUTABLE", "strict": true }
|
||||
});
|
||||
assert_eq!(build_ddl("function", p).unwrap(), vec![
|
||||
"CREATE OR REPLACE FUNCTION \"public\".\"add\"(a int, b int) RETURNS int LANGUAGE plpgsql IMMUTABLE STRICT AS $$BEGIN RETURN a+b; END$$"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn procedure_create_or_replace_no_returns() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "do_thing", "is_procedure": true,
|
||||
"action": { "op": "create_or_replace",
|
||||
"args": [ { "mode": "in", "name": "x", "type": "int" } ],
|
||||
"return_type": null, "language": "plpgsql", "body": "BEGIN PERFORM x; END",
|
||||
"volatility": null, "strict": false }
|
||||
});
|
||||
assert_eq!(build_ddl("procedure", p).unwrap(), vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"do_thing\"(x int) LANGUAGE plpgsql AS $$BEGIN PERFORM x; END$$"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_drop_by_signature() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "add", "is_procedure": false, "action": { "op": "drop", "arg_types": ["int", "int"] } });
|
||||
assert_eq!(build_ddl("function", p).unwrap(), vec!["DROP FUNCTION \"public\".\"add\"(int, int)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn procedure_drop_by_signature() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "do_thing", "is_procedure": true, "action": { "op": "drop", "arg_types": ["int"] } });
|
||||
assert_eq!(build_ddl("procedure", p).unwrap(), vec!["DROP PROCEDURE \"public\".\"do_thing\"(int)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_create_row() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "tr_audit",
|
||||
"action": { "op": "create", "table": "orders", "timing": "BEFORE",
|
||||
"events": ["INSERT", "UPDATE"], "orientation": "ROW",
|
||||
"function_schema": "public", "function_name": "audit_fn",
|
||||
"function_args": [], "when": null }
|
||||
});
|
||||
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
|
||||
"CREATE TRIGGER \"tr_audit\" BEFORE INSERT OR UPDATE ON \"public\".\"orders\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"audit_fn\"()"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_create_with_when_and_args() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "tr_audit",
|
||||
"action": { "op": "create", "table": "orders", "timing": "AFTER",
|
||||
"events": ["UPDATE"], "orientation": "STATEMENT",
|
||||
"function_schema": "public", "function_name": "audit_fn",
|
||||
"function_args": ["'log'"], "when": "OLD.amount IS DISTINCT FROM NEW.amount" }
|
||||
});
|
||||
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
|
||||
"CREATE TRIGGER \"tr_audit\" AFTER UPDATE ON \"public\".\"orders\" FOR EACH STATEMENT WHEN (OLD.amount IS DISTINCT FROM NEW.amount) EXECUTE FUNCTION \"public\".\"audit_fn\"('log')"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_enable_disable_drop() {
|
||||
let base = |op: &str| serde_json::json!({ "schema": "public", "name": "tr_audit", "action": { "op": op, "table": "orders" } });
|
||||
assert_eq!(build_ddl("trigger", base("enable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" ENABLE TRIGGER \"tr_audit\""]);
|
||||
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
|
||||
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@ pub fn run() {
|
||||
objects::search_objects,
|
||||
objects::get_object_ddl,
|
||||
objects::get_object_dependencies,
|
||||
objects::build_object_ddl,
|
||||
objects::get_available_extensions,
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct Connection {
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub use_keychain: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_keychain: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -66,6 +73,7 @@ mod tests {
|
||||
username: Some("admin".to_string()),
|
||||
folder_id: Some("folder1".to_string()),
|
||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
use_keychain: true,
|
||||
password: Some("secret123".to_string()),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("jumphost.example.com".to_string()),
|
||||
@@ -133,6 +141,7 @@ mod tests {
|
||||
environment: None,
|
||||
favorite: false,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
database: Some("mydb".to_string()),
|
||||
@@ -177,6 +186,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
favorite: true,
|
||||
created_at: "2024-01-01T00:00:00Z".into(),
|
||||
updated_at: "2024-01-01T00:00:00Z".into(),
|
||||
@@ -184,4 +194,18 @@ mod tests {
|
||||
let json = serde_json::to_string(&conn).unwrap();
|
||||
assert!(json.contains("\"favorite\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_input_use_keychain_defaults_true_when_absent() {
|
||||
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
|
||||
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_input_use_keychain_preserves_false() {
|
||||
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
|
||||
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||
assert!(!input.use_keychain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,10 @@ pub enum Change {
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
Ddl {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -204,7 +208,8 @@ impl Change {
|
||||
| Change::AlterTable { id, .. }
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. } => id,
|
||||
| Change::EmptyTable { id, .. }
|
||||
| Change::Ddl { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// v8: use_keychain opt-out flag on connections
|
||||
if current_ver < 8 {
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
if !existing.iter().any(|c| c == "use_keychain") {
|
||||
conn.execute(
|
||||
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -292,7 +316,49 @@ mod tests {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 6);
|
||||
assert_eq!(count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v8_adds_use_keychain_column_default_1() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
// v7 baseline
|
||||
run_migrations(&conn).unwrap();
|
||||
// simulate an existing connection row (pre-v8 shape had no use_keychain)
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let uses: i64 = conn
|
||||
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v8_is_idempotent() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let v8: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v8, 1);
|
||||
// PRAGMA confirms exactly one use_keychain column
|
||||
let cols: Vec<String> = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -541,12 +607,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_bumps_schema_version_to_7() {
|
||||
fn v8_bumps_schema_version_to_8() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let ver: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(ver, 7);
|
||||
assert_eq!(ver, 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ impl Store {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
|
||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at, use_keychain FROM connections ORDER BY name",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
@@ -286,6 +286,7 @@ impl Store {
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(20)?,
|
||||
updated_at: row.get(21)?,
|
||||
use_keychain: row.get(22)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -308,8 +309,8 @@ impl Store {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at, use_keychain) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
|
||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now, input.use_keychain],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
@@ -341,6 +342,7 @@ impl Store {
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids,
|
||||
use_keychain: input.use_keychain,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
@@ -419,13 +421,13 @@ impl Store {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, use_keychain=?18, updated_at=?19 WHERE id=?20",
|
||||
params![
|
||||
input.name, input.db_type, input.host, input.port, input.username,
|
||||
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
||||
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
||||
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
||||
input.environment, now, id
|
||||
input.environment, input.use_keychain, now, id
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
// Update tags
|
||||
@@ -463,6 +465,7 @@ impl Store {
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids.clone(),
|
||||
use_keychain: input.use_keychain,
|
||||
created_at: String::new(), // not updated
|
||||
updated_at: now,
|
||||
})
|
||||
@@ -954,6 +957,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1002,6 +1006,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1043,6 +1048,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_folder(&folder.id).unwrap();
|
||||
@@ -1082,6 +1088,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_tag(&tag.id).unwrap();
|
||||
@@ -1142,6 +1149,7 @@ mod tests {
|
||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1194,6 +1202,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1278,6 +1287,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
// Insert 510 rows — should trigger pruning beyond 500
|
||||
@@ -1334,6 +1344,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1378,6 +1389,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1480,6 +1492,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.6",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
Reference in New Issue
Block a user