init
This commit is contained in:
commit
ab2e20f7e1
72 changed files with 2000 additions and 0 deletions
19
Femto.Database/Femto.Database.csproj
Normal file
19
Femto.Database/Femto.Database.csproj
Normal 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>
|
44
Femto.Database/Migrations/20250425121459_Init.sql
Normal file
44
Femto.Database/Migrations/20250425121459_Init.sql
Normal 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
122
Femto.Database/Migrator.cs
Normal 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
154
Femto.Database/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
41
Femto.Database/Seed/TestDataSeeder.cs
Normal file
41
Femto.Database/Seed/TestDataSeeder.cs
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue