diff --git a/README.md b/README.md index 9e15af0..d4f5e53 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ - **Connection-Aware Safe Mode**: Browse tables and run one `SELECT` statement at a time through a read-only database context. Row edits and other writes remain blocked. Use a read-only database account for stronger protection, especially against side effects in database functions. - **Query Plan Inspector**: Explain the selected or cursor `SELECT` without executing it. Inspect estimated plan steps or raw output for PostgreSQL, MySQL/MariaDB, and SQLite. `EXPLAIN ANALYZE` is intentionally unavailable. - **Foreign-Key Navigation**: Follow declared references from table cells into a separate, browse-only filtered tab, then return to the source. Composite keys are supported; masked source values must be revealed before following. +- **Read-Only Schema Compare**: Compare open same-engine connections to spot table and column drift. Review types, nullability, defaults, and primary keys side by side without reading rows or applying migrations. ## Getting Started diff --git a/docs/plans/2026-10-24-schema-compare.md b/docs/plans/2026-10-24-schema-compare.md new file mode 100644 index 0000000..a9fb1ad --- /dev/null +++ b/docs/plans/2026-10-24-schema-compare.md @@ -0,0 +1,24 @@ +# Schema Compare: first slice + +## Goal and scope + +Help users see table and column drift between two currently connected databases of the same engine. The comparison is read-only and does not fetch row data, generate migration SQL, or mutate either connection. Saved but disconnected connections must be opened before selection so credential prompts and network failures remain in the existing connection flow. + +## Design + +- A toolbar action opens a comparison workspace. Source defaults to the active connection; target is another open connection of the same engine. The selected databases are shown prominently and can be swapped. +- Rust returns one metadata-only snapshot per connection. PostgreSQL uses schema-qualified table identities and exact formatted types; MySQL/MariaDB uses the selected database's information schema; SQLite uses main-schema tables and `table_xinfo` for columns. Tables without columns remain visible. +- A pure frontend comparator matches schema/table and column names, then reports added, removed, and changed columns (type, nullability, default, primary key). It distinguishes unchanged tables and never claims an automatic migration is safe. +- The UI shows a searchable summary and side-by-side column details. A failed snapshot leaves no partial diff and offers retry. A selection change discards prior results. All labels are escaped by React. + +## Risks and boundaries + +- PostgreSQL schemas with equal table names must not collide; use schema-qualified identity. +- Different engine types must be rejected before requesting metadata. Cross-engine normalization, index/constraint diffing, data comparison, and automatic sync are deferred. +- Snapshot queries may be slow on very large SQLite databases; show loading and allow closing the view. No row contents or credentials are sent to the comparison code. + +## Implementation plan + +1. **Metadata snapshot:** add `src-tauri/src/core/schema_snapshot.rs`, register `get_schema_snapshot` in `src-tauri/src/lib.rs`, and add a SQLite integration test. Verify with `cargo test --manifest-path src-tauri/Cargo.toml schema_snapshot`. +2. **Pure diff:** add `src/utils/schemaCompare.ts` and `scripts/schema-compare.test.mjs`, including same-name/different-schema, added/removed/changed columns, and comparison direction. Verify with `node --test scripts/schema-compare.test.mjs`. +3. **Interaction:** add a read-only comparison view and toolbar entry point, guarding same-engine/open connections and stale results. Verify with `npm test`, `npm run build`, and the Rust test suite. diff --git a/scripts/schema-compare.test.mjs b/scripts/schema-compare.test.mjs new file mode 100644 index 0000000..0b738d4 --- /dev/null +++ b/scripts/schema-compare.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { build } from 'esbuild'; + +const { outputFiles } = await build({ + entryPoints: ['src/utils/schemaCompare.ts'], + bundle: true, + platform: 'node', + format: 'esm', + write: false, +}); +const { compareSchemas, comparableTargets } = await import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString('base64')}` +); + +const column = (name, data_type = 'integer', extra = {}) => ({ + name, data_type, is_nullable: true, default_value: null, is_primary_key: false, ...extra, +}); +const table = (name, columns, schema = null) => ({ name, schema, columns }); +const snapshot = (tables, engine = 'Postgres') => ({ engine, database: 'app', tables }); + +test('schema compare keeps equal table names in different PostgreSQL schemas separate', () => { + const result = compareSchemas( + snapshot([table('users', [column('id')], 'public'), table('users', [column('id')], 'audit')]), + snapshot([table('users', [column('id')], 'public')]), + ); + assert.equal(result.tables.length, 2); + assert.equal(result.tables.find(entry => entry.schema === 'audit').status, 'source-only'); + assert.equal(result.tables.find(entry => entry.schema === 'public').status, 'unchanged'); +}); + +test('schema compare reports both directions and changed column properties', () => { + const result = compareSchemas( + snapshot([table('projects', [column('id', 'integer', { is_primary_key: true }), column('old')]), table('legacy', [])]), + snapshot([table('projects', [column('id', 'bigint', { is_primary_key: true }), column('new')]), table('fresh', [])]), + ); + assert.equal(result.summary.sourceOnly, 1); + assert.equal(result.summary.targetOnly, 1); + assert.equal(result.summary.changed, 1); + const projects = result.tables.find(entry => entry.name === 'projects'); + assert.equal(projects.status, 'changed'); + assert.deepEqual(projects.columns.map(entry => [entry.name, entry.status]), [ + ['id', 'changed'], ['new', 'target-only'], ['old', 'source-only'], + ]); + assert.deepEqual(projects.columns.find(entry => entry.name === 'id').changedFields, ['data_type']); +}); + +test('schema compare ignores column ordering but detects nullable, default, and key changes', () => { + const result = compareSchemas( + snapshot([table('tasks', [column('name'), column('id')])]), + snapshot([table('tasks', [column('id'), column('name', 'integer', { + is_nullable: false, default_value: '0', is_primary_key: true, + })])]), + ); + const changes = result.tables[0].columns.find(entry => entry.name === 'name'); + assert.deepEqual(changes.changedFields, ['is_nullable', 'default_value', 'is_primary_key']); +}); + +test('schema compare rejects cross-engine snapshots rather than reporting false drift', () => { + assert.throws(() => compareSchemas(snapshot([]), snapshot([], 'MySql')), /same engine/i); +}); + +test('comparison picker lists only other open connections using the source engine', () => { + const connections = [ + { id: 'source', type: 'Postgres' }, { id: 'target', type: 'Postgres' }, + { id: 'closed', type: 'Postgres' }, { id: 'other-engine', type: 'MySql' }, + ]; + assert.deepEqual( + comparableTargets(connections[0], connections, ['source', 'target', 'other-engine']).map(connection => connection.id), + ['target'], + ); +}); diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 5cbf14f..3cdd760 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -8,6 +8,7 @@ pub mod query_engine; pub mod query_parameters; pub mod read_only_sql; pub mod row_commit; +pub mod schema_snapshot; pub mod telemetry; use ai_review::{AiFailedQueryStore, AiProviderTestStore, AiReviewStore}; diff --git a/src-tauri/src/core/schema_snapshot.rs b/src-tauri/src/core/schema_snapshot.rs new file mode 100644 index 0000000..534b1c6 --- /dev/null +++ b/src-tauri/src/core/schema_snapshot.rs @@ -0,0 +1,258 @@ +use crate::core::connection_manager::ConnectionManager; +use anyhow::{anyhow, Result}; +use serde::Serialize; +use sqlx::Row; +use std::collections::BTreeMap; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct SchemaColumn { + pub name: String, + pub data_type: String, + pub is_nullable: bool, + pub default_value: Option, + pub is_primary_key: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SchemaTable { + pub schema: Option, + pub name: String, + pub columns: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SchemaSnapshot { + pub engine: String, + pub database: String, + pub tables: Vec, +} + +pub async fn get_schema_snapshot( + manager: &ConnectionManager, + connection_id: &Uuid, +) -> Result { + if let Some(pool) = manager + .get_postgres_pools() + .await + .get(connection_id) + .cloned() + { + let database: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(&pool) + .await?; + let rows = sqlx::query( + r#" + SELECT n.nspname AS schema_name, c.relname AS table_name, + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + NOT a.attnotnull AS is_nullable, + pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default_value, + EXISTS ( + SELECT 1 FROM pg_catalog.pg_index i + WHERE i.indrelid = c.oid AND i.indisprimary + AND a.attnum = ANY(i.indkey) + ) AS is_primary_key + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid + AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE c.relkind IN ('r', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_toast%' + AND n.nspname NOT LIKE 'pg_temp_%' + ORDER BY n.nspname, c.relname, a.attnum + "#, + ) + .fetch_all(&pool) + .await?; + let mut tables = BTreeMap::<(String, String), SchemaTable>::new(); + for row in rows { + let schema: String = row.get("schema_name"); + let name: String = row.get("table_name"); + let table = tables + .entry((schema.clone(), name.clone())) + .or_insert_with(|| SchemaTable { + schema: Some(schema), + name, + columns: Vec::new(), + }); + if let Some(column_name) = row.get::, _>("column_name") { + table.columns.push(SchemaColumn { + name: column_name, + data_type: row.get("data_type"), + is_nullable: row.get("is_nullable"), + default_value: row.get("default_value"), + is_primary_key: row.get("is_primary_key"), + }); + } + } + return Ok(SchemaSnapshot { + engine: "Postgres".to_string(), + database, + tables: tables.into_values().collect(), + }); + } + + if let Some(pool) = manager.get_mysql_pools().await.get(connection_id).cloned() { + let database: String = sqlx::query_scalar("SELECT DATABASE()") + .fetch_one(&pool) + .await?; + let rows = sqlx::query( + r#" + SELECT t.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, + c.COLUMN_TYPE AS data_type, c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS default_value, c.COLUMN_KEY AS column_key + FROM information_schema.TABLES t + LEFT JOIN information_schema.COLUMNS c + ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME + WHERE t.TABLE_SCHEMA = DATABASE() AND t.TABLE_TYPE = 'BASE TABLE' + ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION + "#, + ) + .fetch_all(&pool) + .await?; + let mut tables = BTreeMap::::new(); + for row in rows { + let name: String = row.get("table_name"); + let table = tables.entry(name.clone()).or_insert_with(|| SchemaTable { + schema: None, + name, + columns: Vec::new(), + }); + if let Some(column_name) = row.get::, _>("column_name") { + table.columns.push(SchemaColumn { + name: column_name, + data_type: row.get("data_type"), + is_nullable: row.get::("is_nullable") == "YES", + default_value: row.get("default_value"), + is_primary_key: row.get::("column_key") == "PRI", + }); + } + } + return Ok(SchemaSnapshot { + engine: "MySql".to_string(), + database, + tables: tables.into_values().collect(), + }); + } + + if let Some(pool) = manager.get_sqlite_pools().await.get(connection_id).cloned() { + let database = sqlx::query("PRAGMA database_list") + .fetch_all(&pool) + .await? + .into_iter() + .find_map(|row| { + (row.get::("name") == "main").then(|| row.get::("file")) + }) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| "main".to_string()); + let rows = sqlx::query( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .fetch_all(&pool) + .await?; + let mut tables = Vec::with_capacity(rows.len()); + for row in rows { + let name: String = row.get("name"); + let sql = format!("PRAGMA table_xinfo(\"{}\")", name.replace('"', "\"\"")); + let column_rows = sqlx::query(&sql).fetch_all(&pool).await?; + let columns = column_rows + .into_iter() + .map(|column| SchemaColumn { + name: column.get("name"), + data_type: column.get("type"), + is_nullable: column.get::("notnull") == 0, + default_value: column.get("dflt_value"), + is_primary_key: column.get::("pk") > 0, + }) + .collect(); + tables.push(SchemaTable { + schema: None, + name, + columns, + }); + } + return Ok(SchemaSnapshot { + engine: "Sqlite".to_string(), + database, + tables, + }); + } + + Err(anyhow!("Connection not found")) +} + +#[cfg(test)] +mod tests { + use super::get_schema_snapshot; + use crate::core::{connection_manager::ConnectionManager, ConnectionConfig}; + use serde_json::json; + use sqlx::{ConnectOptions, Executor}; + use uuid::Uuid; + + #[tokio::test] + async fn schema_snapshot_reads_sqlite_tables_and_columns_without_rows() { + let id = Uuid::new_v4(); + let path = std::env::temp_dir().join(format!("sqlmate-schema-compare-{id}.db")); + let mut setup = sqlx::sqlite::SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .connect() + .await + .unwrap(); + setup + .execute("CREATE TABLE empty_table (id INTEGER PRIMARY KEY)") + .await + .unwrap(); + setup.execute("CREATE TABLE projects (id INTEGER PRIMARY KEY, title TEXT NOT NULL DEFAULT 'untitled', archived_at TEXT)").await.unwrap(); + setup + .execute("INSERT INTO projects (id) VALUES (1)") + .await + .unwrap(); + drop(setup); + + let config: ConnectionConfig = serde_json::from_value(json!({ + "id": id, "name": "test", "db_type": "Sqlite", + "database": path.to_string_lossy(), "ssl_enabled": false, + "ssh_enabled": false + })) + .unwrap(); + let manager = ConnectionManager::new(); + manager.connect(config, None).await.unwrap(); + + let snapshot = get_schema_snapshot(&manager, &id).await.unwrap(); + assert_eq!(snapshot.engine, "Sqlite"); + assert_eq!( + snapshot.database, + path.canonicalize().unwrap().to_string_lossy() + ); + assert_eq!(snapshot.tables.len(), 2); + let projects = snapshot + .tables + .iter() + .find(|table| table.name == "projects") + .unwrap(); + assert_eq!(projects.columns.len(), 3); + let title = projects + .columns + .iter() + .find(|column| column.name == "title") + .unwrap(); + assert_eq!(title.data_type, "TEXT"); + assert!(!title.is_nullable); + assert_eq!(title.default_value.as_deref(), Some("'untitled'")); + assert!( + projects + .columns + .iter() + .find(|column| column.name == "id") + .unwrap() + .is_primary_key + ); + + manager.disconnect(&id).await.unwrap(); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce863c7..4150f24 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use crate::core::ai_review::{self, AiAction, AiReviewPreview, FailedAiQuery}; use crate::core::execution_policy::{ExecutionPolicy, Operation, SafetyMode}; use crate::core::query_engine::QueryEngine; use crate::core::query_parameters::{compile_query, DatabaseDialect, QueryParameter}; +use crate::core::schema_snapshot::{get_schema_snapshot as read_schema_snapshot, SchemaSnapshot}; use crate::core::{ connection_manager::ConnectionManager, telemetry, AiSchemaCacheEntry, AiSchemaTable, AppState, ConnectionConfig, DatabaseType, FilterConfig, QueryPlanResult, QueryResult, SidebarItem, @@ -870,6 +871,16 @@ async fn get_table_structure( .map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_schema_snapshot( + state: State<'_, AppState>, + connection_id: Uuid, +) -> Result { + read_schema_snapshot(&state.connection_manager, &connection_id) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] async fn commit_row_changes( state: State<'_, AppState>, @@ -1332,6 +1343,7 @@ pub fn run() { get_table_count, get_table_metadata, get_table_structure, + get_schema_snapshot, get_sidebar_items, get_sql_completion_tables, get_sql_completion_columns, diff --git a/src-tauri/tests/cross_engine_smoke.rs b/src-tauri/tests/cross_engine_smoke.rs index e1e8d04..12913b4 100644 --- a/src-tauri/tests/cross_engine_smoke.rs +++ b/src-tauri/tests/cross_engine_smoke.rs @@ -8,6 +8,7 @@ use sqlmate_db_lib::core::query_parameters::{ use sqlmate_db_lib::core::row_commit::{ commit_row_changes, MutationKind, RowMutation, RowMutationBatch, }; +use sqlmate_db_lib::core::schema_snapshot::get_schema_snapshot; use sqlmate_db_lib::core::{ConnectionConfig, DatabaseType}; use uuid::Uuid; @@ -110,6 +111,74 @@ async fn run_smoke(database_type: DatabaseType, port: u16) { && key.target_table == table && key.target_columns == ["id"])); + let snapshot = get_schema_snapshot(&manager, &id).await.unwrap(); + assert_eq!(snapshot.database, "sqlmate_smoke"); + let snapshot_table = snapshot + .tables + .iter() + .find(|entry| entry.name == table) + .unwrap(); + let label = snapshot_table + .columns + .iter() + .find(|column| column.name == "label") + .unwrap(); + assert!(!label.is_nullable); + let data_type = label.data_type.to_lowercase(); + assert!(data_type.contains("varchar") || data_type.contains("character varying")); + assert!(data_type.contains("100")); + assert!( + snapshot_table + .columns + .iter() + .find(|column| column.name == "id") + .unwrap() + .is_primary_key + ); + if matches!(&database_type, DatabaseType::Postgres) { + let schema = format!("smoke_schema_{}", Uuid::new_v4().simple()); + QueryEngine::execute_query( + &manager, + &id, + &format!("CREATE SCHEMA {schema}"), + None, + None, + ) + .await + .unwrap(); + QueryEngine::execute_query( + &manager, + &id, + &format!("CREATE TABLE {schema}.{table} (id BIGINT PRIMARY KEY)"), + None, + None, + ) + .await + .unwrap(); + let snapshot = get_schema_snapshot(&manager, &id).await.unwrap(); + let same_name: Vec<_> = snapshot + .tables + .iter() + .filter(|entry| entry.name == table) + .collect(); + assert_eq!(same_name.len(), 2); + assert!(same_name + .iter() + .any(|entry| entry.schema.as_deref() == Some("public"))); + assert!(same_name + .iter() + .any(|entry| entry.schema.as_deref() == Some(&schema))); + QueryEngine::execute_query( + &manager, + &id, + &format!("DROP SCHEMA {schema} CASCADE"), + None, + None, + ) + .await + .unwrap(); + } + let policy = ExecutionPolicy::default(); let read_sql = format!("SELECT label FROM {table} WHERE id = 1"); let read = Operation::Sql(read_sql.clone()); diff --git a/src/App.tsx b/src/App.tsx index ecdf357..e378415 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,15 +13,17 @@ import { DatabaseSelectorModal } from "./components/DatabaseSelectorModal"; import { ObjectDetails } from "./components/ObjectDetails"; import { ImportDialog } from "./components/ImportDialog"; import { ExportDialog } from "./components/ExportDialog"; +import { SchemaCompareDialog } from "./components/SchemaCompareDialog"; import { ConnectionSelectorModal } from "./components/ConnectionSelectorModal"; import { PreferencesDialog } from "./components/preferences/PreferencesDialog"; import { UpdateNotification } from "./components/UpdateNotification"; import { useAppUpdater } from "./hooks/useAppUpdater"; import { useWorkspaceRecovery } from "./hooks/useWorkspaceRecovery"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Group, Panel, Separator } from 'react-resizable-panels'; function App() { + const [showSchemaCompare, setShowSchemaCompare] = useState(false); useWorkspaceRecovery(); useEffect(() => { void useDatabaseStore.getState().loadSavedQueries(); }, []); const updater = useAppUpdater(); @@ -124,6 +126,7 @@ function App() { >
setShowSchemaCompare(true)} onRefresh={() => triggerRefresh(true)} onCheckForUpdates={() => void updater.checkForUpdates()} isCheckingForUpdates={updater.status === 'checking'} @@ -228,6 +231,7 @@ function App() { + {showSchemaCompare && setShowSchemaCompare(false)} />} Not present; + const changed = (field: ColumnDiff['changedFields'][number]) => changedFields.includes(field) ? 'text-orange-300' : 'text-text-secondary'; + return ( +
+
{column.data_type || '(no type)'}
+
+ {column.is_nullable ? 'NULL' : 'NOT NULL'} + {column.is_primary_key && PRIMARY KEY} + {!column.is_primary_key && changedFields.includes('is_primary_key') && not a key} +
+
Default: {column.default_value ?? 'none'}
+
+ ); +} + +function connectionLabel(connection: SavedConnection, database: string | null | undefined) { + return `${connection.name} · ${connection.environment || 'unlabeled'} · ${database || connection.database || 'default database'}`; +} + +function statusTextTone(status: TableDiff['status']) { + if (status === 'source-only') return 'text-amber-300'; + if (status === 'target-only') return 'text-sky-300'; + if (status === 'changed') return 'text-orange-300'; + return 'text-emerald-300'; +} + +export function SchemaCompareDialog({ onClose }: { onClose: () => void }) { + const activeConnectionId = useDatabaseStore(state => state.activeConnectionId); + const openConnectionIds = useDatabaseStore(state => state.openConnectionIds); + const savedConnections = useDatabaseStore(state => state.savedConnections); + const activeDatabases = useDatabaseStore(state => state.activeDatabases); + const [sourceId, setSourceId] = useState(activeConnectionId || openConnectionIds[0] || ''); + const [targetId, setTargetId] = useState(() => { + const source = savedConnections.find(connection => connection.id === activeConnectionId); + return comparableTargets(source, savedConnections, openConnectionIds)[0]?.id || ''; + }); + const [result, setResult] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); + const [search, setSearch] = useState(''); + const [showMatching, setShowMatching] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestId = useRef(0); + + const openConnections = savedConnections.filter(connection => openConnectionIds.includes(connection.id)); + const sourceConnection = openConnections.find(connection => connection.id === sourceId); + const targets = comparableTargets(sourceConnection, savedConnections, openConnectionIds); + const targetConnection = targets.find(connection => connection.id === targetId); + + const clearComparison = () => { + requestId.current += 1; + setResult(null); + setSelectedKey(null); + setError(null); + setLoading(false); + }; + + const compare = async () => { + if (!sourceConnection || !targetConnection || loading) return; + const currentRequest = ++requestId.current; + const sourceDatabase = activeDatabases[sourceId]; + const targetDatabase = activeDatabases[targetId]; + setLoading(true); + setError(null); + setResult(null); + try { + const [source, target] = await Promise.all([ + invoke('get_schema_snapshot', { connectionId: sourceId }), + invoke('get_schema_snapshot', { connectionId: targetId }), + ]); + if (currentRequest !== requestId.current) return; + const state = useDatabaseStore.getState(); + if (!state.openConnectionIds.includes(sourceId) || !state.openConnectionIds.includes(targetId) + || state.activeDatabases[sourceId] !== sourceDatabase + || state.activeDatabases[targetId] !== targetDatabase) { + throw new Error('A connection or selected database changed during comparison. Run it again.'); + } + const diff = compareSchemas(source, target); + setResult({ sourceId, targetId, source, target, diff }); + setSelectedKey(diff.tables.find(table => table.status !== 'unchanged')?.key ?? null); + } catch (cause) { + if (currentRequest === requestId.current) setError(String(cause)); + } finally { + if (currentRequest === requestId.current) setLoading(false); + } + }; + + const visibleTables = result?.diff.tables.filter(table => { + if (!showMatching && table.status === 'unchanged') return false; + return `${table.schema || ''}.${table.name}`.toLocaleLowerCase().includes(search.toLocaleLowerCase()); + }) ?? []; + const selectedTable = visibleTables.find(table => table.key === selectedKey) ?? visibleTables[0]; + const sourceLabel = result ? savedConnections.find(connection => connection.id === result.sourceId)?.name : sourceConnection?.name; + const targetLabel = result ? savedConnections.find(connection => connection.id === result.targetId)?.name : targetConnection?.name; + + return ( + { if (!open) onClose(); }}> + + + +
+
+ +
+ Schema Compare + Compare table names, column types, nullability, defaults, and primary keys. No rows or writes. +
+
+ +
+ +
+
+ + + + +
+ {!targets.length &&

Open another {sourceConnection?.type || 'same-engine'} connection first, then return here to compare.

} +
+ + {error &&

{error}

} + {!result ? ( +
+ +

{loading ? 'Reading schema metadata from both connections...' : 'Select two connections and compare their schemas.'}

+

Only table and column definitions are compared. Changes are never applied from this view.

+
+ ) : ( + <> +
+
+

{sourceLabel} / {result.source.database} → {targetLabel} / {result.target.database}

+ Read-only +
+
+ {result.diff.summary.sourceOnly} only in source + {result.diff.summary.targetOnly} only in target + {result.diff.summary.changed} changed + {result.diff.summary.unchanged} with no checked drift +
+
+
+ +
+ {selectedTable ? ( + <> +
+

{selectedTable.schema && {selectedTable.schema}.}{selectedTable.name}

{selectedTable.columns.filter(column => column.status !== 'unchanged').length} column differences

+ {statusLabel(selectedTable.status)} +
+
+ + + + {selectedTable.columns.map(column => )} + {!selectedTable.columns.length && } + +
ColumnSourceTarget
{column.name}{column.status !== 'unchanged' && {statusLabel(column.status)}}
No columns were returned for this table.
+
+

Column order, indexes, foreign keys, and data are not compared in this first version.

+ + ) :
Choose a table to inspect its columns.
} +
+
+ + )} +
+
+
+ ); +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 938f50e..bf463c9 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { RefreshCw, Layout, Sidebar as SidebarIcon, Terminal, Save, RotateCcw, ChevronRight, Database, Server, Check, Search, X, Key, Loader2, Upload, Download } from 'lucide-react'; +import { RefreshCw, Layout, Sidebar as SidebarIcon, Terminal, Save, RotateCcw, ChevronRight, Database, Server, Check, Search, X, Key, Loader2, Upload, Download, GitCompareArrows } from 'lucide-react'; import { useDatabaseStore, SavedConnection } from '../store/databaseStore'; import { invoke } from '@tauri-apps/api/core'; import { useOutsideClick } from '../hooks/useOutsideClick'; @@ -14,6 +14,7 @@ interface ToolbarProps { onCommit?: () => void; onDiscard?: () => void; pendingChangesCount?: number; + onCompareSchema?: () => void; } export const Toolbar = ({ @@ -22,7 +23,8 @@ export const Toolbar = ({ isCheckingForUpdates = false, onCommit, onDiscard, - pendingChangesCount = 0 + pendingChangesCount = 0, + onCompareSchema, }: ToolbarProps) => { const { activeConnectionId, @@ -179,6 +181,15 @@ export const Toolbar = ({ > +
diff --git a/src/utils/schemaCompare.ts b/src/utils/schemaCompare.ts new file mode 100644 index 0000000..10858bb --- /dev/null +++ b/src/utils/schemaCompare.ts @@ -0,0 +1,115 @@ +export interface SchemaColumn { + name: string; + data_type: string; + is_nullable: boolean; + default_value: string | null; + is_primary_key: boolean; +} + +export interface SchemaTable { + schema: string | null; + name: string; + columns: SchemaColumn[]; +} + +export interface SchemaSnapshot { + engine: 'Postgres' | 'MySql' | 'Sqlite'; + database: string; + tables: SchemaTable[]; +} + +export type DiffStatus = 'source-only' | 'target-only' | 'changed' | 'unchanged'; +export type ChangedField = 'data_type' | 'is_nullable' | 'default_value' | 'is_primary_key'; + +export interface ColumnDiff { + name: string; + status: DiffStatus; + source?: SchemaColumn; + target?: SchemaColumn; + changedFields: ChangedField[]; +} + +export interface TableDiff { + key: string; + schema: string | null; + name: string; + status: DiffStatus; + source?: SchemaTable; + target?: SchemaTable; + columns: ColumnDiff[]; +} + +export interface SchemaDiff { + tables: TableDiff[]; + summary: { sourceOnly: number; targetOnly: number; changed: number; unchanged: number }; +} + +export function comparableTargets( + source: T | undefined, + connections: T[], + openConnectionIds: string[], +): T[] { + if (!source) return []; + const open = new Set(openConnectionIds); + return connections.filter(connection => connection.id !== source.id + && connection.type === source.type && open.has(connection.id)); +} + +const tableKey = (table: SchemaTable) => JSON.stringify([table.schema ?? '', table.name]); + +function compareColumns(source?: SchemaTable, target?: SchemaTable): ColumnDiff[] { + const sourceColumns = new Map(source?.columns.map(column => [column.name, column]) ?? []); + const targetColumns = new Map(target?.columns.map(column => [column.name, column]) ?? []); + return [...new Set([...sourceColumns.keys(), ...targetColumns.keys()])] + .sort((left, right) => left.localeCompare(right)) + .map((name): ColumnDiff => { + const sourceColumn = sourceColumns.get(name); + const targetColumn = targetColumns.get(name); + const changedFields: ChangedField[] = sourceColumn && targetColumn + ? (['data_type', 'is_nullable', 'default_value', 'is_primary_key'] as ChangedField[]) + .filter(field => sourceColumn[field] !== targetColumn[field]) + : []; + return { + name, + status: !sourceColumn ? 'target-only' : !targetColumn ? 'source-only' : changedFields.length ? 'changed' : 'unchanged', + source: sourceColumn, + target: targetColumn, + changedFields, + }; + }); +} + +export function compareSchemas(source: SchemaSnapshot, target: SchemaSnapshot): SchemaDiff { + if (source.engine !== target.engine) { + throw new Error('Schema comparison requires the same engine on both connections.'); + } + const sourceTables = new Map(source.tables.map(table => [tableKey(table), table])); + const targetTables = new Map(target.tables.map(table => [tableKey(table), table])); + const tables = [...new Set([...sourceTables.keys(), ...targetTables.keys()])] + .sort((left, right) => left.localeCompare(right)) + .map((key): TableDiff => { + const sourceTable = sourceTables.get(key); + const targetTable = targetTables.get(key); + const table = sourceTable ?? targetTable!; + const columns = compareColumns(sourceTable, targetTable); + return { + key, + schema: table.schema, + name: table.name, + status: !sourceTable ? 'target-only' : !targetTable ? 'source-only' + : columns.some(column => column.status !== 'unchanged') ? 'changed' : 'unchanged', + source: sourceTable, + target: targetTable, + columns, + }; + }); + return { + tables, + summary: { + sourceOnly: tables.filter(table => table.status === 'source-only').length, + targetOnly: tables.filter(table => table.status === 'target-only').length, + changed: tables.filter(table => table.status === 'changed').length, + unchanged: tables.filter(table => table.status === 'unchanged').length, + }, + }; +}