From efd53e9cfe1888d3a490a262b759841748f01e90 Mon Sep 17 00:00:00 2001 From: oli Date: Sat, 20 Dec 2025 01:09:12 +0100 Subject: [PATCH] feat: add SQLite support with schema extraction capabilities --- CHANGELOG.md | 6 + README.md | 32 +++- src/DbDiff.Cli/Program.cs | 12 +- src/DbDiff.Domain/DatabaseType.cs | 3 +- .../DbDiff.Infrastructure.csproj | 1 + .../SchemaExtractorFactory.cs | 1 + .../SqliteSqlSchemaExtractor.cs | 160 ++++++++++++++++++ 7 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 src/DbDiff.Infrastructure/SqliteSqlSchemaExtractor.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff2c00..257f1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **SqLite database support** - Schema extration within the limits of SqLite +- `SqliteSqlSchemaExtractor` implements the `ISchemaExtractor` for SqLite +- Extended enum in Domain layer (`SqlServer`, `PostgreSql`, `Sqlite`) +- `--database-type` (`-d`) accepts now `sqlite` + ## [0.1.0] - 2025-12-19 ### Added diff --git a/README.md b/README.md index 37b9590..3d0dd14 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # DbDiff - Database Schema Comparison Tool -A CLI tool for exporting and comparing database schemas, built with .NET following hexagonal architecture principles. +DbDiff will be a set of tools for comparing database schemas. While it's still in early development, I use it daily to +compare schemas across different application databases and instances. The tool exports schemas to a simple, +alphabetically-sorted text format that contains just enough information to be readable and easy to compare using diff +tools like [WinMerge](https://winmerge.org/) or [Meld](https://gnome.pages.gitlab.gnome.org/meld/). For most of my +needs, this is sufficient to verify that a database is in the correct state (yes, sometimes manual checks are +necessary). + +Future plans include adding a more comfortable user interface, likely built with AvaloniaUI. For now, I'm satisfied +with the terminal-based tools. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/olibf/dbdiff/actions/workflows/ci.yml/badge.svg)](https://github.com/olibf/dbdiff/actions/workflows/ci.yml) @@ -8,17 +16,18 @@ A CLI tool for exporting and comparing database schemas, built with .NET followi ## Version -Current version: **0.0.1** +Current version: **0.1.0** ## Features -- Export database schemas to text format from SQL Server and PostgreSQL +- Export database schemas to text format from SQL Server, PostgreSQL and SQLite - **Complete schema export including:** - Tables with full column definitions - Views with SQL definitions and column structures - Deterministic, diff-friendly output format - Alphabetically sorted tables and columns for easy comparison - Cross-database schema comparison (compare SQL Server vs PostgreSQL schemas) +- Cross-database schema comparison with SQLite limited due to the limitations of SQLite - Automatic database type detection from connection strings - Configurable via CLI arguments, environment variables, or configuration files - Structured logging with Serilog @@ -32,7 +41,15 @@ Current version: **0.0.1** ### Quick Install (Windows) -Use the provided PowerShell installation script: +### Option 1: Download Pre-built Release + +1. Download the latest release for your platform from the [Releases page](https://github.com/olibf/dbdiff/releases) +2. Extract the archive to a folder of your choice (e.g., `C:\Tools\DbDiff` on Windows or `/usr/local/bin/dbdiff` on Linux) +3. Add the folder to your system PATH environment variable to run `dbdiff` from anywhere + +### Option 2: Build from Source + +If you're building the tool yourself, use the provided installation script: ```powershell .\install.ps1 -Destination "C:\Tools\DbDiff" @@ -237,6 +254,7 @@ The project follows **Hexagonal Architecture** (Ports & Adapters) with clear sep - ✅ Microsoft SQL Server (MSSQL) - ✅ PostgreSQL +- ✅ SQLite ### Cross-Database Schema Comparison @@ -249,8 +267,12 @@ dbdiff --connection "Server=localhost;Database=MyDb;Trusted_Connection=true;" -- # Export PostgreSQL schema dbdiff --connection "Host=localhost;Database=mydb;Username=user;Password=pass" --output postgres-schema.txt +# Exort SQLite schema +DbDiff.Cli --connection "Data Source=path-to-file/database.db" --database-type sqlite --output sqlite-schema.txt + # Compare using your favorite diff tool diff sqlserver-schema.txt postgres-schema.txt +diff sqlserver-schema.txt sqlite-schema.txt ``` **Note:** Data types will differ between platforms (e.g., SQL Server's `nvarchar` vs PostgreSQL's `character varying`), but the consistent format allows you to easily identify structural differences. @@ -262,10 +284,12 @@ diff sqlserver-schema.txt postgres-schema.txt - .NET 10.0 SDK or later - SQL Server (for SQL Server support) - PostgreSQL (for PostgreSQL support) +- SQLite datbase file ### Dependencies - **Microsoft.Data.SqlClient**: SQL Server connectivity +- **Microsoft.Data.SqLite**: SQLite connectivity - **Npgsql**: PostgreSQL connectivity - **Serilog**: Structured logging - **Microsoft.Extensions.***: Configuration and dependency injection diff --git a/src/DbDiff.Cli/Program.cs b/src/DbDiff.Cli/Program.cs index 441b35a..96f5e66 100644 --- a/src/DbDiff.Cli/Program.cs +++ b/src/DbDiff.Cli/Program.cs @@ -123,7 +123,7 @@ Console.WriteLine("Options:"); Console.WriteLine(" -c, --connection Database connection string (required)"); Console.WriteLine(" -o, --output Output file path (default: schema.txt)"); - Console.WriteLine(" -d, --database-type Database type: sqlserver, postgresql (auto-detected if not specified)"); + Console.WriteLine(" -d, --database-type Database type: sqlserver, postgresql, sqlite (auto-detected if not specified)"); Console.WriteLine(" --config Configuration file path"); Console.WriteLine(" --ignore-position Exclude column ordinal positions from output"); Console.WriteLine(" --exclude-view-definitions Exclude view SQL definitions from output"); @@ -201,7 +201,7 @@ if (!Enum.TryParse(databaseTypeArg, ignoreCase: true, out databaseType)) { Console.Error.WriteLine($"Error: Invalid database type '{databaseTypeArg}'."); - Console.Error.WriteLine("Valid values: sqlserver, postgresql"); + Console.Error.WriteLine("Valid values: sqlserver, postgresql, sqlite"); return 1; } } @@ -267,6 +267,14 @@ static DatabaseType DetectDatabaseType(string connectionString) { + // Check for Sqlite keywords + if (connectionString.Contains("Mode=") || + connectionString.Contains("Cache=") || + connectionString.Contains("Filename=")) + { + return DatabaseType.Sqlite; + } + // Check for PostgreSQL keywords if (connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase) || connectionString.Contains("Username=", StringComparison.OrdinalIgnoreCase)) diff --git a/src/DbDiff.Domain/DatabaseType.cs b/src/DbDiff.Domain/DatabaseType.cs index 30ae015..93b45d6 100644 --- a/src/DbDiff.Domain/DatabaseType.cs +++ b/src/DbDiff.Domain/DatabaseType.cs @@ -3,6 +3,7 @@ namespace DbDiff.Domain; public enum DatabaseType { SqlServer, - PostgreSql + PostgreSql, + Sqlite } diff --git a/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj b/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj index 9c5b746..8c5c2cc 100644 --- a/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj +++ b/src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs index 9da8646..1d94a8a 100644 --- a/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs +++ b/src/DbDiff.Infrastructure/SchemaExtractorFactory.cs @@ -8,6 +8,7 @@ public static ISchemaExtractor CreateExtractor(DatabaseType databaseType) { DatabaseType.SqlServer => new MsSqlSchemaExtractor(), DatabaseType.PostgreSql => new PostgreSqlSchemaExtractor(), + DatabaseType.Sqlite => new SqliteSqlSchemaExtractor(), _ => throw new ArgumentException($"Unsupported database type: {databaseType}", nameof(databaseType)) }; } diff --git a/src/DbDiff.Infrastructure/SqliteSqlSchemaExtractor.cs b/src/DbDiff.Infrastructure/SqliteSqlSchemaExtractor.cs new file mode 100644 index 0000000..c6e751d --- /dev/null +++ b/src/DbDiff.Infrastructure/SqliteSqlSchemaExtractor.cs @@ -0,0 +1,160 @@ +using Microsoft.Data.Sqlite; + +namespace DbDiff.Infrastructure; + +public class SqliteSqlSchemaExtractor : ISchemaExtractor +{ + public async Task ExtractSchemaAsync( + string connectionString, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + var databaseName = connection.Database; + var extractedAt = DateTime.UtcNow; + + var tables = await ExtractTablesAsync(connection, cancellationToken); + var views = await ExtractViewsAsync(connection, cancellationToken); + + return new DatabaseSchema(databaseName, extractedAt, tables, views); + } + + private static async Task> ExtractTablesAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + var tables = new List(); + + // Query to get all user tables (excluding system schemas) + const string tableQuery = @" + SELECT + 'sqlite' as table_schema, + name as table_name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name"; + + await using var tableCommand = new SqliteCommand(tableQuery, connection); + await using var tableReader = await tableCommand.ExecuteReaderAsync(cancellationToken); + + var tableInfoList = new List<(string Schema, string Name)>(); + while (await tableReader.ReadAsync(cancellationToken)) + { + var schemaName = tableReader.GetString(0); + var tableName = tableReader.GetString(1); + tableInfoList.Add((schemaName, tableName)); + } + + await tableReader.CloseAsync(); + + // Extract columns for each table + foreach (var (schemaName, tableName) in tableInfoList) + { + var columns = await ExtractColumnsAsync(connection, schemaName, tableName, cancellationToken); + tables.Add(new Table(schemaName, tableName, columns)); + } + + return tables; + } + + private static async Task> ExtractViewsAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + var views = new List(); + + // Query to get all user views with their definitions (excluding system schemas) + const string viewQuery = @" + SELECT + 'sqlite' as table_schema, + name as table_name, + sql as definition + FROM sqlite_master + WHERE type = 'view' + AND name NOT LIKE 'sqlite_%' + ORDER BY name"; + + await using var viewCommand = new SqliteCommand(viewQuery, connection); + await using var viewReader = await viewCommand.ExecuteReaderAsync(cancellationToken); + + var viewInfoList = new List<(string Schema, string Name, string? Definition)>(); + while (await viewReader.ReadAsync(cancellationToken)) + { + var schemaName = viewReader.GetString(0); + var viewName = viewReader.GetString(1); + var definition = viewReader.IsDBNull(2) ? null : viewReader.GetString(2); + viewInfoList.Add((schemaName, viewName, definition)); + } + + await viewReader.CloseAsync(); + + // Extract columns for each view + foreach (var (schemaName, viewName, definition) in viewInfoList) + { + var columns = await ExtractColumnsAsync(connection, schemaName, viewName, cancellationToken); + views.Add(new View(schemaName, viewName, columns, definition)); + } + + return views; + } + + private static async Task> ExtractColumnsAsync( + SqliteConnection connection, + string schemaName, + string tableName, + CancellationToken cancellationToken) + { + var columns = new List(); + + const string columnQuery = @" + SELECT + name as column_name, + type as data_type, + ""notnull"" as is_nullable, + null as character_maximum_length, + null as numeric_precision, + null as numeric_scale, + cid as ordinal_position + FROM pragma_table_info(@TableName) c + ORDER BY cid"; + + await using var columnCommand = new SqliteCommand(columnQuery, connection); + columnCommand.Parameters.AddWithValue("@SchemaName", schemaName); + columnCommand.Parameters.AddWithValue("@TableName", tableName); + + await using var columnReader = await columnCommand.ExecuteReaderAsync(cancellationToken); + + while (await columnReader.ReadAsync(cancellationToken)) + { + var columnName = columnReader.GetString(0); + var dataTypeName = columnReader.GetString(1); + var isNullableStr = columnReader.GetString(2); + var isNullable = isNullableStr.Equals("YES", StringComparison.OrdinalIgnoreCase); + + int? maxLength = columnReader.IsDBNull(3) ? null : columnReader.GetInt32(3); + int? precision = columnReader.IsDBNull(4) ? null : columnReader.GetInt32(4); + int? scale = columnReader.IsDBNull(5) ? null : columnReader.GetInt32(5); + var ordinalPosition = columnReader.GetInt32(6); + + var dataType = new DataType(dataTypeName); + var column = new Column( + columnName, + dataType, + isNullable, + ordinalPosition, + maxLength, + precision, + scale); + + columns.Add(column); + } + + return columns; + } +} +