This commit is contained in:
john 2025-05-03 15:38:57 +02:00
commit ab2e20f7e1
72 changed files with 2000 additions and 0 deletions

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,44 @@
-- Migration: Init
-- Created at: 25/04/2025 12:14:59
CREATE SCHEMA blog;
CREATE TABLE blog.author
(
id uuid PRIMARY KEY,
username varchar(64) UNIQUE NOT NULL
);
CREATE TABLE blog.post
(
id uuid PRIMARY KEY,
content text NOT NULL,
created_on timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
author_id uuid NOT NULL REFERENCES blog.author (id) on DELETE CASCADE
);
CREATE TABLE blog.post_media
(
id uuid PRIMARY KEY,
post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE,
url text NOT NULL,
ordering int NOT NULL
);
CREATE TYPE outbox_status AS ENUM ('pending', 'completed', 'failed');
CREATE TABLE blog.outbox
(
id uuid PRIMARY KEY,
event_type text NOT NULL,
aggregate_id uuid NOT NULL,
payload jsonb NOT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
processed_at timestamp,
next_retry_at timestamp,
retry_count int DEFAULT 0 NOT NULL,
last_error text,
status outbox_status DEFAULT 'pending' NOT NULL
)

122
Femto.Database/Migrator.cs Normal file
View file

@ -0,0 +1,122 @@
using Npgsql;
namespace Femto.Database;
internal class Migrator(string migrationDirectory, NpgsqlDataSource dataSource) : IAsyncDisposable
{
private record MigrationScript(string Id, string Script);
public async Task Migrate()
{
var migrations = await LoadMigrationScripts();
await this.EnsureMigrationsTableExists();
var executedMigrations = await GetExecutedMigrations();
EnsureConsistentMigrationHistory(migrations, executedMigrations);
if (executedMigrations.Count == migrations.Count)
{
Console.WriteLine("up to date");
return;
}
await DoMigrations(migrations.Skip(executedMigrations.Count));
}
private async Task<IList<MigrationScript>> LoadMigrationScripts()
{
if (!Directory.Exists(migrationDirectory))
throw new DirectoryNotFoundException($"Migration directory not found: {migrationDirectory}");
var scriptFiles = Directory.EnumerateFiles(migrationDirectory, "*.sql")
.OrderBy(Path.GetFileName)
.ToList();
if (!scriptFiles.Any())
throw new Exception("No migration scripts found in the specified directory.");
var migrationScripts = new List<MigrationScript>();
foreach (var file in scriptFiles)
{
var scriptContent = await File.ReadAllTextAsync(file);
var scriptId = Path.GetFileName(file);
migrationScripts.Add(new MigrationScript(scriptId, scriptContent));
}
return migrationScripts;
}
private async Task EnsureMigrationsTableExists()
{
await using var createSchemaCommand = dataSource.CreateCommand(
"""CREATE SCHEMA IF NOT EXISTS __migrations;"""
);
await createSchemaCommand.ExecuteNonQueryAsync();
await using var createTableCommand = dataSource.CreateCommand(
"""
CREATE TABLE IF NOT EXISTS __migrations.__migration_history ( migration VARCHAR(127) PRIMARY KEY );
"""
);
await createTableCommand.ExecuteNonQueryAsync();
}
private async Task<IList<string>> GetExecutedMigrations()
{
await using var command = dataSource.CreateCommand(
"""SELECT migration FROM __migrations.__migration_history"""
);
var reader = await command.ExecuteReaderAsync();
var migrations = new List<string>();
while (await reader.ReadAsync())
{
migrations.Add(reader.GetString(0));
}
return migrations;
}
private void EnsureConsistentMigrationHistory(
IList<MigrationScript> migrationScripts,
IList<string> executedMigrationIds
)
{
if (executedMigrationIds.Count > migrationScripts.Count)
throw new Exception("inconsistent migration history");
for (var i = 0; i < executedMigrationIds.Count; i++)
{
var migration = migrationScripts[i];
var executedMigrationId = executedMigrationIds[i];
if (migration.Id != executedMigrationId)
throw new Exception($"unexpected migration in history {executedMigrationId}");
}
}
private async Task DoMigrations(IEnumerable<MigrationScript> migrationScripts)
{
foreach (var migration in migrationScripts)
{
Console.WriteLine($"applying migration {migration.Id}");
await using var command = dataSource.CreateCommand(migration.Script);
await command.ExecuteNonQueryAsync();
await using var addToHistoryCommand = dataSource.CreateCommand(
"""INSERT INTO __migrations.__migration_history (migration) VALUES (@migration)"""
);
addToHistoryCommand.Parameters.AddWithValue("@migration", migration.Id);
await addToHistoryCommand.ExecuteNonQueryAsync();
}
}
public ValueTask DisposeAsync()
{
return dataSource.DisposeAsync();
}
}

154
Femto.Database/Program.cs Normal file
View file

@ -0,0 +1,154 @@
using System.CommandLine;
using Femto.Database;
using Femto.Database.Seed;
using Npgsql;
var nameArg = new Argument<string>("name", "the name of the migration");
var migrationsDirectoryOption = new Option<string>(
["--migrations-directory"],
() => "./Migrations",
"the directory where the migrations are stored"
);
var newCommand = new Command("new", "creates a new migrations")
{
nameArg,
migrationsDirectoryOption,
};
newCommand.SetHandler(MakeNewMigration, nameArg, migrationsDirectoryOption);
var connectionStringArg = new Argument<string>(
"--connection-string",
"the connection string to the database"
);
var upCommand = new Command("up", "update the database to the most current migration")
{
migrationsDirectoryOption,
connectionStringArg,
};
upCommand.SetHandler(MigrateUp, migrationsDirectoryOption, connectionStringArg);
var seedCommand = new Command("seed", "seed the database with test data") { connectionStringArg };
seedCommand.SetHandler(Seed, connectionStringArg);
// Add these near the top with other command definitions
var yesOption = new Option<bool>(
["-y", "--yes"],
"Skip confirmation prompt"
);
var resetCommand = new Command("reset", "drops the existing database, runs migrations, and seeds test data")
{
connectionStringArg,
migrationsDirectoryOption,
yesOption
};
resetCommand.SetHandler(CreateTestDatabase, connectionStringArg, migrationsDirectoryOption, yesOption);
var rootCommand = new RootCommand("migrator") { newCommand, upCommand, seedCommand, resetCommand };
return await rootCommand.InvokeAsync(args);
static async Task MakeNewMigration(string name, string migrationsDirectory)
{
Directory.CreateDirectory(migrationsDirectory);
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var fileName = $"{timestamp}_{name}.sql";
var filePath = Path.Combine(migrationsDirectory, fileName);
// Write an initial comment in the file
File.WriteAllText(filePath, $"-- Migration: {name}\n-- Created at: {DateTime.UtcNow}");
// Notify the user
Console.WriteLine($"Migration created successfully: {filePath}");
}
static async Task MigrateUp(string migrationsDirectory, string connectionString)
{
if (!Directory.Exists(migrationsDirectory))
{
Console.WriteLine("Migrations directory does not exist.");
return;
}
await using var dataSource = NpgsqlDataSource.Create(connectionString);
await using var migrator = new Migrator(migrationsDirectory, dataSource);
await migrator.Migrate();
}
static async Task Seed(string connectionString)
{
var dataSource = NpgsqlDataSource.Create(connectionString);
await TestDataSeeder.Seed(dataSource);
}
static async Task CreateTestDatabase(string connectionString, string migrationsDirectory, bool skipConfirmation)
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
if (!skipConfirmation)
{
builder.Database = "postgres";
try
{
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pg_database WHERE datname = '{builder.Database}'";
var exists = await cmd.ExecuteScalarAsync();
if (exists is true)
{
Console.WriteLine("WARNING: This will drop the existing database and recreate it.");
Console.Write("Are you sure you want to continue? (y/N): ");
var response = Console.ReadLine()?.ToLower();
if (response != "y")
{
Console.WriteLine("Operation cancelled.");
return;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error checking database existence: {ex.Message}");
throw;
}
}
var databaseName = builder.Database;
builder.Database = "postgres"; // Connect to default database to drop the target
try
{
// Connect to postgres database to drop/create the target database
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
// Drop database if it exists
cmd.CommandText = $"DROP DATABASE IF EXISTS {databaseName} WITH (FORCE)";
await cmd.ExecuteNonQueryAsync();
// Create fresh database
cmd.CommandText = $"CREATE DATABASE {databaseName}";
await cmd.ExecuteNonQueryAsync();
// Now run migrations and seed with the original connection string
Console.WriteLine("Running migrations...");
await MigrateUp(migrationsDirectory, connectionString);
Console.WriteLine("Seeding database...");
await Seed(connectionString);
Console.WriteLine("Database reset completed successfully.");
}
catch (Exception ex)
{
Console.WriteLine($"Error resetting database: {ex.Message}");
throw;
}
}

View file

@ -0,0 +1,41 @@
using Npgsql;
namespace Femto.Database.Seed;
public static class TestDataSeeder
{
public static async Task Seed(NpgsqlDataSource dataSource)
{
await using var addToHistoryCommand = dataSource.CreateCommand(
"""
INSERT INTO blog.author
(id, username)
VALUES
('0196960c-6296-7532-ba66-8fabb38c6ae0', 'johnbotris')
;
INSERT INTO blog.post
(id, author_id, content)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', '0196960c-6296-7532-ba66-8fabb38c6ae0','Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('019691a0-4dd3-7e89-909e-94a6fd19a05e', '0196960c-6296-7532-ba66-8fabb38c6ae0','Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
;
INSERT INTO blog.post_media
(id, post_id, url, ordering)
VALUES
('019691a2-c1b0-705e-8865-b5053bed9671', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://wallpaperaccess.com/full/1401569.jpg', 0),
('019691b5-bbfa-7481-ad74-25a6fea8db60', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://i.redd.it/4g8k43py9pi81.png', 1),
('019691b5-d813-7f46-87a5-8ee4e987622c', '019691a0-4ace-7bb5-a8f3-e3362920eba0', 'https://wallpapercave.com/wp/wp5305675.jpg', 0),
('019691b5-f345-7d86-9eba-2ca6780d8358', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-kpb89wgv5ov3znql.jpg', 0),
('019691b6-07cb-7353-8c33-68456188f462', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-2bxloyitgw7q1hfg.jpg', 1),
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
;
"""
);
await addToHistoryCommand.ExecuteNonQueryAsync();
}
}