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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
# 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)
[![Release](https://github.com/olibf/dbdiff/actions/workflows/release.yml/badge.svg)](https://github.com/olibf/dbdiff/actions/workflows/release.yml)

## 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
Expand All @@ -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"
Expand Down Expand Up @@ -237,6 +254,7 @@ The project follows **Hexagonal Architecture** (Ports & Adapters) with clear sep

- ✅ Microsoft SQL Server (MSSQL)
- ✅ PostgreSQL
- ✅ SQLite

### Cross-Database Schema Comparison

Expand All @@ -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.
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/DbDiff.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
Console.WriteLine("Options:");
Console.WriteLine(" -c, --connection <string> Database connection string (required)");
Console.WriteLine(" -o, --output <path> Output file path (default: schema.txt)");
Console.WriteLine(" -d, --database-type <type> Database type: sqlserver, postgresql (auto-detected if not specified)");
Console.WriteLine(" -d, --database-type <type> Database type: sqlserver, postgresql, sqlite (auto-detected if not specified)");
Console.WriteLine(" --config <path> Configuration file path");
Console.WriteLine(" --ignore-position Exclude column ordinal positions from output");
Console.WriteLine(" --exclude-view-definitions Exclude view SQL definitions from output");
Expand Down Expand Up @@ -201,7 +201,7 @@
if (!Enum.TryParse<DatabaseType>(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;
}
}
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion src/DbDiff.Domain/DatabaseType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace DbDiff.Domain;
public enum DatabaseType
{
SqlServer,
PostgreSql
PostgreSql,
Sqlite
}

1 change: 1 addition & 0 deletions src/DbDiff.Infrastructure/DbDiff.Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.1" />
<PackageReference Include="Npgsql" Version="8.0.5" />
</ItemGroup>

Expand Down
1 change: 1 addition & 0 deletions src/DbDiff.Infrastructure/SchemaExtractorFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
};
}
Expand Down
160 changes: 160 additions & 0 deletions src/DbDiff.Infrastructure/SqliteSqlSchemaExtractor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using Microsoft.Data.Sqlite;

namespace DbDiff.Infrastructure;

public class SqliteSqlSchemaExtractor : ISchemaExtractor
{
public async Task<DatabaseSchema> 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<List<Table>> ExtractTablesAsync(
SqliteConnection connection,
CancellationToken cancellationToken)
{
var tables = new List<Table>();

// 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<List<View>> ExtractViewsAsync(
SqliteConnection connection,
CancellationToken cancellationToken)
{
var views = new List<View>();

// 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<List<Column>> ExtractColumnsAsync(
SqliteConnection connection,
string schemaName,
string tableName,
CancellationToken cancellationToken)
{
var columns = new List<Column>();

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;
}
}

Loading