feat: compare_schemas command + diff staging into the changes queue (Task 15)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<crate::store::Store>,
|
||||
connection_id: &str,
|
||||
) -> Result<String, String> {
|
||||
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<DiffReport, String> {
|
||||
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;
|
||||
@@ -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() {}
|
||||
@@ -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<SchemaSnapshot, String> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TablespaceIn
|
||||
}
|
||||
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
||||
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
||||
}
|
||||
}
|
||||
export async function compareSchemas(
|
||||
sourceConnectionId: string,
|
||||
sourceSchema: string,
|
||||
targetConnectionId: string,
|
||||
targetSchema: string,
|
||||
): Promise<DiffReport> {
|
||||
return invoke<DiffReport>("compare_schemas", {
|
||||
sourceConnectionId, sourceSchema, targetConnectionId, targetSchema,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -506,4 +506,25 @@ export interface Relationship {
|
||||
export interface SchemaGraph {
|
||||
tables: TableNode[];
|
||||
relationships: Relationship[];
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<QueueItem>) => void;
|
||||
@@ -549,6 +550,19 @@ export const useDbViewerStore = create<DbViewerState>((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) =>
|
||||
|
||||
Reference in New Issue
Block a user