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
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using JasperFx;
using Npgsql;
using Shouldly;
using Weasel.Core;
using Weasel.Core.Migrations;
using Weasel.EntityFrameworkCore.CommandLine;
using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated;
using Weasel.EntityFrameworkCore.Tests.Postgresql;
using Weasel.Postgresql;
using Xunit;
using PgTable = Weasel.Postgresql.Tables.Table;

namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations;

/// <summary>
/// The db-ef-migration engine (#368): first-run scaffolding, incremental
/// add against the snapshot, and baselining an existing database.
/// </summary>
[Collection("pg-schema-comparison")]
public class ef_migration_generator : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), $"weasel-efgen-{Guid.NewGuid():N}");

/// <summary>Minimal IDatabase over the sample schema objects</summary>
private class SampleDatabase : PostgresqlDatabase
{
private readonly List<ISchemaObject> _objects;

public SampleDatabase(List<ISchemaObject> objects)
: base(new DefaultMigrationLogger(), AutoCreate.CreateOrUpdate, new PostgresqlMigrator(),
"SampleStore", NpgsqlDataSource.Create(PostgresqlDbContext.ConnectionString))
{
_objects = objects;
}

public override IFeatureSchema[] BuildFeatureSchemas() =>
new IFeatureSchema[] { new SchemaObjects(_objects) };

private class SchemaObjects : FeatureSchemaBase
{
private readonly List<ISchemaObject> _objects;

public SchemaObjects(List<ISchemaObject> objects) : base("sample", new PostgresqlMigrator())
{
_objects = objects;
}

protected override IEnumerable<ISchemaObject> schemaObjects() => _objects;
}
}

public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, true);
}
}

private EfMigrationGenerationOptions options() => new()
{
Directory = _directory, Namespace = "SampleStore.Migrations"
};

[Fact]
public void detects_the_provider_from_the_migrator()
{
var database = new SampleDatabase(SampleWeaselSchema.Objects().ToList());
EfMigrationGenerator.DetectProvider(database).ShouldBe(EfMigrationProvider.PostgreSql);
}

[Fact]
public void partition_detection_works_structurally()
{
var partitioned = new PgTable("p.t");
partitioned.AddColumn<int>("id").AsPrimaryKey();
partitioned.AddColumn<string>("tenant_id").AsPrimaryKey();
partitioned.PartitionByList("tenant_id");

var plain = new PgTable("p.plain");
plain.AddColumn<int>("id").AsPrimaryKey();

EfMigrationGenerator.IsPartitioned(partitioned).ShouldBeTrue();
EfMigrationGenerator.IsPartitioned(plain).ShouldBeFalse();
}

[Fact]
public async Task first_add_scaffolds_context_migration_and_snapshot_then_incremental_add()
{
var objects = SampleWeaselSchema.Objects().ToList();
var database = new SampleDatabase(objects);

// first run: create everything
var first = await EfMigrationGenerator.AddAsync(database, "Initial", options());

first.HasChanges.ShouldBeTrue();
first.MigrationId!.ShouldEndWith("_Initial");
File.Exists(first.MigrationFile!).ShouldBeTrue();
File.Exists(first.ContextFile!).ShouldBeTrue();
File.Exists(first.SnapshotFile).ShouldBeTrue();

var contextCode = await File.ReadAllTextAsync(first.ContextFile!);
contextCode.ShouldContain("class SampleStoreSchemaDbContext : DbContext");
contextCode.ShouldContain("UseNpgsql(");
// history relocated into the sample schema, not public
contextCode.ShouldContain($"MigrationsHistoryTable(\"__EFMigrationsHistory\", \"{SampleWeaselSchema.SchemaName}\")");

var migrationCode = await File.ReadAllTextAsync(first.MigrationFile!);
migrationCode.ShouldContain("[DbContext(typeof(SampleStoreSchemaDbContext))]");
migrationCode.ShouldContain("migrationBuilder.CreateTable(");

// no model change → no migration
var unchanged = await EfMigrationGenerator.AddAsync(database, "Nothing", options());
unchanged.HasChanges.ShouldBeFalse();

// change the model → incremental migration with a later id
var orders = objects.OfType<PgTable>().Single(x => x.Identifier.Name == "orders");
orders.AddColumn<string>("tenant_id").NotNull();
orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'";

var second = await EfMigrationGenerator.AddAsync(database, "AddTenantId", options());
second.HasChanges.ShouldBeTrue();
second.ContextFile.ShouldBeNull();
string.Compare(second.MigrationId, first.MigrationId, StringComparison.Ordinal).ShouldBeGreaterThan(0);

var incrementalCode = await File.ReadAllTextAsync(second.MigrationFile!);
incrementalCode.ShouldContain("migrationBuilder.AddColumn<string>(");
incrementalCode.ShouldNotContain("migrationBuilder.CreateTable(");

EfMigrationGenerator.MigrationIdsIn(_directory)
.ShouldBe(new[] { first.MigrationId, second.MigrationId! });
}

[Fact]
public async Task baseline_records_history_rows_without_executing()
{
var database = new SampleDatabase(SampleWeaselSchema.Objects().ToList());

// reset the history table state
await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString))
{
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText =
$"drop table if exists \"{SampleWeaselSchema.SchemaName}\".\"__EFMigrationsHistory\";";
await cmd.ExecuteNonQueryAsync();
}

var added = await EfMigrationGenerator.AddAsync(database, "Initial", options());

var recorded = await EfMigrationGenerator.BaselineAsync(database, options());
recorded.ShouldBe(new[] { added.MigrationId! });

// idempotent: nothing new to record on a second pass
(await EfMigrationGenerator.BaselineAsync(database, options())).ShouldBeEmpty();

// and the row really is in the relocated history table
await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString))
{
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText =
$"select count(*) from \"{SampleWeaselSchema.SchemaName}\".\"__EFMigrationsHistory\" where \"MigrationId\" = @id";
var p = cmd.CreateParameter();
p.ParameterName = "@id";
p.Value = added.MigrationId!;
cmd.Parameters.Add(p);
((long)(await cmd.ExecuteScalarAsync())!).ShouldBe(1);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql;
using Shouldly;
using Weasel.Core;
using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated;
using Weasel.EntityFrameworkCore.Tests.Postgresql;
using Xunit;
using PgTable = Weasel.Postgresql.Tables.Table;
using PgIndex = Weasel.Postgresql.Tables.IndexDefinition;

namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations;

/// <summary>
/// #367 acceptance: apply the initial generated migration, change the
/// Weasel model, diff against the snapshot baseline, run the incremental
/// operations through the real Npgsql migrations SQL generator against the
/// database — and Weasel's own delta detection then reports None.
/// </summary>
[Collection("pg-schema-comparison")]
public class incremental_migration_end_to_end : IAsyncLifetime
{
public async Task InitializeAsync()
{
WeaselSampleDbContext.ConnectionString = PostgresqlDbContext.ConnectionString;

await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"drop schema if exists {SampleWeaselSchema.SchemaName} cascade;";
await cmd.ExecuteNonQueryAsync();
}

public Task DisposeAsync() => Task.CompletedTask;

private static ISchemaObject[] modifiedObjects()
{
var objects = SampleWeaselSchema.Objects();
var orders = objects.OfType<PgTable>().Single(x => x.Identifier.Name == "orders");
orders.AddColumn<string>("tenant_id").NotNull();
orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'";
orders.Indexes.Add(new PgIndex("idx_orders_tenant") { Columns = new[] { "tenant_id" } });
return objects;
}

[Fact]
public async Task incremental_operations_apply_and_round_trip()
{
var options = SampleWeaselSchema.TranslationOptions();

// 1. initial migration through the EF runtime (checked-in generated file)
await using var context = new WeaselSampleDbContext();
await context.Database.MigrateAsync();

// 2. snapshot baseline of the initial model, then change the model
var baseline = EfSchemaSnapshot.FromSchemaObjects(SampleWeaselSchema.Objects(), options);
var target = EfSchemaSnapshot.FromSchemaObjects(modifiedObjects(), options);

var diff = EfSnapshotDiffer.Diff(baseline, target, options);
diff.HasChanges.ShouldBeTrue();

// 3. run the incremental operations through the REAL provider SQL
// generator and execute against the database
await executeOperationsAsync(context, diff.UpOperations);

// 4. Weasel's own delta detection agrees the migrated database matches
// the changed model
foreach (var table in modifiedObjects().OfType<PgTable>())
{
await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString);
await conn.OpenAsync();
var delta = await table.FindDeltaAsync(conn);
delta.HasChanges().ShouldBeFalse(
$"table {table.Identifier} should round-trip after the incremental migration");
}

// 5. and the Down operations take the change back out
await executeOperationsAsync(context, diff.DownOperations);

foreach (var table in SampleWeaselSchema.Objects().OfType<PgTable>())
{
await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString);
await conn.OpenAsync();
var delta = await table.FindDeltaAsync(conn);
delta.HasChanges().ShouldBeFalse(
$"table {table.Identifier} should match the original model after rollback");
}
}

private static async Task executeOperationsAsync(
DbContext context,
IReadOnlyList<Microsoft.EntityFrameworkCore.Migrations.Operations.MigrationOperation> operations)
{
var generator = context.GetService<IMigrationsSqlGenerator>();
var commands = generator.Generate(operations.ToList());

await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString);
await conn.OpenAsync();
foreach (var command in commands)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = command.CommandText;
await cmd.ExecuteNonQueryAsync();
}
}
}
Loading
Loading