Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/plans/2026-10-24-schema-compare.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions scripts/schema-compare.test.mjs
Original file line number Diff line number Diff line change
@@ -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'],
);
});
1 change: 1 addition & 0 deletions src-tauri/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
258 changes: 258 additions & 0 deletions src-tauri/src/core/schema_snapshot.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub is_primary_key: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct SchemaTable {
pub schema: Option<String>,
pub name: String,
pub columns: Vec<SchemaColumn>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SchemaSnapshot {
pub engine: String,
pub database: String,
pub tables: Vec<SchemaTable>,
}

pub async fn get_schema_snapshot(
manager: &ConnectionManager,
connection_id: &Uuid,
) -> Result<SchemaSnapshot> {
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::<Option<String>, _>("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::<String, SchemaTable>::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::<Option<String>, _>("column_name") {
table.columns.push(SchemaColumn {
name: column_name,
data_type: row.get("data_type"),
is_nullable: row.get::<String, _>("is_nullable") == "YES",
default_value: row.get("default_value"),
is_primary_key: row.get::<String, _>("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::<String, _>("name") == "main").then(|| row.get::<String, _>("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::<i32, _>("notnull") == 0,
default_value: column.get("dflt_value"),
is_primary_key: column.get::<i32, _>("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();
}
}
Loading
Loading