From f2e44c56a37d84425d6ba54948e78badfbec31a8 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 21:03:32 +0800 Subject: [PATCH] feat: compare_schemas command + diff staging into the changes queue (Task 15) --- desktop/src-tauri/src/commands/mod.rs | 1 + desktop/src-tauri/src/commands/schema_diff.rs | 82 +++++++++++++++++++ .../src/commands/schema_diff.test.rs | 25 ++++++ desktop/src-tauri/src/db/schema_diff.rs | 2 +- desktop/src-tauri/src/lib.rs | 3 +- desktop/src/lib/commands.ts | 13 ++- desktop/src/lib/types.ts | 23 +++++- desktop/src/stores/dbViewerStore.diff.test.ts | 30 +++++++ desktop/src/stores/dbViewerStore.ts | 16 +++- 9 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 desktop/src-tauri/src/commands/schema_diff.rs create mode 100644 desktop/src-tauri/src/commands/schema_diff.test.rs create mode 100644 desktop/src/stores/dbViewerStore.diff.test.ts diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 01e38da..43e0596 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod maintenance; pub mod objects; pub mod query; pub mod query_export; +pub mod schema_diff; pub mod schema_graph; pub mod settings; pub mod ssh; diff --git a/desktop/src-tauri/src/commands/schema_diff.rs b/desktop/src-tauri/src/commands/schema_diff.rs new file mode 100644 index 0000000..76d1392 --- /dev/null +++ b/desktop/src-tauri/src/commands/schema_diff.rs @@ -0,0 +1,82 @@ +//! Thin `compare_schemas` command (spec §4: two pool lookups → snapshots → +//! diff; errors name the failing side; staging never happens here). + +use crate::models::db_viewer::DiffReport; +use tauri::State; + +/// Engine family for diff pairing: mysql and mariadb are one family. +pub fn engine_family(db_type: &str) -> String { + match db_type { + "mysql" | "mariadb" => "mysql".to_string(), + "sqlite" => "sqlite".to_string(), + "postgresql" => "postgresql".to_string(), + other => other.to_string(), + } +} + +fn validate_same_family(source_db_type: &str, target_db_type: &str) -> Result<(), String> { + let (s, t) = (engine_family(source_db_type), engine_family(target_db_type)); + if s != t { + return Err(format!( + "Schema diff requires the same engine family — source is {s}, target is {t}" + )); + } + Ok(()) +} + +fn connection_db_type( + db_store: &std::sync::Mutex, + connection_id: &str, +) -> Result { + let store = db_store.lock().map_err(|e| e.to_string())?; + let conns = store.get_connections().map_err(|e| e.to_string())?; + conns + .iter() + .find(|c| c.id == connection_id) + .map(|c| c.db_type.clone()) + .ok_or_else(|| format!("connection {connection_id} not found")) +} + +#[tauri::command] +pub async fn compare_schemas( + source_connection_id: String, + source_schema: String, + target_connection_id: String, + target_schema: String, + state: State<'_, crate::AppState>, +) -> Result { + let source_db_type = connection_db_type(&state.db_store, &source_connection_id) + .map_err(|e| format!("Source connection failed: {e}"))?; + let target_db_type = connection_db_type(&state.db_store, &target_connection_id) + .map_err(|e| format!("Target connection failed: {e}"))?; + validate_same_family(&source_db_type, &target_db_type)?; + + let mut pm = state.pool_manager.lock().await; + + let source = { + let handle = pm + .get(&source_connection_id) + .ok_or_else(|| "Source connection failed: not connected (open it in the DB viewer first)".to_string())?; + crate::db::schema_diff::capture_snapshot(handle, &source_db_type, &source_schema) + .await + .map_err(|e| format!("Source connection failed: {e}"))? + }; + + let target = { + let handle = pm + .get(&target_connection_id) + .ok_or_else(|| "Target connection failed: not connected".to_string())?; + crate::db::schema_diff::capture_snapshot(handle, &target_db_type, &target_schema) + .await + .map_err(|e| format!("Target connection failed: {e}"))? + }; + + let mut report = crate::db::schema_diff::diff_snapshots(&source, &target); + report.source_label = format!("{} · {}", report.source_label, source_connection_id); + report.target_label = format!("{} · {}", report.target_label, target_connection_id); + Ok(report) +} + +#[cfg(test)] +#[path = "schema_diff.test.rs"] +mod schema_diff_tests; diff --git a/desktop/src-tauri/src/commands/schema_diff.test.rs b/desktop/src-tauri/src/commands/schema_diff.test.rs new file mode 100644 index 0000000..755bb14 --- /dev/null +++ b/desktop/src-tauri/src/commands/schema_diff.test.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn engine_family_pairs_mysql_with_mariadb() { + assert_eq!(engine_family("mysql"), engine_family("mariadb")); + assert_eq!(engine_family("mysql"), "mysql"); + assert_eq!(engine_family("postgresql"), "postgresql"); + assert_eq!(engine_family("sqlite"), "sqlite"); + assert_ne!(engine_family("postgresql"), engine_family("mysql")); +} + +#[test] +fn family_check_rejects_cross_engine_pairs() { + let err = validate_same_family("postgresql", "mysql").expect_err("must reject"); + assert!(err.contains("same engine"), "got: {err}"); + assert!(validate_same_family("mariadb", "mysql").is_ok()); + assert!(validate_same_family("mysql", "mariadb").is_ok()); + assert!(validate_same_family("postgresql", "postgresql").is_ok()); +} + +/// Live end-to-end: register two connections and diff their schemas through +/// the real pool path. +#[test] +#[ignore = "needs the pool manager wiring from the command; run via the UI or the live harness"] +fn compare_command_live_pg() {} diff --git a/desktop/src-tauri/src/db/schema_diff.rs b/desktop/src-tauri/src/db/schema_diff.rs index ba95cfd..b481ca9 100644 --- a/desktop/src-tauri/src/db/schema_diff.rs +++ b/desktop/src-tauri/src/db/schema_diff.rs @@ -216,7 +216,7 @@ pub(crate) async fn capture_mysql( /// Dispatch a snapshot capture across engines. `engine` is the CONNECTION's /// db_type (mariadb vs mysql matters for the diff dialect). pub(crate) async fn capture_snapshot( - handle: &DbHandle, + handle: &mut DbHandle, engine: &str, schema: &str, ) -> Result { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index def56de..f666ba6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ pub struct AppState { use commands::{ backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query, - query_export, schema_graph, settings, tags, + query_export, schema_diff, schema_graph, settings, tags, }; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -173,6 +173,7 @@ pub fn run() { settings::export_settings, settings::import_settings, schema_graph::get_schema_graph, + schema_diff::compare_schemas, query::execute_query, query::execute_query_multi, query_export::export_query_to_file, diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index 4993bfc..662adda 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { ChangePayload } from "./changePayload"; +import type { DiffReport } from "./types"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; export { type ObjectKind, type DdlParams }; @@ -418,4 +419,14 @@ export async function getTablespaces(connectionId: string): Promise(connectionId: string, schema: string, table: string, newColumns: C): Promise { return invoke("build_rebuild_script", { connectionId, schema, table, newColumns }); -} \ No newline at end of file +} +export async function compareSchemas( + sourceConnectionId: string, + sourceSchema: string, + targetConnectionId: string, + targetSchema: string, +): Promise { + return invoke("compare_schemas", { + sourceConnectionId, sourceSchema, targetConnectionId, targetSchema, + }); +} diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 477fef9..dc1bd33 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -506,4 +506,25 @@ export interface Relationship { export interface SchemaGraph { tables: TableNode[]; relationships: Relationship[]; -} \ No newline at end of file +} +export interface DiffDetailLine { + label: string; + old: string; + new: string; +} + +export interface DiffItem { + object_type: string; + name: string; + kind: "added" | "removed" | "changed"; + detail: DiffDetailLine[]; + sync_sql: string[]; + destructive: boolean; +} + +export interface DiffReport { + items: DiffItem[]; + source_label: string; + target_label: string; + truncated: boolean; +} diff --git a/desktop/src/stores/dbViewerStore.diff.test.ts b/desktop/src/stores/dbViewerStore.diff.test.ts new file mode 100644 index 0000000..d107ddd --- /dev/null +++ b/desktop/src/stores/dbViewerStore.diff.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useDbViewerStore } from "./dbViewerStore"; +import type { DiffItem } from "../lib/types"; + +const item = (name: string, sql: string, destructive: boolean): DiffItem => ({ + object_type: "table", + name, + kind: "added", + detail: [], + sync_sql: [sql], + destructive, +}); + +describe("addDiffItems", () => { + beforeEach(() => useDbViewerStore.getState().clearChanges()); + + it("stages one ddl change per non-destructive item, skipping destructive and empty", () => { + const items = [ + item("users", "CREATE TABLE users (id int);", false), + item("old", "DROP TABLE old;", true), + { ...item("weird", "", false), sync_sql: [] }, + ]; + useDbViewerStore.getState().addDiffItems(items); + const queue = useDbViewerStore.getState().changesQueue; + expect(queue).toHaveLength(1); + expect(queue[0].type).toBe("ddl"); + expect(queue[0].sql).toBe("CREATE TABLE users (id int);"); + expect(queue[0].description).toContain("users"); + }); +}); diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index acfa360..aa122b2 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult } from "../lib/types"; +import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult, DiffItem } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; @@ -169,6 +169,7 @@ interface DbViewerState { rows?: unknown[][]; description?: string | null; }) => void; + addDiffItems: (items: DiffItem[]) => void; cancelChange: (changeId: string) => void; removeChange: (changeId: string) => void; updateChange: (changeId: string, patch: Partial) => void; @@ -549,6 +550,19 @@ export const useDbViewerStore = create((set, get) => ({ set((state) => ({ changesQueue: [...state.changesQueue, item] })); }, + addDiffItems: (items) => { + for (const it of items) { + if (it.destructive || it.sync_sql.length === 0) continue; + for (const sql of it.sync_sql) { + get().addChange({ + type: "ddl", + sql, + description: `Schema diff: ${it.object_type} ${it.name} (${it.kind})`, + }); + } + } + }, + cancelChange: (changeId) => set((state) => ({ changesQueue: state.changesQueue.map((c) =>