diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 8462e602..6be2cf58 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -28,6 +28,7 @@ + diff --git a/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs b/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs new file mode 100644 index 00000000..2d06c4f1 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs @@ -0,0 +1,127 @@ +using System.Diagnostics; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Seed; + +public sealed class SeedCommand : Command +{ + public override int Execute(CommandContext context, SeedSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" + ); + return 1; + } + + var seederProject = Path.Combine( + solution.RootPath, + "tools", + "SimpleModule.PerfSeeder", + "SimpleModule.PerfSeeder.csproj" + ); + if (!File.Exists(seederProject)) + { + AnsiConsole.MarkupLine( + $"[red]Perf seeder project not found at {seederProject.EscapeMarkup()}[/]" + ); + return 1; + } + + var args = BuildForwardArgs(settings, solution); + + AnsiConsole.MarkupLine( + "[bold blue]Running perf seeder[/] (this may take a while for large counts)..." + ); + AnsiConsole.MarkupLine( + $"[dim] dotnet run -c Release --project {seederProject.EscapeMarkup()} -- {string.Join(' ', args).EscapeMarkup()}[/]" + ); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = solution.RootPath, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add("run"); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("Release"); + startInfo.ArgumentList.Add("--project"); + startInfo.ArgumentList.Add(seederProject); + startInfo.ArgumentList.Add("--"); + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + try + { + using var process = Process.Start(startInfo); + if (process is null) + { + AnsiConsole.MarkupLine("[red]Failed to start dotnet process.[/]"); + return 1; + } + process.WaitForExit(); + return process.ExitCode; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + AnsiConsole.MarkupLine($"[red]Seeder failed: {ex.Message.EscapeMarkup()}[/]"); + return 1; + } + } + + private static List BuildForwardArgs(SeedSettings settings, SolutionContext solution) + { + var args = new List + { + "--module", + settings.Module, + "--batch-size", + settings.BatchSize.ToString(System.Globalization.CultureInfo.InvariantCulture), + "--seed", + settings.RandomSeed.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + if (settings.Count is { } count) + { + args.Add("--count"); + args.Add(count.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrWhiteSpace(settings.Connection)) + { + args.Add("--connection"); + args.Add(settings.Connection); + } + if (!string.IsNullOrWhiteSpace(settings.Provider)) + { + args.Add("--provider"); + args.Add(settings.Provider); + } + if (settings.Truncate) + { + args.Add("--truncate"); + } + if (settings.CreateSchema) + { + args.Add("--create-schema"); + } + + // Always pass the host project so the seeder reads the right appsettings.json. + var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath); + if (!string.IsNullOrWhiteSpace(hostDir)) + { + args.Add("--project"); + args.Add(hostDir); + } + + return args; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs b/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs new file mode 100644 index 00000000..a609add9 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Seed; + +public sealed class SeedSettings : CommandSettings +{ + [CommandOption("-m|--module ")] + [Description("Target module: products, orders, or all. Default: all.")] + public string Module { get; set; } = "all"; + + [CommandOption("-c|--count ")] + [Description( + "Row count override applied to each target module. " + + "Defaults: products=1,000,000, orders=100,000." + )] + public int? Count { get; set; } + + [CommandOption("--connection ")] + [Description( + "Override the database connection string. By default reads Database:DefaultConnection from the host's appsettings.json." + )] + public string? Connection { get; set; } + + [CommandOption("--provider ")] + [Description( + "Override the database provider (Sqlite|PostgreSql|SqlServer). By default auto-detected." + )] + public string? Provider { get; set; } + + [CommandOption("--batch-size ")] + [Description("Rows per SaveChanges batch. Default: 5000.")] + public int BatchSize { get; set; } = 5000; + + [CommandOption("--seed ")] + [Description("Randomizer seed for deterministic data generation. Default: 42.")] + public int RandomSeed { get; set; } = 42; + + [CommandOption("--truncate")] + [Description( + "Delete existing rows in the target tables before seeding. Products keeps rows with Id <= 10 (migration seed)." + )] + public bool Truncate { get; set; } + + [CommandOption("--create-schema")] + [Description( + "Call EnsureCreated on each target DbContext before seeding. Useful against a fresh database when migrations are unavailable." + )] + public bool CreateSchema { get; set; } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 3487db1f..34db3159 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -2,6 +2,7 @@ using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; using SimpleModule.Cli.Commands.New; +using SimpleModule.Cli.Commands.Seed; using Spectre.Console.Cli; var app = new CommandApp(); @@ -43,6 +44,12 @@ config .AddCommand("doctor") .WithDescription("Validate project structure and conventions"); + + config + .AddCommand("seed") + .WithDescription( + "Seed the configured database with bulk test data for perf testing (Products, Orders). AuditLogs are populated automatically by the audit interceptor on real operations." + ); }); return app.Run(args); diff --git a/tools/SimpleModule.PerfSeeder/Program.cs b/tools/SimpleModule.PerfSeeder/Program.cs new file mode 100644 index 00000000..3ba23e7d --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/Program.cs @@ -0,0 +1,10 @@ +using SimpleModule.PerfSeeder; +using Spectre.Console.Cli; + +var app = new CommandApp(); +app.Configure(config => +{ + config.SetApplicationName("SimpleModule.PerfSeeder"); + config.PropagateExceptions(); +}); +return await app.RunAsync(args).ConfigureAwait(false); diff --git a/tools/SimpleModule.PerfSeeder/SeedCommand.cs b/tools/SimpleModule.PerfSeeder/SeedCommand.cs new file mode 100644 index 00000000..bdba1570 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/SeedCommand.cs @@ -0,0 +1,239 @@ +using System.Data.Common; +using System.Diagnostics; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Orders; +using SimpleModule.PerfSeeder.Seeders; +using SimpleModule.Products; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.PerfSeeder; + +public sealed class SeedCommand : AsyncCommand +{ + private const int DefaultProductsCount = 1_000_000; + private const int DefaultOrdersCount = 100_000; + + public override async Task ExecuteAsync(CommandContext context, SeedSettings settings) + { + var projectPath = ResolveProjectPath(settings.ProjectPath); + if (projectPath is null) + { + AnsiConsole.MarkupLine( + "[red]Could not find a host project. Pass --project .[/]" + ); + return 1; + } + + var dbOptions = LoadDatabaseOptions(projectPath, settings); + if (string.IsNullOrWhiteSpace(dbOptions.DefaultConnection)) + { + AnsiConsole.MarkupLine( + "[red]Database:DefaultConnection is empty. Configure appsettings.json or pass --connection.[/]" + ); + return 1; + } + + DatabaseProvider provider; + try + { + provider = DatabaseProviderDetector.Detect( + dbOptions.DefaultConnection, + dbOptions.Provider + ); + } + catch (InvalidOperationException ex) + { + AnsiConsole.MarkupLine($"[red]{ex.Message.EscapeMarkup()}[/]"); + return 1; + } + + AnsiConsole.MarkupLine( + $"[bold]Perf seeder[/] — project: [cyan]{projectPath.EscapeMarkup()}[/], provider: [cyan]{provider}[/]" + ); + AnsiConsole.MarkupLine( + $"[dim]Connection: {Redact(dbOptions.DefaultConnection).EscapeMarkup()}[/]" + ); + + var module = settings.Module.Trim(); + var runAll = module.Length == 0 || module.Equals("all", StringComparison.OrdinalIgnoreCase); + var runProducts = runAll || module.Equals("products", StringComparison.OrdinalIgnoreCase); + var runOrders = runAll || module.Equals("orders", StringComparison.OrdinalIgnoreCase); + + if (!runProducts && !runOrders) + { + AnsiConsole.MarkupLine( + $"[red]Unknown module '{module.EscapeMarkup()}'. Valid: products, orders, all.[/]" + ); + return 1; + } + + var totalStopwatch = Stopwatch.StartNew(); + + if (runProducts) + { + var count = settings.Count ?? DefaultProductsCount; + using var db = BuildContext(dbOptions, provider); + if (settings.CreateSchema) + { + EnsureTablesCreated(db); + } + await new ProductsSeeder(db, provider) + .RunAsync(count, settings.BatchSize, settings.RandomSeed, settings.Truncate) + .ConfigureAwait(false); + } + + if (runOrders) + { + var count = settings.Count ?? DefaultOrdersCount; + using var db = BuildContext(dbOptions, provider); + if (settings.CreateSchema) + { + EnsureTablesCreated(db); + } + await new OrdersSeeder(db, provider) + .RunAsync(count, settings.BatchSize, settings.RandomSeed, settings.Truncate) + .ConfigureAwait(false); + } + + totalStopwatch.Stop(); + AnsiConsole.MarkupLine($"[green]Done in {totalStopwatch.Elapsed.TotalSeconds:F1}s.[/]"); + return 0; + } + + private static string? ResolveProjectPath(string? explicitPath) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return Path.GetFullPath(explicitPath); + } + + var dir = Directory.GetCurrentDirectory(); + while (dir is not null) + { + if (Directory.GetFiles(dir, "*.slnx").Length > 0) + { + var candidate = Path.Combine(dir, "template", "SimpleModule.Host"); + if (File.Exists(Path.Combine(candidate, "appsettings.json"))) + { + return candidate; + } + + var srcHosts = Directory.GetDirectories(Path.Combine(dir, "src"), "*.Host"); + foreach (var host in srcHosts) + { + if (File.Exists(Path.Combine(host, "appsettings.json"))) + { + return host; + } + } + } + + dir = Directory.GetParent(dir)?.FullName; + } + + return null; + } + + private static DatabaseOptions LoadDatabaseOptions(string projectPath, SeedSettings settings) + { + var config = new ConfigurationBuilder() + .SetBasePath(projectPath) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var options = config.GetSection("Database").Get() ?? new DatabaseOptions(); + + if (!string.IsNullOrWhiteSpace(settings.Connection)) + { + options.DefaultConnection = settings.Connection; + } + if (!string.IsNullOrWhiteSpace(settings.Provider)) + { + options.Provider = settings.Provider; + } + + return options; + } + + private static TContext BuildContext( + DatabaseOptions dbOptions, + DatabaseProvider provider + ) + where TContext : DbContext + { + var builder = new DbContextOptionsBuilder(); + switch (provider) + { + case DatabaseProvider.PostgreSql: + builder.UseNpgsql(dbOptions.DefaultConnection); + break; + case DatabaseProvider.SqlServer: + builder.UseSqlServer(dbOptions.DefaultConnection); + break; + default: + builder.UseSqlite(dbOptions.DefaultConnection); + break; + } + + var ctx = (TContext?) + Activator.CreateInstance(typeof(TContext), builder.Options, Options.Create(dbOptions)); + if (ctx is null) + { + throw new InvalidOperationException( + $"Could not construct DbContext of type {typeof(TContext).Name}." + ); + } + + ctx.ChangeTracker.AutoDetectChangesEnabled = false; + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return ctx; + } + + /// + /// Creates the target DbContext's tables even when the physical DB already exists + /// (EnsureCreated skips table creation when any tables are present). Swallows + /// duplicate-table errors so it's safe to call across multiple module contexts + /// sharing the same database. + /// + private static void EnsureTablesCreated(DbContext db) + { + if (db.Database.EnsureCreated()) + { + return; + } + try + { + db.GetService().CreateTables(); + } + catch (DbException) + { + // Some/all tables already exist — fine. + } + } + + private static string Redact(string connectionString) + { + // Hide password-like values in log output. + var parts = connectionString.Split(';'); + for (var i = 0; i < parts.Length; i++) + { + var kv = parts[i].Split('=', 2); + if ( + kv.Length == 2 + && kv[0].Trim().Equals("Password", StringComparison.OrdinalIgnoreCase) + ) + { + parts[i] = $"{kv[0]}=***"; + } + } + return string.Join(';', parts); + } +} diff --git a/tools/SimpleModule.PerfSeeder/SeedSettings.cs b/tools/SimpleModule.PerfSeeder/SeedSettings.cs new file mode 100644 index 00000000..d73f58a2 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/SeedSettings.cs @@ -0,0 +1,55 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.PerfSeeder; + +public sealed class SeedSettings : CommandSettings +{ + [CommandOption("-m|--module ")] + [Description("Target module: products, orders, or all. Default: all.")] + public string Module { get; set; } = "all"; + + [CommandOption("-c|--count ")] + [Description( + "Row count override. Applied per module. " + "Defaults: products=1000000, orders=100000." + )] + public int? Count { get; set; } + + [CommandOption("--connection ")] + [Description( + "Override the database connection string. By default reads Database:DefaultConnection from appsettings.json." + )] + public string? Connection { get; set; } + + [CommandOption("--provider ")] + [Description( + "Override the database provider (Sqlite|PostgreSql|SqlServer). By default auto-detected from the connection string." + )] + public string? Provider { get; set; } + + [CommandOption("--project ")] + [Description( + "Path to the host project directory used for appsettings.json discovery. Default: ./template/SimpleModule.Host relative to the solution root." + )] + public string? ProjectPath { get; set; } + + [CommandOption("--batch-size ")] + [Description("Rows per SaveChanges batch. Default: 5000.")] + public int BatchSize { get; set; } = 5000; + + [CommandOption("--seed ")] + [Description("Randomizer seed for deterministic data generation. Default: 42.")] + public int RandomSeed { get; set; } = 42; + + [CommandOption("--truncate")] + [Description( + "Delete existing rows in the target tables before seeding. Keeps Products rows with Id <= 10 (the migration seed)." + )] + public bool Truncate { get; set; } + + [CommandOption("--create-schema")] + [Description( + "Call EnsureCreated on each target DbContext before seeding. Useful against a fresh database when migrations are unavailable. Skips silently if tables already exist." + )] + public bool CreateSchema { get; set; } +} diff --git a/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs b/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs new file mode 100644 index 00000000..609b22d2 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs @@ -0,0 +1,118 @@ +using System.Diagnostics; +using Bogus; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Database; +using SimpleModule.Orders; +using SimpleModule.Orders.Contracts; +using Spectre.Console; + +namespace SimpleModule.PerfSeeder.Seeders; + +internal sealed class OrdersSeeder(OrdersDbContext db, DatabaseProvider provider) +{ + private const int UserPoolSize = 1000; + private const int ProductIdMax = 1000; + private const int MaxItemsPerOrder = 5; + + public async Task RunAsync(int count, int batchSize, int randomSeed, bool truncate) + { + AnsiConsole.MarkupLine($"[bold]-> Orders[/] (target: {count:N0} rows)"); + + if (truncate) + { + var itemsTable = db.QuoteTable(typeof(OrderItem)); + var ordersTable = db.QuoteTable(typeof(Order)); + // Delete children first — the FK from OrderItems.OrderId to Orders is restrict by default. + // Table names come from EF model metadata, not user input — safe from injection. +#pragma warning disable EF1002 + var deletedItems = await db + .Database.ExecuteSqlRawAsync($"DELETE FROM {itemsTable}") + .ConfigureAwait(false); + var deletedOrders = await db + .Database.ExecuteSqlRawAsync($"DELETE FROM {ordersTable}") + .ConfigureAwait(false); +#pragma warning restore EF1002 + AnsiConsole.MarkupLine( + $"[dim] truncated {deletedOrders:N0} orders, {deletedItems:N0} items[/]" + ); + } + + await EnableSqliteFastPathAsync(provider, db).ConfigureAwait(false); + + var rng = new Randomizer(randomSeed); + var now = DateTimeOffset.UtcNow; + + // Build the faker inline so we can capture rng for deterministic distinct-product sampling. + Order BuildOrder() + { + var itemCount = rng.Int(1, MaxItemsPerOrder); + // Distinct ProductIds within an order (composite PK is OrderId, ProductId). + var pickedProducts = new HashSet(itemCount); + var items = new List(itemCount); + while (pickedProducts.Count < itemCount) + { + var pid = rng.Int(1, ProductIdMax); + if (pickedProducts.Add(pid)) + { + items.Add(new OrderItem { ProductId = pid, Quantity = rng.Int(1, 10) }); + } + } + return new Order + { + UserId = rng.Int(1, UserPoolSize) + .ToString(System.Globalization.CultureInfo.InvariantCulture), + Items = items, + Total = Math.Round((decimal)rng.Double(10, 500), 2), + CreatedAt = now.AddMinutes(-rng.Int(0, 60 * 24 * 30)), + UpdatedAt = now, + CreatedBy = "perf-seeder", + UpdatedBy = "perf-seeder", + ConcurrencyStamp = Guid.NewGuid().ToString("N"), + }; + } + + var sw = Stopwatch.StartNew(); + var inserted = 0L; + var itemsInserted = 0L; + var remaining = count; + var nextReport = (long)batchSize * 5; + + await using var tx = await db.Database.BeginTransactionAsync().ConfigureAwait(false); + while (remaining > 0) + { + var take = Math.Min(batchSize, remaining); + var batch = new List(take); + for (var i = 0; i < take; i++) + { + var o = BuildOrder(); + batch.Add(o); + itemsInserted += o.Items.Count; + } + await db.Orders.AddRangeAsync(batch).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + db.ChangeTracker.Clear(); + inserted += take; + remaining -= take; + + if (inserted >= nextReport || remaining == 0) + { + SeederProgress.Report("orders", inserted, count, sw); + nextReport = inserted + (long)batchSize * 5; + } + } + await tx.CommitAsync().ConfigureAwait(false); + + SeederProgress.Final($"orders ({itemsInserted:N0} items)", inserted, sw); + } + + private static async Task EnableSqliteFastPathAsync(DatabaseProvider provider, DbContext ctx) + { + if (provider != DatabaseProvider.Sqlite) + { + return; + } + await ctx.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;").ConfigureAwait(false); + await ctx.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;").ConfigureAwait(false); + await ctx.Database.ExecuteSqlRawAsync("PRAGMA temp_store=MEMORY;").ConfigureAwait(false); + } +} diff --git a/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs b/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs new file mode 100644 index 00000000..0f5d8a89 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using System.Globalization; +using Bogus; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Database; +using SimpleModule.Products; +using SimpleModule.Products.Contracts; +using Spectre.Console; + +namespace SimpleModule.PerfSeeder.Seeders; + +internal sealed class ProductsSeeder(ProductsDbContext db, DatabaseProvider provider) +{ + /// + /// Ids 1..10 are reserved for the migration seed data in ProductConfiguration. + /// We skip these on truncate so the referential data stays intact. + /// + private const int MigrationSeedMaxId = 10; + + public async Task RunAsync(int count, int batchSize, int randomSeed, bool truncate) + { + AnsiConsole.MarkupLine($"[bold]-> Products[/] (target: {count:N0} rows)"); + + if (truncate) + { + var table = db.QuoteTable(typeof(Product)); + // Table name comes from EF model metadata, not user input — safe from injection. +#pragma warning disable EF1002 + var deleted = await db + .Database.ExecuteSqlRawAsync( + $"DELETE FROM {table} WHERE \"Id\" > {MigrationSeedMaxId}" + ) + .ConfigureAwait(false); +#pragma warning restore EF1002 + AnsiConsole.MarkupLine($"[dim] truncated {deleted:N0} existing rows[/]"); + } + + await EnableSqliteFastPathAsync(provider, db).ConfigureAwait(false); + + var faker = new Faker() + .UseSeed(randomSeed) + .RuleFor(p => p.Name, f => f.Commerce.ProductName()) + .RuleFor( + p => p.Price, + f => + decimal.Parse( + f.Commerce.Price(1, 1000), + NumberStyles.Any, + CultureInfo.InvariantCulture + ) + ) + .RuleFor(p => p.CreatedAt, f => f.Date.Past(1)) + .RuleFor(p => p.UpdatedAt, (_, p) => p.CreatedAt) + .RuleFor(p => p.ConcurrencyStamp, _ => Guid.NewGuid().ToString("N")); + + var sw = Stopwatch.StartNew(); + var inserted = 0L; + var remaining = count; + var nextReport = (long)batchSize * 10; + + await using var tx = await db.Database.BeginTransactionAsync().ConfigureAwait(false); + while (remaining > 0) + { + var take = Math.Min(batchSize, remaining); + var batch = faker.Generate(take); + await db.Products.AddRangeAsync(batch).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + db.ChangeTracker.Clear(); + inserted += take; + remaining -= take; + + if (inserted >= nextReport || remaining == 0) + { + SeederProgress.Report("products", inserted, count, sw); + nextReport = inserted + (long)batchSize * 10; + } + } + await tx.CommitAsync().ConfigureAwait(false); + + SeederProgress.Final("products", inserted, sw); + } + + private static async Task EnableSqliteFastPathAsync(DatabaseProvider provider, DbContext ctx) + { + if (provider != DatabaseProvider.Sqlite) + { + return; + } + // WAL mode + relaxed sync drastically improves bulk insert throughput. + // These pragmas persist for the connection lifetime only, so this is safe. + await ctx.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;").ConfigureAwait(false); + await ctx.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;").ConfigureAwait(false); + await ctx.Database.ExecuteSqlRawAsync("PRAGMA temp_store=MEMORY;").ConfigureAwait(false); + } +} diff --git a/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs b/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs new file mode 100644 index 00000000..4fcd4591 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace SimpleModule.PerfSeeder.Seeders; + +internal static class SeederHelpers +{ + /// + /// Returns the provider-appropriate quoted table identifier (e.g. "products"."Products" + /// under PostgreSQL, "Products_Products" under SQLite). + /// + public static string QuoteTable(this DbContext ctx, Type entityType) + { + var et = + ctx.Model.FindEntityType(entityType) + ?? throw new InvalidOperationException( + $"Entity type {entityType.Name} is not part of the model." + ); + return QuoteTable(et); + } + + public static string QuoteTable(IEntityType et) + { + var table = + et.GetTableName() + ?? throw new InvalidOperationException($"Entity {et.ClrType.Name} has no table name."); + var schema = et.GetSchema(); + return schema is null ? $"\"{table}\"" : $"\"{schema}\".\"{table}\""; + } +} diff --git a/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs b/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs new file mode 100644 index 00000000..3c017685 --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using Spectre.Console; + +namespace SimpleModule.PerfSeeder.Seeders; + +internal static class SeederProgress +{ + public static void Report(string label, long inserted, long total, Stopwatch sw) + { + var elapsed = sw.Elapsed.TotalSeconds; + var rate = elapsed > 0 ? inserted / elapsed : 0; + var pct = total > 0 ? (double)inserted / total * 100 : 0; + AnsiConsole.MarkupLine( + $"[dim] {label.EscapeMarkup()}:[/] " + + $"{inserted:N0}/{total:N0} " + + $"[green]({pct:F1}%)[/] " + + $"[dim]{rate:N0} rows/s[/]" + ); + } + + public static void Final(string label, long inserted, Stopwatch sw) + { + sw.Stop(); + var elapsed = sw.Elapsed.TotalSeconds; + var rate = elapsed > 0 ? inserted / elapsed : 0; + AnsiConsole.MarkupLine( + $"[green][[ok]][/] {label.EscapeMarkup()}: " + + $"inserted {inserted:N0} rows in {elapsed:F1}s " + + $"[dim]({rate:N0} rows/s)[/]" + ); + } +} diff --git a/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj b/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj new file mode 100644 index 00000000..263448fa --- /dev/null +++ b/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj @@ -0,0 +1,26 @@ + + + Exe + net10.0 + SimpleModule.PerfSeeder + Performance-test data seeder. Populates the configured database with large volumes of realistic data (Products, Orders, AuditLogs) for manual perf exploration and benchmarks. + false + + + + + + + + + + + + + + + + + + +