From eaeb7a93bd8534c46dfb1e98d7fba63ffe44e84d Mon Sep 17 00:00:00 2001 From: Robert Faust Date: Sat, 6 Sep 2025 15:06:28 +0000 Subject: [PATCH] feat: Initial implementation of database comparison tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Tauri desktop application with React frontend - Implement Node.js sidecar for database operations - Add MS SQL Server driver with Windows authentication support - Create extensible plugin system for additional database drivers - Implement schema comparison (tables, columns, indexes, foreign keys, views, procedures, functions) - Add basic data comparison (row counts) - Create intuitive UI with connection forms and comparison results view - Include comprehensive documentation and development setup - Add example MySQL plugin to demonstrate plugin architecture Features: ✅ MS SQL Server support with Windows auth ✅ Schema comparison and diff visualization ✅ Data comparison capabilities ✅ Plugin system for extensibility ✅ Cross-platform desktop application ✅ Professional UI with Tailwind CSS ✅ TypeScript throughout for type safety ✅ Comprehensive error handling and logging Architecture: - Frontend: React + TypeScript + Tailwind CSS - Backend: Tauri (Rust) + Node.js sidecar - Database: Plugin-based driver system - Communication: Tauri commands + REST API --- .env.example | 16 ++ .gitignore | 61 +++++ README.md | 114 ++++++++- docs/plugin-development.md | 174 +++++++++++++ index.html | 13 + package.json | 57 +++++ plugins/example-mysql/index.js | 205 ++++++++++++++++ plugins/example-mysql/package.json | 12 + postcss.config.js | 6 + scripts/dev.sh | 48 ++++ sidecar/package.json | 45 ++++ sidecar/src/comparison/engine.ts | 338 ++++++++++++++++++++++++++ sidecar/src/database/drivers/mssql.ts | 321 ++++++++++++++++++++++++ sidecar/src/database/manager.ts | 76 ++++++ sidecar/src/database/types.ts | 66 +++++ sidecar/src/index.ts | 133 ++++++++++ sidecar/src/plugins/manager.ts | 157 ++++++++++++ sidecar/src/utils/logger.ts | 26 ++ sidecar/src/utils/validation.ts | 37 +++ sidecar/tsconfig.json | 24 ++ src-tauri/Cargo.toml | 28 +++ src-tauri/build.rs | 3 + src-tauri/src/commands.rs | 164 +++++++++++++ src-tauri/src/main.rs | 46 ++++ src-tauri/src/sidecar.rs | 151 ++++++++++++ src-tauri/tauri.conf.json | 86 +++++++ src/App.tsx | 87 +++++++ src/components/ComparisonView.tsx | 191 +++++++++++++++ src/components/ConnectionForm.tsx | 205 ++++++++++++++++ src/components/Header.tsx | 17 ++ src/components/StatusBar.tsx | 52 ++++ src/index.css | 59 +++++ src/main.tsx | 22 ++ src/services/tauri.ts | 36 +++ src/types/database.ts | 91 +++++++ src/utils/cn.ts | 6 + tailwind.config.js | 52 ++++ tsconfig.json | 36 +++ tsconfig.node.json | 10 + vite.config.ts | 32 +++ 40 files changed, 3301 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docs/plugin-development.md create mode 100644 index.html create mode 100644 package.json create mode 100644 plugins/example-mysql/index.js create mode 100644 plugins/example-mysql/package.json create mode 100644 postcss.config.js create mode 100755 scripts/dev.sh create mode 100644 sidecar/package.json create mode 100644 sidecar/src/comparison/engine.ts create mode 100644 sidecar/src/database/drivers/mssql.ts create mode 100644 sidecar/src/database/manager.ts create mode 100644 sidecar/src/database/types.ts create mode 100644 sidecar/src/index.ts create mode 100644 sidecar/src/plugins/manager.ts create mode 100644 sidecar/src/utils/logger.ts create mode 100644 sidecar/src/utils/validation.ts create mode 100644 sidecar/tsconfig.json create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/sidecar.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/App.tsx create mode 100644 src/components/ComparisonView.tsx create mode 100644 src/components/ConnectionForm.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/services/tauri.ts create mode 100644 src/types/database.ts create mode 100644 src/utils/cn.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7779ce --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Environment variables for Open Data Diff + +# Sidecar configuration +PORT=3001 +LOG_LEVEL=info +NODE_ENV=development + +# Database connection examples (for testing) +# MSSQL_HOST=localhost +# MSSQL_PORT=1433 +# MSSQL_DATABASE=TestDB +# MSSQL_USERNAME=sa +# MSSQL_PASSWORD=YourPassword + +# Plugin directory (optional) +# PLUGIN_DIRECTORY=./plugins diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74c5c63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Tauri +src-tauri/target + +# Sidecar +sidecar/dist +sidecar/node_modules + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Database files +*.db +*.sqlite +*.sqlite3 + +# OS generated files +Thumbs.db +.DS_Store + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Rust +target/ +Cargo.lock + +# Build artifacts +build/ +out/ diff --git a/README.md b/README.md index ccbb9d9..8ad25ea 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,115 @@ -# open-data-diff -Open source database comparison tool +# Open Data Diff + +An open-source database comparison tool built with Tauri, React, TypeScript, and Node.js. Compare database schemas and data across different database systems with an intuitive desktop interface. + +## Features + +- 🔍 **Schema Comparison**: Compare tables, columns, indexes, foreign keys, views, procedures, and functions +- 📊 **Data Comparison**: Compare row counts and detect data mismatches +- 🔌 **Plugin System**: Extensible architecture for adding new database drivers +- 🖥️ **Desktop App**: Cross-platform desktop application built with Tauri +- 🛡️ **Windows Authentication**: Built-in support for MS SQL Server with Windows authentication +- ⚡ **Fast Performance**: Rust backend with Node.js sidecar for database operations + +## Supported Databases + +### Built-in Support +- ✅ **Microsoft SQL Server** (with Windows Authentication) + +### Coming Soon (Plugin System) +- 🔄 MySQL +- 🔄 PostgreSQL +- 🔄 SQLite +- 🔄 MongoDB + +## Architecture + +- **Frontend**: React + TypeScript + Tailwind CSS +- **Backend**: Tauri (Rust) + Node.js sidecar +- **Database Drivers**: Plugin-based architecture +- **Communication**: Tauri commands + REST API + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later) +- [Rust](https://rustup.rs/) (latest stable) +- [Tauri CLI](https://tauri.app/v1/guides/getting-started/prerequisites) + +## Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/your-username/open-data-diff.git + cd open-data-diff + ``` + +2. **Install dependencies** + ```bash + # Install main project dependencies + npm install + + # Install sidecar dependencies + cd sidecar + npm install + cd .. + ``` + +3. **Build the sidecar** + ```bash + npm run sidecar:build + ``` + +4. **Run in development mode** + ```bash + npm run tauri:dev + ``` + +## Building for Production + +```bash +# Build the sidecar first +npm run sidecar:build + +# Build the Tauri application +npm run tauri:build +``` + +## Usage + +1. **Launch the application** +2. **Configure source database connection** + - Enter connection details (host, port, database name) + - Choose authentication method (Windows Auth or SQL Auth) + - Test the connection +3. **Configure target database connection** +4. **Click "Compare Databases"** +5. **Review the comparison results** + - Schema differences (tables, columns, indexes, etc.) + - Data differences (row counts, data mismatches) + +## Plugin Development + +Create custom database drivers by implementing the `DatabaseDriver` interface: + +```typescript +interface DatabaseDriver { + name: string; + testConnection(connection: any): Promise; + getSchema(connection: any): Promise; + executeQuery(connection: any, query: string): Promise; + disconnect(): Promise; +} +``` + +See the [Plugin Development Guide](docs/plugin-development.md) for detailed instructions. + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request ## License diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 0000000..ab0c35c --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,174 @@ +# Plugin Development Guide + +This guide explains how to create custom database drivers for Open Data Diff. + +## Plugin Structure + +A plugin is a Node.js module that exports a function returning a `DatabaseDriver` implementation. + +### Directory Structure +``` +my-plugin/ +├── package.json +├── index.js (or index.ts) +└── README.md +``` + +### package.json Requirements +```json +{ + "name": "my-database-plugin", + "version": "1.0.0", + "description": "Custom database driver for MyDB", + "main": "index.js", + "databaseDriver": "mydb", + "author": "Your Name", + "keywords": ["database", "open-data-diff", "plugin"] +} +``` + +The `databaseDriver` field is required and specifies the driver name. + +## DatabaseDriver Interface + +```typescript +interface DatabaseDriver { + name: string; + testConnection(connection: any): Promise; + getSchema(connection: any): Promise; + executeQuery(connection: any, query: string): Promise; + disconnect(): Promise; +} +``` + +### Methods + +#### `testConnection(connection)` +Tests if a connection can be established to the database. +- **Parameters**: Connection configuration object +- **Returns**: `Promise` - true if connection successful + +#### `getSchema(connection)` +Retrieves the complete database schema. +- **Parameters**: Connection configuration object +- **Returns**: `Promise` - schema object with tables, views, etc. + +#### `executeQuery(connection, query)` +Executes a SQL query and returns results. +- **Parameters**: + - `connection`: Connection configuration object + - `query`: SQL query string +- **Returns**: `Promise` - array of result rows + +#### `disconnect()` +Closes all connections and cleans up resources. +- **Returns**: `Promise` + +## Example Plugin + +```javascript +// index.js +const mysql = require('mysql2/promise'); + +class MySQLDriver { + constructor() { + this.name = 'mysql'; + this.pools = new Map(); + } + + async testConnection(connection) { + try { + const pool = await this.getPool(connection); + const [rows] = await pool.execute('SELECT 1 as test'); + return rows.length > 0; + } catch (error) { + return false; + } + } + + async getSchema(connection) { + const pool = await this.getPool(connection); + + // Implement schema retrieval logic + const tables = await this.getTables(pool); + const views = await this.getViews(pool); + + return { + tables, + views, + procedures: [], + functions: [] + }; + } + + async executeQuery(connection, query) { + const pool = await this.getPool(connection); + const [rows] = await pool.execute(query); + return rows; + } + + async disconnect() { + for (const pool of this.pools.values()) { + await pool.end(); + } + this.pools.clear(); + } + + async getPool(connection) { + const key = `${connection.host}:${connection.port}:${connection.database}`; + + if (!this.pools.has(key)) { + const pool = mysql.createPool({ + host: connection.host, + port: connection.port || 3306, + user: connection.username, + password: connection.password, + database: connection.database, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 + }); + + this.pools.set(key, pool); + } + + return this.pools.get(key); + } + + // Implement getTables, getViews, etc. +} + +module.exports = function() { + return new MySQLDriver(); +}; +``` + +## Installation + +1. Place your plugin directory in the `plugins/` folder of the application +2. The plugin will be automatically discovered on startup +3. Load the plugin through the UI or API + +## Best Practices + +- Always handle connection errors gracefully +- Implement proper connection pooling +- Clean up resources in the `disconnect()` method +- Use parameterized queries to prevent SQL injection +- Follow the schema format exactly as defined in the types +- Test your plugin thoroughly with different connection scenarios + +## Schema Format + +Ensure your `getSchema()` method returns data in the correct format: + +```typescript +interface DatabaseSchema { + tables: TableSchema[]; + views: ViewSchema[]; + procedures: ProcedureSchema[]; + functions: FunctionSchema[]; +} +``` + +See the TypeScript definitions in `src/types/database.ts` for complete schema structure. diff --git a/index.html b/index.html new file mode 100644 index 0000000..6911c45 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Open Data Diff + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b975dbb --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "open-data-diff", + "version": "0.1.0", + "description": "Open source database comparison tool", + "main": "src/main.tsx", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "sidecar:dev": "cd sidecar && npm run dev", + "sidecar:build": "cd sidecar && npm run build" + }, + "dependencies": { + "@tauri-apps/api": "^1.5.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@tanstack/react-query": "^5.0.0", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^1.5.8", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + }, + "repository": { + "type": "git", + "url": "https://github.com/your-username/open-data-diff.git" + }, + "keywords": [ + "database", + "comparison", + "diff", + "sql", + "tauri", + "react", + "typescript" + ], + "author": "Robert Faust", + "license": "Apache-2.0" +} diff --git a/plugins/example-mysql/index.js b/plugins/example-mysql/index.js new file mode 100644 index 0000000..a33f166 --- /dev/null +++ b/plugins/example-mysql/index.js @@ -0,0 +1,205 @@ +const mysql = require('mysql2/promise'); + +class MySQLDriver { + constructor() { + this.name = 'mysql'; + this.pools = new Map(); + } + + async testConnection(connection) { + try { + const pool = await this.getPool(connection); + const [rows] = await pool.execute('SELECT 1 as test'); + return rows.length > 0; + } catch (error) { + console.error('MySQL connection test failed:', error); + return false; + } + } + + async getSchema(connection) { + try { + const pool = await this.getPool(connection); + + const tables = await this.getTables(pool, connection.database); + const views = await this.getViews(pool, connection.database); + + return { + tables, + views, + procedures: [], // TODO: Implement procedures + functions: [] // TODO: Implement functions + }; + } catch (error) { + console.error('MySQL schema retrieval failed:', error); + throw error; + } + } + + async getTables(pool, database) { + const [tables] = await pool.execute(` + SELECT TABLE_NAME as table_name, TABLE_SCHEMA as schema_name + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME + `, [database]); + + const result = []; + for (const table of tables) { + const columns = await this.getTableColumns(pool, database, table.table_name); + const indexes = await this.getTableIndexes(pool, database, table.table_name); + const foreignKeys = await this.getTableForeignKeys(pool, database, table.table_name); + + result.push({ + name: table.table_name, + schema: table.schema_name, + columns, + indexes, + foreign_keys: foreignKeys + }); + } + + return result; + } + + async getTableColumns(pool, database, tableName) { + const [columns] = await pool.execute(` + SELECT + COLUMN_NAME as column_name, + DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, + COLUMN_DEFAULT as default_value, + CHARACTER_MAXIMUM_LENGTH as max_length, + NUMERIC_PRECISION as precision, + NUMERIC_SCALE as scale, + COLUMN_KEY as column_key, + EXTRA as extra + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + `, [database, tableName]); + + return columns.map(col => ({ + name: col.column_name, + data_type: col.data_type, + is_nullable: col.is_nullable === 'YES', + default_value: col.default_value, + is_primary_key: col.column_key === 'PRI', + is_identity: col.extra.includes('auto_increment'), + max_length: col.max_length, + precision: col.precision, + scale: col.scale + })); + } + + async getTableIndexes(pool, database, tableName) { + const [indexes] = await pool.execute(` + SELECT + INDEX_NAME as index_name, + NON_UNIQUE as non_unique, + COLUMN_NAME as column_name + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY INDEX_NAME, SEQ_IN_INDEX + `, [database, tableName]); + + const indexMap = new Map(); + for (const idx of indexes) { + if (!indexMap.has(idx.index_name)) { + indexMap.set(idx.index_name, { + name: idx.index_name, + is_unique: idx.non_unique === 0, + is_primary: idx.index_name === 'PRIMARY', + columns: [] + }); + } + indexMap.get(idx.index_name).columns.push(idx.column_name); + } + + return Array.from(indexMap.values()); + } + + async getTableForeignKeys(pool, database, tableName) { + const [fks] = await pool.execute(` + SELECT + CONSTRAINT_NAME as fk_name, + COLUMN_NAME as column_name, + REFERENCED_TABLE_NAME as referenced_table, + REFERENCED_COLUMN_NAME as referenced_column + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL + `, [database, tableName]); + + return fks.map(fk => ({ + name: fk.fk_name, + column: fk.column_name, + referenced_table: fk.referenced_table, + referenced_column: fk.referenced_column + })); + } + + async getViews(pool, database) { + const [views] = await pool.execute(` + SELECT + TABLE_NAME as view_name, + TABLE_SCHEMA as schema_name, + VIEW_DEFINITION as definition + FROM INFORMATION_SCHEMA.VIEWS + WHERE TABLE_SCHEMA = ? + ORDER BY TABLE_NAME + `, [database]); + + return views.map(view => ({ + name: view.view_name, + schema: view.schema_name, + definition: view.definition + })); + } + + async executeQuery(connection, query) { + try { + const pool = await this.getPool(connection); + const [rows] = await pool.execute(query); + return rows; + } catch (error) { + console.error('MySQL query execution failed:', error); + throw error; + } + } + + async disconnect() { + for (const [key, pool] of this.pools) { + try { + await pool.end(); + this.pools.delete(key); + } catch (error) { + console.error(`Failed to close MySQL pool ${key}:`, error); + } + } + } + + async getPool(connection) { + const key = `${connection.host}:${connection.port || 3306}:${connection.database}`; + + if (!this.pools.has(key)) { + const pool = mysql.createPool({ + host: connection.host, + port: connection.port || 3306, + user: connection.username, + password: connection.password, + database: connection.database, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 + }); + + this.pools.set(key, pool); + } + + return this.pools.get(key); + } +} + +module.exports = function() { + return new MySQLDriver(); +}; diff --git a/plugins/example-mysql/package.json b/plugins/example-mysql/package.json new file mode 100644 index 0000000..05427ef --- /dev/null +++ b/plugins/example-mysql/package.json @@ -0,0 +1,12 @@ +{ + "name": "open-data-diff-mysql-plugin", + "version": "1.0.0", + "description": "MySQL database driver plugin for Open Data Diff", + "main": "index.js", + "databaseDriver": "mysql", + "author": "Open Data Diff Team", + "keywords": ["database", "mysql", "open-data-diff", "plugin"], + "dependencies": { + "mysql2": "^3.6.5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..af68018 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Development script for Open Data Diff + +set -e + +echo "🚀 Starting Open Data Diff development environment..." + +# Check if Node.js is installed +if ! command -v node &> /dev/null; then + echo "❌ Node.js is not installed. Please install Node.js first." + exit 1 +fi + +# Check if Rust is installed +if ! command -v cargo &> /dev/null; then + echo "❌ Rust is not installed. Please install Rust first." + exit 1 +fi + +# Install main dependencies +echo "📦 Installing main dependencies..." +npm install + +# Install sidecar dependencies +echo "📦 Installing sidecar dependencies..." +cd sidecar +npm install +cd .. + +# Build sidecar +echo "🔨 Building sidecar..." +npm run sidecar:build + +# Check if Tauri CLI is installed +if ! command -v cargo tauri &> /dev/null; then + echo "📦 Installing Tauri CLI..." + cargo install tauri-cli +fi + +echo "✅ Setup complete! Starting development server..." +echo "" +echo "🌐 Frontend will be available at: http://localhost:1420" +echo "🔧 Sidecar API will be available at: http://localhost:3001" +echo "" + +# Start development +npm run tauri:dev diff --git a/sidecar/package.json b/sidecar/package.json new file mode 100644 index 0000000..cf5b175 --- /dev/null +++ b/sidecar/package.json @@ -0,0 +1,45 @@ +{ + "name": "open-data-diff-sidecar", + "version": "0.1.0", + "description": "Node.js sidecar for database operations", + "main": "dist/index.js", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "mssql": "^10.0.1", + "mysql2": "^3.6.5", + "pg": "^8.11.3", + "sqlite3": "^5.1.6", + "mongodb": "^6.3.0", + "dotenv": "^16.3.1", + "winston": "^3.11.0", + "joi": "^17.11.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/mssql": "^9.1.4", + "@types/pg": "^8.10.9", + "@types/node": "^20.10.5", + "tsx": "^4.6.2", + "typescript": "^5.3.3" + }, + "keywords": [ + "database", + "comparison", + "sidecar", + "mssql", + "mysql", + "postgresql", + "sqlite", + "mongodb" + ], + "author": "Robert Faust", + "license": "Apache-2.0" +} diff --git a/sidecar/src/comparison/engine.ts b/sidecar/src/comparison/engine.ts new file mode 100644 index 0000000..d06ca61 --- /dev/null +++ b/sidecar/src/comparison/engine.ts @@ -0,0 +1,338 @@ +import { DatabaseManager } from '../database/manager.js'; +import { DatabaseConnection } from '../utils/validation.js'; +import { DatabaseSchema, TableSchema, ColumnSchema } from '../database/types.js'; +import { logger } from '../utils/logger.js'; + +export interface ComparisonResult { + schema_differences: SchemaDifference[]; + data_differences: DataDifference[]; +} + +export interface SchemaDifference { + object_type: string; + object_name: string; + difference_type: 'added' | 'removed' | 'modified'; + details: string; +} + +export interface DataDifference { + table_name: string; + difference_type: 'row_count' | 'data_mismatch'; + details: string; +} + +export class ComparisonEngine { + constructor(private databaseManager: DatabaseManager) {} + + async compare(source: DatabaseConnection, target: DatabaseConnection): Promise { + try { + logger.info(`Starting comparison between ${source.name} and ${target.name}`); + + // Get schemas from both databases + const [sourceSchema, targetSchema] = await Promise.all([ + this.databaseManager.getSchema(source), + this.databaseManager.getSchema(target), + ]); + + // Compare schemas + const schemaDifferences = this.compareSchemas(sourceSchema, targetSchema); + + // Compare data (basic row counts for now) + const dataDifferences = await this.compareData(source, target, sourceSchema, targetSchema); + + return { + schema_differences: schemaDifferences, + data_differences: dataDifferences, + }; + } catch (error) { + logger.error('Database comparison failed:', error); + throw error; + } + } + + private compareSchemas(source: DatabaseSchema, target: DatabaseSchema): SchemaDifference[] { + const differences: SchemaDifference[] = []; + + // Compare tables + differences.push(...this.compareTables(source.tables, target.tables)); + + // Compare views + differences.push(...this.compareViews(source.views, target.views)); + + // Compare procedures + differences.push(...this.compareProcedures(source.procedures, target.procedures)); + + // Compare functions + differences.push(...this.compareFunctions(source.functions, target.functions)); + + return differences; + } + + private compareTables(sourceTables: TableSchema[], targetTables: TableSchema[]): SchemaDifference[] { + const differences: SchemaDifference[] = []; + const sourceTableMap = new Map(sourceTables.map(t => [`${t.schema}.${t.name}`, t])); + const targetTableMap = new Map(targetTables.map(t => [`${t.schema}.${t.name}`, t])); + + // Find tables only in source + for (const [tableName, table] of sourceTableMap) { + if (!targetTableMap.has(tableName)) { + differences.push({ + object_type: 'table', + object_name: tableName, + difference_type: 'removed', + details: `Table exists in source but not in target`, + }); + } + } + + // Find tables only in target + for (const [tableName, table] of targetTableMap) { + if (!sourceTableMap.has(tableName)) { + differences.push({ + object_type: 'table', + object_name: tableName, + difference_type: 'added', + details: `Table exists in target but not in source`, + }); + } + } + + // Compare common tables + for (const [tableName, sourceTable] of sourceTableMap) { + const targetTable = targetTableMap.get(tableName); + if (targetTable) { + differences.push(...this.compareTableStructure(sourceTable, targetTable)); + } + } + + return differences; + } + + private compareTableStructure(source: TableSchema, target: TableSchema): SchemaDifference[] { + const differences: SchemaDifference[] = []; + const tableName = `${source.schema}.${source.name}`; + + // Compare columns + const sourceColumnMap = new Map(source.columns.map(c => [c.name, c])); + const targetColumnMap = new Map(target.columns.map(c => [c.name, c])); + + // Find columns only in source + for (const [columnName, column] of sourceColumnMap) { + if (!targetColumnMap.has(columnName)) { + differences.push({ + object_type: 'column', + object_name: `${tableName}.${columnName}`, + difference_type: 'removed', + details: `Column exists in source but not in target`, + }); + } + } + + // Find columns only in target + for (const [columnName, column] of targetColumnMap) { + if (!sourceColumnMap.has(columnName)) { + differences.push({ + object_type: 'column', + object_name: `${tableName}.${columnName}`, + difference_type: 'added', + details: `Column exists in target but not in source`, + }); + } + } + + // Compare common columns + for (const [columnName, sourceColumn] of sourceColumnMap) { + const targetColumn = targetColumnMap.get(columnName); + if (targetColumn) { + const columnDiffs = this.compareColumns(sourceColumn, targetColumn); + if (columnDiffs.length > 0) { + differences.push({ + object_type: 'column', + object_name: `${tableName}.${columnName}`, + difference_type: 'modified', + details: columnDiffs.join('; '), + }); + } + } + } + + return differences; + } + + private compareColumns(source: ColumnSchema, target: ColumnSchema): string[] { + const differences: string[] = []; + + if (source.data_type !== target.data_type) { + differences.push(`Data type changed from ${source.data_type} to ${target.data_type}`); + } + + if (source.is_nullable !== target.is_nullable) { + differences.push(`Nullable changed from ${source.is_nullable} to ${target.is_nullable}`); + } + + if (source.is_primary_key !== target.is_primary_key) { + differences.push(`Primary key changed from ${source.is_primary_key} to ${target.is_primary_key}`); + } + + if (source.max_length !== target.max_length) { + differences.push(`Max length changed from ${source.max_length} to ${target.max_length}`); + } + + if (source.default_value !== target.default_value) { + differences.push(`Default value changed from ${source.default_value} to ${target.default_value}`); + } + + return differences; + } + + private compareViews(sourceViews: any[], targetViews: any[]): SchemaDifference[] { + const differences: SchemaDifference[] = []; + const sourceViewMap = new Map(sourceViews.map(v => [`${v.schema}.${v.name}`, v])); + const targetViewMap = new Map(targetViews.map(v => [`${v.schema}.${v.name}`, v])); + + // Find views only in source + for (const [viewName] of sourceViewMap) { + if (!targetViewMap.has(viewName)) { + differences.push({ + object_type: 'view', + object_name: viewName, + difference_type: 'removed', + details: `View exists in source but not in target`, + }); + } + } + + // Find views only in target + for (const [viewName] of targetViewMap) { + if (!sourceViewMap.has(viewName)) { + differences.push({ + object_type: 'view', + object_name: viewName, + difference_type: 'added', + details: `View exists in target but not in source`, + }); + } + } + + // Compare common views (basic definition comparison) + for (const [viewName, sourceView] of sourceViewMap) { + const targetView = targetViewMap.get(viewName); + if (targetView && sourceView.definition !== targetView.definition) { + differences.push({ + object_type: 'view', + object_name: viewName, + difference_type: 'modified', + details: `View definition has changed`, + }); + } + } + + return differences; + } + + private compareProcedures(sourceProcedures: any[], targetProcedures: any[]): SchemaDifference[] { + const differences: SchemaDifference[] = []; + const sourceProcMap = new Map(sourceProcedures.map(p => [`${p.schema}.${p.name}`, p])); + const targetProcMap = new Map(targetProcedures.map(p => [`${p.schema}.${p.name}`, p])); + + // Find procedures only in source + for (const [procName] of sourceProcMap) { + if (!targetProcMap.has(procName)) { + differences.push({ + object_type: 'procedure', + object_name: procName, + difference_type: 'removed', + details: `Procedure exists in source but not in target`, + }); + } + } + + // Find procedures only in target + for (const [procName] of targetProcMap) { + if (!sourceProcMap.has(procName)) { + differences.push({ + object_type: 'procedure', + object_name: procName, + difference_type: 'added', + details: `Procedure exists in target but not in source`, + }); + } + } + + return differences; + } + + private compareFunctions(sourceFunctions: any[], targetFunctions: any[]): SchemaDifference[] { + const differences: SchemaDifference[] = []; + const sourceFuncMap = new Map(sourceFunctions.map(f => [`${f.schema}.${f.name}`, f])); + const targetFuncMap = new Map(targetFunctions.map(f => [`${f.schema}.${f.name}`, f])); + + // Find functions only in source + for (const [funcName] of sourceFuncMap) { + if (!targetFuncMap.has(funcName)) { + differences.push({ + object_type: 'function', + object_name: funcName, + difference_type: 'removed', + details: `Function exists in source but not in target`, + }); + } + } + + // Find functions only in target + for (const [funcName] of targetFuncMap) { + if (!sourceFuncMap.has(funcName)) { + differences.push({ + object_type: 'function', + object_name: funcName, + difference_type: 'added', + details: `Function exists in target but not in source`, + }); + } + } + + return differences; + } + + private async compareData( + source: DatabaseConnection, + target: DatabaseConnection, + sourceSchema: DatabaseSchema, + targetSchema: DatabaseSchema + ): Promise { + const differences: DataDifference[] = []; + + // Get common tables + const sourceTableNames = new Set(sourceSchema.tables.map(t => `${t.schema}.${t.name}`)); + const targetTableNames = new Set(targetSchema.tables.map(t => `${t.schema}.${t.name}`)); + const commonTables = [...sourceTableNames].filter(name => targetTableNames.has(name)); + + // Compare row counts for common tables + for (const tableName of commonTables) { + try { + const [sourceCount, targetCount] = await Promise.all([ + this.getRowCount(source, tableName), + this.getRowCount(target, tableName), + ]); + + if (sourceCount !== targetCount) { + differences.push({ + table_name: tableName, + difference_type: 'row_count', + details: `Row count differs: source=${sourceCount}, target=${targetCount}`, + }); + } + } catch (error) { + logger.warn(`Failed to compare row counts for table ${tableName}:`, error); + } + } + + return differences; + } + + private async getRowCount(connection: DatabaseConnection, tableName: string): Promise { + const query = `SELECT COUNT(*) as count FROM ${tableName}`; + const result = await this.databaseManager.executeQuery(connection, query); + return result[0]?.count || 0; + } +} diff --git a/sidecar/src/database/drivers/mssql.ts b/sidecar/src/database/drivers/mssql.ts new file mode 100644 index 0000000..59ff052 --- /dev/null +++ b/sidecar/src/database/drivers/mssql.ts @@ -0,0 +1,321 @@ +import sql from 'mssql'; +import { DatabaseConnection } from '../../utils/validation.js'; +import { + DatabaseSchema, + DatabaseDriver, + TableSchema, + ColumnSchema, + IndexSchema, + ForeignKeySchema, + ViewSchema, + ProcedureSchema, + FunctionSchema +} from '../types.js'; +import { logger } from '../../utils/logger.js'; + +export class MSSQLDriver implements DatabaseDriver { + name = 'mssql'; + private pools: Map = new Map(); + + private getConnectionConfig(connection: DatabaseConnection): sql.config { + const config: sql.config = { + server: connection.host, + port: connection.port || 1433, + database: connection.database, + options: { + encrypt: true, // Use encryption + trustServerCertificate: true, // For development + enableArithAbort: true, + }, + pool: { + max: 10, + min: 0, + idleTimeoutMillis: 30000, + }, + }; + + if (connection.use_windows_auth) { + config.authentication = { + type: 'ntlm', + options: { + domain: '', // Will use current domain + userName: connection.username || '', + password: connection.password || '', + }, + }; + } else if (connection.username && connection.password) { + config.user = connection.username; + config.password = connection.password; + } + + return config; + } + + private async getPool(connection: DatabaseConnection): Promise { + const poolKey = `${connection.host}:${connection.port || 1433}:${connection.database}`; + + let pool = this.pools.get(poolKey); + if (!pool) { + const config = this.getConnectionConfig(connection); + pool = new sql.ConnectionPool(config); + await pool.connect(); + this.pools.set(poolKey, pool); + } + + return pool; + } + + async testConnection(connection: DatabaseConnection): Promise { + try { + const pool = await this.getPool(connection); + const result = await pool.request().query('SELECT 1 as test'); + return result.recordset.length > 0; + } catch (error) { + logger.error('MSSQL connection test failed:', error); + return false; + } + } + + async getSchema(connection: DatabaseConnection): Promise { + try { + const pool = await this.getPool(connection); + + const [tables, views, procedures, functions] = await Promise.all([ + this.getTables(pool), + this.getViews(pool), + this.getProcedures(pool), + this.getFunctions(pool), + ]); + + return { + tables, + views, + procedures, + functions, + }; + } catch (error) { + logger.error('MSSQL schema retrieval failed:', error); + throw error; + } + } + + private async getTables(pool: sql.ConnectionPool): Promise { + const tablesQuery = ` + SELECT + t.TABLE_SCHEMA as schema_name, + t.TABLE_NAME as table_name + FROM INFORMATION_SCHEMA.TABLES t + WHERE t.TABLE_TYPE = 'BASE TABLE' + ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME + `; + + const result = await pool.request().query(tablesQuery); + const tables: TableSchema[] = []; + + for (const row of result.recordset) { + const columns = await this.getTableColumns(pool, row.schema_name, row.table_name); + const indexes = await this.getTableIndexes(pool, row.schema_name, row.table_name); + const foreignKeys = await this.getTableForeignKeys(pool, row.schema_name, row.table_name); + + tables.push({ + name: row.table_name, + schema: row.schema_name, + columns, + indexes, + foreign_keys: foreignKeys, + }); + } + + return tables; + } + + private async getTableColumns(pool: sql.ConnectionPool, schemaName: string, tableName: string): Promise { + const columnsQuery = ` + SELECT + c.COLUMN_NAME as column_name, + c.DATA_TYPE as data_type, + c.IS_NULLABLE as is_nullable, + c.COLUMN_DEFAULT as default_value, + c.CHARACTER_MAXIMUM_LENGTH as max_length, + c.NUMERIC_PRECISION as precision, + c.NUMERIC_SCALE as scale, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as is_primary_key, + CASE WHEN ic.is_identity = 1 THEN 1 ELSE 0 END as is_identity + FROM INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN ( + SELECT ku.TABLE_SCHEMA, ku.TABLE_NAME, ku.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku + ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME + ) pk ON c.TABLE_SCHEMA = pk.TABLE_SCHEMA + AND c.TABLE_NAME = pk.TABLE_NAME + AND c.COLUMN_NAME = pk.COLUMN_NAME + LEFT JOIN sys.identity_columns ic ON ic.object_id = OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME) + AND ic.name = c.COLUMN_NAME + WHERE c.TABLE_SCHEMA = @schema AND c.TABLE_NAME = @table + ORDER BY c.ORDINAL_POSITION + `; + + const request = pool.request(); + request.input('schema', sql.VarChar, schemaName); + request.input('table', sql.VarChar, tableName); + const result = await request.query(columnsQuery); + + return result.recordset.map(row => ({ + name: row.column_name, + data_type: row.data_type, + is_nullable: row.is_nullable === 'YES', + default_value: row.default_value, + is_primary_key: row.is_primary_key === 1, + is_identity: row.is_identity === 1, + max_length: row.max_length, + precision: row.precision, + scale: row.scale, + })); + } + + private async getTableIndexes(pool: sql.ConnectionPool, schemaName: string, tableName: string): Promise { + const indexesQuery = ` + SELECT + i.name as index_name, + i.is_unique, + i.is_primary_key, + STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) as columns + FROM sys.indexes i + INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id + INNER JOIN sys.tables t ON i.object_id = t.object_id + INNER JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE s.name = @schema AND t.name = @table + GROUP BY i.name, i.is_unique, i.is_primary_key + ORDER BY i.name + `; + + const request = pool.request(); + request.input('schema', sql.VarChar, schemaName); + request.input('table', sql.VarChar, tableName); + const result = await request.query(indexesQuery); + + return result.recordset.map(row => ({ + name: row.index_name, + is_unique: row.is_unique, + is_primary: row.is_primary_key, + columns: row.columns.split(', '), + })); + } + + private async getTableForeignKeys(pool: sql.ConnectionPool, schemaName: string, tableName: string): Promise { + const fkQuery = ` + SELECT + fk.name as fk_name, + c1.name as column_name, + s2.name + '.' + t2.name as referenced_table, + c2.name as referenced_column + FROM sys.foreign_keys fk + INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id + INNER JOIN sys.tables t1 ON fk.parent_object_id = t1.object_id + INNER JOIN sys.schemas s1 ON t1.schema_id = s1.schema_id + INNER JOIN sys.columns c1 ON fkc.parent_object_id = c1.object_id AND fkc.parent_column_id = c1.column_id + INNER JOIN sys.tables t2 ON fkc.referenced_object_id = t2.object_id + INNER JOIN sys.schemas s2 ON t2.schema_id = s2.schema_id + INNER JOIN sys.columns c2 ON fkc.referenced_object_id = c2.object_id AND fkc.referenced_column_id = c2.column_id + WHERE s1.name = @schema AND t1.name = @table + ORDER BY fk.name + `; + + const request = pool.request(); + request.input('schema', sql.VarChar, schemaName); + request.input('table', sql.VarChar, tableName); + const result = await request.query(fkQuery); + + return result.recordset.map(row => ({ + name: row.fk_name, + column: row.column_name, + referenced_table: row.referenced_table, + referenced_column: row.referenced_column, + })); + } + + private async getViews(pool: sql.ConnectionPool): Promise { + const viewsQuery = ` + SELECT + v.TABLE_SCHEMA as schema_name, + v.TABLE_NAME as view_name, + vm.definition + FROM INFORMATION_SCHEMA.VIEWS v + INNER JOIN sys.sql_modules vm ON vm.object_id = OBJECT_ID(v.TABLE_SCHEMA + '.' + v.TABLE_NAME) + ORDER BY v.TABLE_SCHEMA, v.TABLE_NAME + `; + + const result = await pool.request().query(viewsQuery); + return result.recordset.map(row => ({ + name: row.view_name, + schema: row.schema_name, + definition: row.definition, + })); + } + + private async getProcedures(pool: sql.ConnectionPool): Promise { + const proceduresQuery = ` + SELECT + s.name as schema_name, + p.name as procedure_name, + sm.definition + FROM sys.procedures p + INNER JOIN sys.schemas s ON p.schema_id = s.schema_id + INNER JOIN sys.sql_modules sm ON p.object_id = sm.object_id + ORDER BY s.name, p.name + `; + + const result = await pool.request().query(proceduresQuery); + return result.recordset.map(row => ({ + name: row.procedure_name, + schema: row.schema_name, + definition: row.definition, + })); + } + + private async getFunctions(pool: sql.ConnectionPool): Promise { + const functionsQuery = ` + SELECT + s.name as schema_name, + f.name as function_name, + sm.definition + FROM sys.objects f + INNER JOIN sys.schemas s ON f.schema_id = s.schema_id + INNER JOIN sys.sql_modules sm ON f.object_id = sm.object_id + WHERE f.type IN ('FN', 'IF', 'TF') + ORDER BY s.name, f.name + `; + + const result = await pool.request().query(functionsQuery); + return result.recordset.map(row => ({ + name: row.function_name, + schema: row.schema_name, + definition: row.definition, + })); + } + + async executeQuery(connection: DatabaseConnection, query: string): Promise { + try { + const pool = await this.getPool(connection); + const result = await pool.request().query(query); + return result.recordset; + } catch (error) { + logger.error('MSSQL query execution failed:', error); + throw error; + } + } + + async disconnect(): Promise { + for (const [key, pool] of this.pools) { + try { + await pool.close(); + this.pools.delete(key); + } catch (error) { + logger.error(`Failed to close MSSQL pool ${key}:`, error); + } + } + } +} diff --git a/sidecar/src/database/manager.ts b/sidecar/src/database/manager.ts new file mode 100644 index 0000000..1f8820d --- /dev/null +++ b/sidecar/src/database/manager.ts @@ -0,0 +1,76 @@ +import { DatabaseConnection } from '../utils/validation.js'; +import { DatabaseSchema, DatabaseDriver } from './types.js'; +import { MSSQLDriver } from './drivers/mssql.js'; +import { logger } from '../utils/logger.js'; + +export class DatabaseManager { + private drivers: Map = new Map(); + + constructor() { + // Register built-in drivers + this.registerDriver('mssql', new MSSQLDriver()); + + // TODO: Add other drivers + // this.registerDriver('mysql', new MySQLDriver()); + // this.registerDriver('postgresql', new PostgreSQLDriver()); + // this.registerDriver('sqlite', new SQLiteDriver()); + // this.registerDriver('mongodb', new MongoDBDriver()); + } + + registerDriver(name: string, driver: DatabaseDriver): void { + this.drivers.set(name, driver); + logger.info(`Registered database driver: ${name}`); + } + + getDriver(name: string): DatabaseDriver { + const driver = this.drivers.get(name); + if (!driver) { + throw new Error(`Database driver not found: ${name}`); + } + return driver; + } + + getAvailableDrivers(): string[] { + return Array.from(this.drivers.keys()); + } + + async testConnection(connection: DatabaseConnection): Promise { + try { + const driver = this.getDriver(connection.driver); + return await driver.testConnection(connection); + } catch (error) { + logger.error(`Connection test failed for ${connection.driver}:`, error); + return false; + } + } + + async getSchema(connection: DatabaseConnection): Promise { + try { + const driver = this.getDriver(connection.driver); + return await driver.getSchema(connection); + } catch (error) { + logger.error(`Schema retrieval failed for ${connection.driver}:`, error); + throw error; + } + } + + async executeQuery(connection: DatabaseConnection, query: string): Promise { + try { + const driver = this.getDriver(connection.driver); + return await driver.executeQuery(connection, query); + } catch (error) { + logger.error(`Query execution failed for ${connection.driver}:`, error); + throw error; + } + } + + async disconnect(driverName: string): Promise { + try { + const driver = this.getDriver(driverName); + await driver.disconnect(); + } catch (error) { + logger.error(`Disconnect failed for ${driverName}:`, error); + throw error; + } + } +} diff --git a/sidecar/src/database/types.ts b/sidecar/src/database/types.ts new file mode 100644 index 0000000..e4856e3 --- /dev/null +++ b/sidecar/src/database/types.ts @@ -0,0 +1,66 @@ +export interface DatabaseSchema { + tables: TableSchema[]; + views: ViewSchema[]; + procedures: ProcedureSchema[]; + functions: FunctionSchema[]; +} + +export interface TableSchema { + name: string; + schema: string; + columns: ColumnSchema[]; + indexes: IndexSchema[]; + foreign_keys: ForeignKeySchema[]; +} + +export interface ColumnSchema { + name: string; + data_type: string; + is_nullable: boolean; + default_value?: string; + is_primary_key: boolean; + is_identity: boolean; + max_length?: number; + precision?: number; + scale?: number; +} + +export interface IndexSchema { + name: string; + is_unique: boolean; + is_primary: boolean; + columns: string[]; +} + +export interface ForeignKeySchema { + name: string; + column: string; + referenced_table: string; + referenced_column: string; +} + +export interface ViewSchema { + name: string; + schema: string; + definition: string; +} + +export interface ProcedureSchema { + name: string; + schema: string; + definition: string; +} + +export interface FunctionSchema { + name: string; + schema: string; + definition: string; +} + +export interface DatabaseDriver { + name: string; + testConnection(connection: any): Promise; + getSchema(connection: any): Promise; + executeQuery(connection: any, query: string): Promise; + disconnect(): Promise; +} diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts new file mode 100644 index 0000000..75cf9d8 --- /dev/null +++ b/sidecar/src/index.ts @@ -0,0 +1,133 @@ +import express from 'express'; +import cors from 'cors'; +import { config } from 'dotenv'; +import { logger } from './utils/logger.js'; +import { DatabaseManager } from './database/manager.js'; +import { ComparisonEngine } from './comparison/engine.js'; +import { PluginManager } from './plugins/manager.js'; +import { validateConnection } from './utils/validation.js'; + +// Load environment variables +config(); + +const app = express(); +const port = process.env.PORT || 3001; + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// Initialize managers +const databaseManager = new DatabaseManager(); +const comparisonEngine = new ComparisonEngine(databaseManager); +const pluginManager = new PluginManager(); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Test database connection +app.post('/test_connection', async (req, res) => { + try { + const connection = validateConnection(req.body); + const isConnected = await databaseManager.testConnection(connection); + res.json({ success: isConnected }); + } catch (error) { + logger.error('Connection test failed:', error); + res.status(400).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get database schema +app.post('/get_schema', async (req, res) => { + try { + const connection = validateConnection(req.body); + const schema = await databaseManager.getSchema(connection); + res.json(schema); + } catch (error) { + logger.error('Schema retrieval failed:', error); + res.status(400).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Compare databases +app.post('/compare_databases', async (req, res) => { + try { + const { source, target } = req.body; + + if (!source || !target) { + return res.status(400).json({ error: 'Source and target connections are required' }); + } + + const sourceConnection = validateConnection(source); + const targetConnection = validateConnection(target); + + const comparison = await comparisonEngine.compare(sourceConnection, targetConnection); + res.json(comparison); + } catch (error) { + logger.error('Database comparison failed:', error); + res.status(400).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get available plugins +app.get('/plugins', async (req, res) => { + try { + const plugins = await pluginManager.getAvailablePlugins(); + res.json(plugins); + } catch (error) { + logger.error('Failed to get plugins:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Load plugin +app.post('/plugins/load', async (req, res) => { + try { + const { pluginPath } = req.body; + if (!pluginPath) { + return res.status(400).json({ error: 'Plugin path is required' }); + } + + await pluginManager.loadPlugin(pluginPath); + res.json({ success: true }); + } catch (error) { + logger.error('Failed to load plugin:', error); + res.status(400).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +app.use((error: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.error('Unhandled error:', error); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Start server +app.listen(port, () => { + logger.info(`Server started on port ${port}`); + console.log(`Server listening on port ${port}`); // This line is important for Tauri to detect startup +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + logger.info('SIGTERM received, shutting down gracefully'); + process.exit(0); +}); + +process.on('SIGINT', () => { + logger.info('SIGINT received, shutting down gracefully'); + process.exit(0); +}); diff --git a/sidecar/src/plugins/manager.ts b/sidecar/src/plugins/manager.ts new file mode 100644 index 0000000..37e22da --- /dev/null +++ b/sidecar/src/plugins/manager.ts @@ -0,0 +1,157 @@ +import { DatabaseDriver } from '../database/types.js'; +import { logger } from '../utils/logger.js'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface PluginInfo { + name: string; + version: string; + description: string; + driver: string; + author: string; + path: string; +} + +export interface Plugin { + info: PluginInfo; + driver: DatabaseDriver; +} + +export class PluginManager { + private plugins: Map = new Map(); + private pluginDirectory: string; + + constructor(pluginDirectory?: string) { + this.pluginDirectory = pluginDirectory || path.join(process.cwd(), 'plugins'); + } + + async getAvailablePlugins(): Promise { + const plugins: PluginInfo[] = []; + + try { + // Check if plugin directory exists + await fs.access(this.pluginDirectory); + + const entries = await fs.readdir(this.pluginDirectory, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + try { + const pluginPath = path.join(this.pluginDirectory, entry.name); + const packageJsonPath = path.join(pluginPath, 'package.json'); + + // Check if package.json exists + await fs.access(packageJsonPath); + + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); + + // Validate plugin structure + if (this.isValidPlugin(packageJson)) { + plugins.push({ + name: packageJson.name, + version: packageJson.version, + description: packageJson.description || '', + driver: packageJson.databaseDriver || 'unknown', + author: packageJson.author || 'unknown', + path: pluginPath, + }); + } + } catch (error) { + logger.warn(`Failed to read plugin info for ${entry.name}:`, error); + } + } + } + } catch (error) { + logger.info('Plugin directory not found or inaccessible:', this.pluginDirectory); + } + + return plugins; + } + + private isValidPlugin(packageJson: any): boolean { + return ( + packageJson.name && + packageJson.version && + packageJson.main && + packageJson.databaseDriver + ); + } + + async loadPlugin(pluginPath: string): Promise { + try { + const packageJsonPath = path.join(pluginPath, 'package.json'); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); + + if (!this.isValidPlugin(packageJson)) { + throw new Error('Invalid plugin structure'); + } + + // Dynamic import of the plugin + const mainFile = path.join(pluginPath, packageJson.main); + const pluginModule = await import(mainFile); + + if (!pluginModule.default || typeof pluginModule.default !== 'function') { + throw new Error('Plugin must export a default function that returns a DatabaseDriver'); + } + + const driver: DatabaseDriver = pluginModule.default(); + + if (!this.isValidDriver(driver)) { + throw new Error('Plugin must return a valid DatabaseDriver implementation'); + } + + const plugin: Plugin = { + info: { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description || '', + driver: packageJson.databaseDriver, + author: packageJson.author || 'unknown', + path: pluginPath, + }, + driver, + }; + + this.plugins.set(packageJson.databaseDriver, plugin); + logger.info(`Loaded plugin: ${packageJson.name} v${packageJson.version}`); + } catch (error) { + logger.error(`Failed to load plugin from ${pluginPath}:`, error); + throw error; + } + } + + private isValidDriver(driver: any): driver is DatabaseDriver { + return ( + driver && + typeof driver.name === 'string' && + typeof driver.testConnection === 'function' && + typeof driver.getSchema === 'function' && + typeof driver.executeQuery === 'function' && + typeof driver.disconnect === 'function' + ); + } + + getPlugin(driverName: string): Plugin | undefined { + return this.plugins.get(driverName); + } + + getLoadedPlugins(): Plugin[] { + return Array.from(this.plugins.values()); + } + + unloadPlugin(driverName: string): boolean { + return this.plugins.delete(driverName); + } + + async unloadAllPlugins(): Promise { + for (const plugin of this.plugins.values()) { + try { + await plugin.driver.disconnect(); + } catch (error) { + logger.warn(`Failed to disconnect plugin ${plugin.info.name}:`, error); + } + } + this.plugins.clear(); + logger.info('All plugins unloaded'); + } +} diff --git a/sidecar/src/utils/logger.ts b/sidecar/src/utils/logger.ts new file mode 100644 index 0000000..ae28ab6 --- /dev/null +++ b/sidecar/src/utils/logger.ts @@ -0,0 +1,26 @@ +import winston from 'winston'; + +export const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'open-data-diff-sidecar' }, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] +}); + +// If we're not in production, log to the console with a simple format +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.simple() + })); +} diff --git a/sidecar/src/utils/validation.ts b/sidecar/src/utils/validation.ts new file mode 100644 index 0000000..c0ad68c --- /dev/null +++ b/sidecar/src/utils/validation.ts @@ -0,0 +1,37 @@ +import Joi from 'joi'; + +export interface DatabaseConnection { + id: string; + name: string; + driver: string; + host: string; + port?: number; + database: string; + username?: string; + password?: string; + use_windows_auth: boolean; + connection_string?: string; +} + +const connectionSchema = Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), + driver: Joi.string().valid('mssql', 'mysql', 'postgresql', 'sqlite', 'mongodb').required(), + host: Joi.string().required(), + port: Joi.number().integer().min(1).max(65535).optional(), + database: Joi.string().required(), + username: Joi.string().optional(), + password: Joi.string().optional(), + use_windows_auth: Joi.boolean().default(false), + connection_string: Joi.string().optional() +}); + +export function validateConnection(data: any): DatabaseConnection { + const { error, value } = connectionSchema.validate(data); + + if (error) { + throw new Error(`Invalid connection data: ${error.details[0].message}`); + } + + return value as DatabaseConnection; +} diff --git a/sidecar/tsconfig.json b/sidecar/tsconfig.json new file mode 100644 index 0000000..68c6c53 --- /dev/null +++ b/sidecar/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..2500e23 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "open-data-diff" +version = "0.1.0" +description = "Open source database comparison tool" +authors = ["Robert Faust "] +license = "Apache-2.0" +repository = "https://github.com/your-username/open-data-diff" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "1.5", features = [] } + +[dependencies] +tauri = { version = "1.5", features = ["api-all", "shell-open"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["full"] } +anyhow = "1.0" +log = "0.4" +env_logger = "0.10" +reqwest = { version = "0.11", features = ["json"] } + +[features] +# this feature is used for production builds or when `devPath` points to the filesystem +# DO NOT REMOVE!! +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..7649049 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,164 @@ +use crate::{sidecar::SidecarStatus, AppState}; +use serde::{Deserialize, Serialize}; +use tauri::{command, State}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseConnection { + pub id: String, + pub name: String, + pub driver: String, + pub host: String, + pub port: Option, + pub database: String, + pub username: Option, + pub password: Option, + pub use_windows_auth: bool, + pub connection_string: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseSchema { + pub tables: Vec, + pub views: Vec, + pub procedures: Vec, + pub functions: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableSchema { + pub name: String, + pub schema: String, + pub columns: Vec, + pub indexes: Vec, + pub foreign_keys: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ColumnSchema { + pub name: String, + pub data_type: String, + pub is_nullable: bool, + pub default_value: Option, + pub is_primary_key: bool, + pub is_identity: bool, + pub max_length: Option, + pub precision: Option, + pub scale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct IndexSchema { + pub name: String, + pub is_unique: bool, + pub is_primary: bool, + pub columns: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ForeignKeySchema { + pub name: String, + pub column: String, + pub referenced_table: String, + pub referenced_column: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViewSchema { + pub name: String, + pub schema: String, + pub definition: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProcedureSchema { + pub name: String, + pub schema: String, + pub definition: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FunctionSchema { + pub name: String, + pub schema: String, + pub definition: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ComparisonResult { + pub schema_differences: Vec, + pub data_differences: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SchemaDifference { + pub object_type: String, + pub object_name: String, + pub difference_type: String, // "added", "removed", "modified" + pub details: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DataDifference { + pub table_name: String, + pub difference_type: String, // "row_count", "data_mismatch" + pub details: String, +} + +#[command] +pub async fn start_sidecar(state: State<'_, AppState>) -> Result<(), String> { + let mut manager = state.sidecar_manager.lock().await; + manager.start_if_not_running().await.map_err(|e| e.to_string()) +} + +#[command] +pub async fn stop_sidecar(state: State<'_, AppState>) -> Result<(), String> { + let mut manager = state.sidecar_manager.lock().await; + manager.stop().await.map_err(|e| e.to_string()) +} + +#[command] +pub async fn get_sidecar_status(state: State<'_, AppState>) -> Result { + let manager = state.sidecar_manager.lock().await; + Ok(manager.get_status()) +} + +#[command] +pub async fn test_database_connection( + connection: DatabaseConnection, + state: State<'_, AppState>, +) -> Result { + let manager = state.sidecar_manager.lock().await; + manager + .send_request("test_connection", &connection) + .await + .map_err(|e| e.to_string()) +} + +#[command] +pub async fn get_database_schema( + connection: DatabaseConnection, + state: State<'_, AppState>, +) -> Result { + let manager = state.sidecar_manager.lock().await; + manager + .send_request("get_schema", &connection) + .await + .map_err(|e| e.to_string()) +} + +#[command] +pub async fn compare_databases( + source: DatabaseConnection, + target: DatabaseConnection, + state: State<'_, AppState>, +) -> Result { + let manager = state.sidecar_manager.lock().await; + let request = serde_json::json!({ + "source": source, + "target": target + }); + manager + .send_request("compare_databases", &request) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..c561966 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,46 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod commands; +mod sidecar; + +use commands::*; +use sidecar::SidecarManager; +use std::sync::Arc; +use tauri::{Manager, State}; +use tokio::sync::Mutex; + +pub struct AppState { + pub sidecar_manager: Arc>, +} + +fn main() { + env_logger::init(); + + let sidecar_manager = Arc::new(Mutex::new(SidecarManager::new())); + + tauri::Builder::default() + .manage(AppState { sidecar_manager }) + .invoke_handler(tauri::generate_handler![ + start_sidecar, + stop_sidecar, + get_sidecar_status, + test_database_connection, + get_database_schema, + compare_databases + ]) + .setup(|app| { + // Start the sidecar process on app startup + let app_handle = app.handle(); + tauri::async_runtime::spawn(async move { + let state: State = app_handle.state(); + let mut manager = state.sidecar_manager.lock().await; + if let Err(e) = manager.start(&app_handle).await { + log::error!("Failed to start sidecar: {}", e); + } + }); + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs new file mode 100644 index 0000000..d53f441 --- /dev/null +++ b/src-tauri/src/sidecar.rs @@ -0,0 +1,151 @@ +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child as TokioChild, Command as TokioCommand}; +use std::process::Stdio; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SidecarStatus { + Stopped, + Starting, + Running, + Error(String), +} + +pub struct SidecarManager { + process: Option, + status: SidecarStatus, + port: u16, +} + +impl SidecarManager { + pub fn new() -> Self { + Self { + process: None, + status: SidecarStatus::Stopped, + port: 3001, // Default port for the sidecar + } + } + + pub fn get_status(&self) -> SidecarStatus { + self.status.clone() + } + + pub async fn start(&mut self, app_handle: &AppHandle) -> Result<()> { + if matches!(self.status, SidecarStatus::Running) { + return Ok(()); + } + + self.status = SidecarStatus::Starting; + + // Get the sidecar binary path + let resource_dir = app_handle + .path_resolver() + .resource_dir() + .ok_or_else(|| anyhow!("Failed to get resource directory"))?; + + let sidecar_path = resource_dir.join("sidecar").join("dist").join("index.js"); + + if !sidecar_path.exists() { + let error = format!("Sidecar binary not found at: {:?}", sidecar_path); + self.status = SidecarStatus::Error(error.clone()); + return Err(anyhow!(error)); + } + + // Start the Node.js sidecar process + let mut child = TokioCommand::new("node") + .arg(sidecar_path) + .arg("--port") + .arg(self.port.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| { + let error = format!("Failed to start sidecar process: {}", e); + self.status = SidecarStatus::Error(error.clone()); + anyhow!(error) + })?; + + // Wait for the sidecar to be ready + if let Some(stdout) = child.stdout.take() { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + + // Read the first line to check if the server started successfully + match reader.read_line(&mut line).await { + Ok(_) => { + if line.contains("Server started") || line.contains("listening") { + self.status = SidecarStatus::Running; + log::info!("Sidecar started successfully on port {}", self.port); + } else { + let error = format!("Sidecar startup failed: {}", line); + self.status = SidecarStatus::Error(error.clone()); + return Err(anyhow!(error)); + } + } + Err(e) => { + let error = format!("Failed to read sidecar output: {}", e); + self.status = SidecarStatus::Error(error.clone()); + return Err(anyhow!(error)); + } + } + } + + self.process = Some(child); + Ok(()) + } + + pub async fn start_if_not_running(&mut self) -> Result<()> { + match self.status { + SidecarStatus::Running => Ok(()), + _ => { + // We need an app handle to start the sidecar, but we don't have it here + // This method should be called from a context where we have access to the app handle + Err(anyhow!("Cannot start sidecar without app handle")) + } + } + } + + pub async fn stop(&mut self) -> Result<()> { + if let Some(mut child) = self.process.take() { + child.kill().await.map_err(|e| anyhow!("Failed to kill sidecar process: {}", e))?; + self.status = SidecarStatus::Stopped; + log::info!("Sidecar stopped"); + } + Ok(()) + } + + pub async fn send_request(&self, endpoint: &str, data: &T) -> Result + where + T: Serialize, + R: for<'de> Deserialize<'de>, + { + if !matches!(self.status, SidecarStatus::Running) { + return Err(anyhow!("Sidecar is not running")); + } + + let client = reqwest::Client::new(); + let url = format!("http://localhost:{}/{}", self.port, endpoint); + + let response = client + .post(&url) + .json(data) + .send() + .await + .map_err(|e| anyhow!("Failed to send request to sidecar: {}", e))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow!("Sidecar request failed: {}", error_text)); + } + + let result = response + .json::() + .await + .map_err(|e| anyhow!("Failed to parse sidecar response: {}", e))?; + + Ok(result) + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..04535b3 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,86 @@ +{ + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devPath": "http://localhost:1420", + "distDir": "../dist", + "withGlobalTauri": false + }, + "package": { + "productName": "Open Data Diff", + "version": "0.1.0" + }, + "tauri": { + "allowlist": { + "all": false, + "shell": { + "all": false, + "open": true, + "sidecar": true, + "scope": [ + { + "name": "node-sidecar", + "cmd": "node", + "args": ["sidecar/dist/index.js"] + } + ] + }, + "dialog": { + "all": false, + "ask": true, + "confirm": true, + "message": true, + "open": true, + "save": true + }, + "fs": { + "all": false, + "readFile": true, + "writeFile": true, + "readDir": true, + "copyFile": true, + "createDir": true, + "removeDir": true, + "removeFile": true, + "renameFile": true, + "exists": true, + "scope": ["$APPDATA", "$APPDATA/**", "$APPCONFIG", "$APPCONFIG/**"] + }, + "path": { + "all": true + }, + "window": { + "all": false, + "close": true, + "hide": true, + "show": true, + "maximize": true, + "minimize": true, + "unmaximize": true, + "unminimize": true, + "startDragging": true + } + }, + "bundle": { + "active": true, + "targets": "all", + "identifier": "com.rjftech.open-data-diff", + + "externalBin": ["sidecar/dist/index.js"] + }, + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "resizable": true, + "title": "Open Data Diff", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600 + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..272f9c1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { TauriService } from '@/services/tauri'; +import { DatabaseConnection, ComparisonResult } from '@/types/database'; +import { ConnectionForm } from '@/components/ConnectionForm'; +import { ComparisonView } from '@/components/ComparisonView'; +import { StatusBar } from '@/components/StatusBar'; +import { Header } from '@/components/Header'; + +function App() { + const [sourceConnection, setSourceConnection] = useState(null); + const [targetConnection, setTargetConnection] = useState(null); + const [comparisonResult, setComparisonResult] = useState(null); + const [isComparing, setIsComparing] = useState(false); + + // Query sidecar status + const { data: sidecarStatus } = useQuery({ + queryKey: ['sidecar-status'], + queryFn: TauriService.getSidecarStatus, + refetchInterval: 2000, + }); + + const handleCompare = async () => { + if (!sourceConnection || !targetConnection) { + return; + } + + setIsComparing(true); + try { + const result = await TauriService.compareDatabases(sourceConnection, targetConnection); + setComparisonResult(result); + } catch (error) { + console.error('Comparison failed:', error); + // TODO: Show error toast + } finally { + setIsComparing(false); + } + }; + + const canCompare = sourceConnection && targetConnection && !isComparing; + + return ( +
+
+ +
+
+
+

Source Database

+ +
+ +
+

Target Database

+ +
+
+ +
+ +
+ + {comparisonResult && ( + + )} +
+ + +
+ ); +} + +export default App; diff --git a/src/components/ComparisonView.tsx b/src/components/ComparisonView.tsx new file mode 100644 index 0000000..bdb1152 --- /dev/null +++ b/src/components/ComparisonView.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, Minus, Edit } from 'lucide-react'; +import { ComparisonResult, SchemaDifference, DataDifference } from '@/types/database'; +import { cn } from '@/utils/cn'; + +interface ComparisonViewProps { + result: ComparisonResult; +} + +export function ComparisonView({ result }: ComparisonViewProps) { + const [expandedSections, setExpandedSections] = useState>(new Set(['schema', 'data'])); + + const toggleSection = (section: string) => { + const newExpanded = new Set(expandedSections); + if (newExpanded.has(section)) { + newExpanded.delete(section); + } else { + newExpanded.add(section); + } + setExpandedSections(newExpanded); + }; + + const getDifferenceIcon = (type: string) => { + switch (type) { + case 'added': + return ; + case 'removed': + return ; + case 'modified': + return ; + default: + return null; + } + }; + + const getDifferenceColor = (type: string) => { + switch (type) { + case 'added': + return 'bg-green-50 border-green-200'; + case 'removed': + return 'bg-red-50 border-red-200'; + case 'modified': + return 'bg-yellow-50 border-yellow-200'; + default: + return 'bg-gray-50 border-gray-200'; + } + }; + + const groupDifferencesByType = (differences: SchemaDifference[]) => { + return differences.reduce((acc, diff) => { + if (!acc[diff.object_type]) { + acc[diff.object_type] = []; + } + acc[diff.object_type].push(diff); + return acc; + }, {} as Record); + }; + + const schemaDifferencesByType = groupDifferencesByType(result.schema_differences); + + return ( +
+
+
toggleSection('schema')} + > +
+ {expandedSections.has('schema') ? ( + + ) : ( + + )} +

Schema Differences

+ + {result.schema_differences.length} + +
+
+ + {expandedSections.has('schema') && ( +
+ {result.schema_differences.length === 0 ? ( +

+ No schema differences found +

+ ) : ( +
+ {Object.entries(schemaDifferencesByType).map(([objectType, differences]) => ( +
+

+ {objectType}s ({differences.length}) +

+
+ {differences.map((diff, index) => ( +
+
+ {getDifferenceIcon(diff.difference_type)} +
+
+ + {diff.object_name} + + + {diff.difference_type} + +
+

+ {diff.details} +

+
+
+
+ ))} +
+
+ ))} +
+ )} +
+ )} +
+ +
+
toggleSection('data')} + > +
+ {expandedSections.has('data') ? ( + + ) : ( + + )} +

Data Differences

+ + {result.data_differences.length} + +
+
+ + {expandedSections.has('data') && ( +
+ {result.data_differences.length === 0 ? ( +

+ No data differences found +

+ ) : ( +
+ {result.data_differences.map((diff, index) => ( +
+
+ +
+
+ + {diff.table_name} + + + {diff.difference_type} + +
+

+ {diff.details} +

+
+
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/ConnectionForm.tsx b/src/components/ConnectionForm.tsx new file mode 100644 index 0000000..e5e3967 --- /dev/null +++ b/src/components/ConnectionForm.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import { DatabaseConnection } from '@/types/database'; +import { TauriService } from '@/services/tauri'; +import { cn } from '@/utils/cn'; + +interface ConnectionFormProps { + connection: DatabaseConnection | null; + onChange: (connection: DatabaseConnection | null) => void; + label: string; +} + +export function ConnectionForm({ connection, onChange, label }: ConnectionFormProps) { + const [formData, setFormData] = useState>( + connection || { + id: '', + name: '', + driver: 'mssql', + host: '', + port: 1433, + database: '', + username: '', + password: '', + use_windows_auth: true, + } + ); + + const testConnectionMutation = useMutation({ + mutationFn: (conn: DatabaseConnection) => TauriService.testDatabaseConnection(conn), + }); + + const handleInputChange = (field: keyof DatabaseConnection, value: any) => { + const updated = { ...formData, [field]: value }; + setFormData(updated); + + // Update parent if all required fields are filled + if (updated.id && updated.name && updated.host && updated.database) { + onChange(updated as DatabaseConnection); + } else { + onChange(null); + } + }; + + const handleTestConnection = async () => { + if (!connection) return; + + try { + await testConnectionMutation.mutateAsync(connection); + } catch (error) { + console.error('Connection test failed:', error); + } + }; + + const getTestConnectionStatus = () => { + if (testConnectionMutation.isPending) { + return ; + } + + if (testConnectionMutation.isSuccess) { + return testConnectionMutation.data ? ( + + ) : ( + + ); + } + + if (testConnectionMutation.isError) { + return ; + } + + return null; + }; + + return ( +
+
+
+ + handleInputChange('name', e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background" + placeholder="My Database" + /> +
+ +
+ + +
+
+ +
+
+ + handleInputChange('host', e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background" + placeholder="localhost" + /> +
+ +
+ + handleInputChange('port', parseInt(e.target.value) || 1433)} + className="w-full px-3 py-2 border rounded-md bg-background" + /> +
+
+ +
+ + handleInputChange('database', e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background" + placeholder="MyDatabase" + /> +
+ +
+ handleInputChange('use_windows_auth', e.target.checked)} + className="rounded" + /> + +
+ + {!formData.use_windows_auth && ( +
+
+ + handleInputChange('username', e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background" + /> +
+ +
+ + handleInputChange('password', e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background" + /> +
+
+ )} + +
+ + +
+ {getTestConnectionStatus()} + {testConnectionMutation.isSuccess && ( + + {testConnectionMutation.data ? "Connected" : "Failed"} + + )} + {testConnectionMutation.isError && ( + Error + )} +
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..1a9983d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,17 @@ +import { Database } from 'lucide-react'; + +export function Header() { + return ( +
+
+
+ +
+

Open Data Diff

+

Database comparison tool

+
+
+
+
+ ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000..986fad5 --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,52 @@ +import { Circle } from 'lucide-react'; +import { SidecarStatus } from '@/types/database'; +import { cn } from '@/utils/cn'; + +interface StatusBarProps { + sidecarStatus?: SidecarStatus; +} + +export function StatusBar({ sidecarStatus }: StatusBarProps) { + const getStatusInfo = (status?: SidecarStatus) => { + if (!status) { + return { text: 'Unknown', color: 'text-gray-500', bgColor: 'bg-gray-500' }; + } + + if (status === 'Running') { + return { text: 'Running', color: 'text-green-600', bgColor: 'bg-green-500' }; + } + + if (status === 'Starting') { + return { text: 'Starting', color: 'text-yellow-600', bgColor: 'bg-yellow-500' }; + } + + if (status === 'Stopped') { + return { text: 'Stopped', color: 'text-red-600', bgColor: 'bg-red-500' }; + } + + if (typeof status === 'object' && 'Error' in status) { + return { text: `Error: ${status.Error}`, color: 'text-red-600', bgColor: 'bg-red-500' }; + } + + return { text: 'Unknown', color: 'text-gray-500', bgColor: 'bg-gray-500' }; + }; + + const statusInfo = getStatusInfo(sidecarStatus); + + return ( +
+
+
+ + + Sidecar: {statusInfo.text} + +
+ +
+ Open Data Diff v0.1.0 +
+
+
+ ); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..7b06b6d --- /dev/null +++ b/src/index.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 84% 4.9%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 94.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..0b3ec8f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App.tsx' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/src/services/tauri.ts b/src/services/tauri.ts new file mode 100644 index 0000000..59faf15 --- /dev/null +++ b/src/services/tauri.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/tauri'; +import { + DatabaseConnection, + DatabaseSchema, + ComparisonResult, + SidecarStatus +} from '@/types/database'; + +export class TauriService { + static async startSidecar(): Promise { + return invoke('start_sidecar'); + } + + static async stopSidecar(): Promise { + return invoke('stop_sidecar'); + } + + static async getSidecarStatus(): Promise { + return invoke('get_sidecar_status'); + } + + static async testDatabaseConnection(connection: DatabaseConnection): Promise { + return invoke('test_database_connection', { connection }); + } + + static async getDatabaseSchema(connection: DatabaseConnection): Promise { + return invoke('get_database_schema', { connection }); + } + + static async compareDatabases( + source: DatabaseConnection, + target: DatabaseConnection + ): Promise { + return invoke('compare_databases', { source, target }); + } +} diff --git a/src/types/database.ts b/src/types/database.ts new file mode 100644 index 0000000..ec9ac6f --- /dev/null +++ b/src/types/database.ts @@ -0,0 +1,91 @@ +export interface DatabaseConnection { + id: string; + name: string; + driver: string; + host: string; + port?: number; + database: string; + username?: string; + password?: string; + use_windows_auth: boolean; + connection_string?: string; +} + +export interface DatabaseSchema { + tables: TableSchema[]; + views: ViewSchema[]; + procedures: ProcedureSchema[]; + functions: FunctionSchema[]; +} + +export interface TableSchema { + name: string; + schema: string; + columns: ColumnSchema[]; + indexes: IndexSchema[]; + foreign_keys: ForeignKeySchema[]; +} + +export interface ColumnSchema { + name: string; + data_type: string; + is_nullable: boolean; + default_value?: string; + is_primary_key: boolean; + is_identity: boolean; + max_length?: number; + precision?: number; + scale?: number; +} + +export interface IndexSchema { + name: string; + is_unique: boolean; + is_primary: boolean; + columns: string[]; +} + +export interface ForeignKeySchema { + name: string; + column: string; + referenced_table: string; + referenced_column: string; +} + +export interface ViewSchema { + name: string; + schema: string; + definition: string; +} + +export interface ProcedureSchema { + name: string; + schema: string; + definition: string; +} + +export interface FunctionSchema { + name: string; + schema: string; + definition: string; +} + +export interface ComparisonResult { + schema_differences: SchemaDifference[]; + data_differences: DataDifference[]; +} + +export interface SchemaDifference { + object_type: string; + object_name: string; + difference_type: 'added' | 'removed' | 'modified'; + details: string; +} + +export interface DataDifference { + table_name: string; + difference_type: 'row_count' | 'data_mismatch'; + details: string; +} + +export type SidecarStatus = 'Stopped' | 'Starting' | 'Running' | { Error: string }; diff --git a/src/utils/cn.ts b/src/utils/cn.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..dd8a0da --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,52 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..48a4037 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@/components/*": ["./src/components/*"], + "@/hooks/*": ["./src/hooks/*"], + "@/services/*": ["./src/services/*"], + "@/types/*": ["./src/types/*"], + "@/utils/*": ["./src/utils/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..98860dc --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // prevent vite from obscuring rust errors + clearScreen: false, + // tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + }, + // to make use of `TAURI_DEBUG` and other env variables + // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand + envPrefix: ['VITE_', 'TAURI_'], + build: { + // Tauri supports es2021 + target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', + // don't minify for debug builds + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + // produce sourcemaps for debug builds + sourcemap: !!process.env.TAURI_DEBUG, + }, +})