diff --git a/Directory.Packages.props b/Directory.Packages.props index 00d7fcfd7a..c677d54663 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,9 +13,9 @@ - - - + + + @@ -60,8 +60,8 @@ - - + + diff --git a/src/CoreTests/Bugs/Bug_4224_unique_index_on_mixed_case_table.cs b/src/CoreTests/Bugs/Bug_4224_unique_index_on_mixed_case_table.cs new file mode 100644 index 0000000000..09516b998d --- /dev/null +++ b/src/CoreTests/Bugs/Bug_4224_unique_index_on_mixed_case_table.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using JasperFx; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Projections.Flattened; +using Marten.Testing.Harness; +using Weasel.Core; +using Weasel.Postgresql.Tables; +using Xunit; + +namespace CoreTests.Bugs; + +/// +/// Reproduces https://github.com/JasperFx/weasel/issues/224 +/// A FlatTableProjection with a mixed-case table name and a unique index +/// should not fail on the second migration run with "relation already exists". +/// +public class Bug_4224_unique_index_on_mixed_case_table : BugIntegrationContext +{ + [Fact] + public async Task should_not_fail_on_second_migration_with_unique_index() + { + // Matches the user's exact scenario from the issue + StoreOptions(options => + { + options.DatabaseSchemaName = "bug_4224_marten"; + options.AutoCreateSchemaObjects = AutoCreate.CreateOrUpdate; + + options.Projections.Add(ProjectionLifecycle.Inline); + }); + + // First migration - creates schema, table, and index + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + // Second migration - should detect existing index and not try to recreate it + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + } +} + +public class TestProjector : FlatTableProjection +{ + public TestProjector() + : base(new DbObjectName("bug_4224", "TestRecords")) + { + Table.AddColumn("id").AsPrimaryKey(); + Table.AddColumn("fake_id"); + Table.AddColumn("year"); + Table.AddColumn("month"); + + Table.Indexes.Add(new IndexDefinition("ix_test_records_unique") + { + Columns = ["fake_id", "year", "month"], + IsUnique = true + }); + + // Need at least one event handler for validation + Project(map => + { + map.Map(x => x.FakeId, "fake_id"); + map.Map(x => x.Year, "year"); + map.Map(x => x.Month, "month"); + }); + } +} + +public class TestEvent +{ + public Guid FakeId { get; set; } + public int Year { get; set; } + public int Month { get; set; } +} diff --git a/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs b/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs new file mode 100644 index 0000000000..df6351087a --- /dev/null +++ b/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Projections; + +/// +/// Document type used in an EventProjection but NOT explicitly registered with Marten. +/// The source generator should discover this type from Store/Insert calls in ApplyAsync +/// and register it automatically. +/// See https://github.com/JasperFx/marten/issues/4166 +/// +public class AuditRecord +{ + public Guid Id { get; set; } + public Guid StreamId { get; set; } + public string EventType { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } +} + +public class AuditableEvent +{ + public string Description { get; set; } = string.Empty; +} + +/// +/// An EventProjection with an explicit ApplyAsync override that stores a document type +/// using operations.Store<T>(). The source generator should detect this and emit a +/// constructor that registers AuditRecord as a published type. +/// See https://github.com/JasperFx/marten/issues/4166 +/// +public partial class AuditRecordProjection : EventProjection +{ + public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) + { + switch (e.Data) + { + case AuditableEvent: + operations.Store(new AuditRecord + { + Id = Guid.NewGuid(), + StreamId = e.StreamId, + EventType = e.Data.GetType().Name, + Timestamp = e.Timestamp + }); + break; + } + + return new ValueTask(); + } +} + +/// +/// An EventProjection with conventional Create method that returns a document type. +/// The source generator should register this type via the emitted constructor. +/// +public partial class AuditRecordCreatorProjection : EventProjection +{ + public AuditRecord Create(AuditableEvent e) => new AuditRecord + { + Id = Guid.NewGuid(), + EventType = nameof(AuditableEvent) + }; +} + +public class event_projection_should_register_document_types : OneOffConfigurationsContext +{ + [Fact] + public void explicit_apply_async_projection_should_register_document_types() + { + // Issue #4166: Document types used in operations.Store() inside an explicit + // ApplyAsync override should be automatically discovered and registered. + StoreOptions(opts => + { + opts.Projections.Add(new AuditRecordProjection(), ProjectionLifecycle.Inline); + }); + + var documentTypes = theStore.StorageFeatures.AllDocumentMappings + .Select(x => x.DocumentType).ToList(); + + documentTypes.ShouldContain(typeof(AuditRecord), + "AuditRecord should be auto-discovered from operations.Store() in ApplyAsync"); + } + + [Fact] + public void conventional_create_projection_should_register_document_types() + { + // EventProjection with Create method should also register the return type. + StoreOptions(opts => + { + opts.Projections.Add(new AuditRecordCreatorProjection(), ProjectionLifecycle.Inline); + }); + + var documentTypes = theStore.StorageFeatures.AllDocumentMappings + .Select(x => x.DocumentType).ToList(); + + documentTypes.ShouldContain(typeof(AuditRecord), + "AuditRecord should be auto-discovered from Create method return type"); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreSchemaTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreSchemaTests.cs index 9becfdb2a4..3a55fd4ae2 100644 --- a/src/Marten.EntityFrameworkCore.Tests/EfCoreSchemaTests.cs +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreSchemaTests.cs @@ -1,8 +1,12 @@ using System; using System.Linq; +using System.Threading.Tasks; +using JasperFx; using Marten.Testing.Harness; using Microsoft.EntityFrameworkCore; +using Npgsql; using Shouldly; +using Weasel.EntityFrameworkCore; using Weasel.Postgresql.Tables; using Xunit; @@ -66,6 +70,40 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// +/// DbContext with no explicit schema - these entities should be moved to the +/// Marten document store schema, including their FK references. +/// See https://github.com/JasperFx/marten/issues/4192 +/// +public class NoEfSchemaDbContext : DbContext +{ + public NoEfSchemaDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Entities => Set(); + public DbSet EntityTypes => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("entity_type"); + entity.HasKey(e => e.Id); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("entity"); + entity.HasKey(e => e.Id); + entity.HasOne(e => e.EntityType) + .WithMany() + .HasForeignKey(e => e.EntityTypeId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} + public class EfCoreSchemaTests { [Fact] @@ -126,4 +164,133 @@ public void should_move_tables_without_explicit_schema_to_marten_schema() orderSummariesTable.Identifier.Schema.ShouldBe(martenSchema, "Tables without explicit schema should be moved to Marten's schema"); } + + [Fact] + public void should_return_entity_types_in_fk_dependency_order() + { + // Issue #4180: GetEntityTypesForMigration should return entity types sorted + // so that referenced tables come before referencing tables. + var builder = new DbContextOptionsBuilder(); + builder.UseNpgsql("Host=localhost"); + + using var dbContext = new SeparateSchemaDbContext(builder.Options); + + var entityTypes = DbContextExtensions.GetEntityTypesForMigration(dbContext); + var names = entityTypes.Select(e => e.GetTableName()).ToList(); + + // entity_type must come before entity because entity has a FK to entity_type + var entityTypeIndex = names.IndexOf("entity_type"); + var entityIndex = names.IndexOf("entity"); + + entityTypeIndex.ShouldBeGreaterThanOrEqualTo(0); + entityIndex.ShouldBeGreaterThanOrEqualTo(0); + entityTypeIndex.ShouldBeLessThan(entityIndex, + "entity_type should come before entity due to FK dependency (issue #4180)"); + } + + [Fact] + public async Task should_apply_schema_with_fk_dependencies_without_error() + { + // Issue #4180: End-to-end test proving that Marten can apply schema changes + // for EF Core entities with FK dependencies without table ordering errors. + const string testSchema = "ef_fk_order_test"; + + // Clean up any previous test schema + await using var cleanupConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await cleanupConn.OpenAsync(); + await using (var cmd = cleanupConn.CreateCommand()) + { + cmd.CommandText = $"DROP SCHEMA IF EXISTS {testSchema} CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + await cleanupConn.CloseAsync(); + + try + { + await using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = testSchema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + + opts.AddEntityTablesFromDbContext(b => + { + b.UseNpgsql("Host=localhost"); + }); + }); + + // This triggers schema creation - should not throw due to FK ordering + await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + // Verify tables were created by querying the information schema + await using var verifyConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await verifyConn.OpenAsync(); + await using var verifyCmd = verifyConn.CreateCommand(); + verifyCmd.CommandText = @" + SELECT table_name FROM information_schema.tables + WHERE table_schema = @schema + ORDER BY table_name"; + verifyCmd.Parameters.AddWithValue("schema", SeparateSchemaDbContext.EfSchema); + + var tables = new System.Collections.Generic.List(); + await using var reader = await verifyCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tables.Add(reader.GetString(0)); + } + + tables.ShouldContain("entity"); + tables.ShouldContain("entity_type"); + } + finally + { + // Clean up + await using var finalConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await finalConn.OpenAsync(); + await using var finalCmd = finalConn.CreateCommand(); + finalCmd.CommandText = $"DROP SCHEMA IF EXISTS {testSchema} CASCADE"; + await finalCmd.ExecuteNonQueryAsync(); + finalCmd.CommandText = $"DROP SCHEMA IF EXISTS {SeparateSchemaDbContext.EfSchema} CASCADE"; + await finalCmd.ExecuteNonQueryAsync(); + } + } + + [Fact] + public void should_move_tables_and_fk_without_explicit_schema_to_marten_schema() + { + // Issue #4192: When EF Core entities do NOT have an explicit schema, + // tables AND their FK references should be moved to Marten's schema. + const string martenSchema = "test_marten_schema"; + + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = martenSchema; + + // NoEfSchemaDbContext does not set an explicit schema + opts.AddEntityTablesFromDbContext(); + }); + + var extendedObjects = store.Options.Storage.ExtendedSchemaObjects; + + var entityTable = extendedObjects.OfType() + .FirstOrDefault(t => t.Identifier.Name == "entity"); + var entityTypeTable = extendedObjects.OfType
() + .FirstOrDefault(t => t.Identifier.Name == "entity_type"); + + entityTable.ShouldNotBeNull(); + entityTypeTable.ShouldNotBeNull(); + + // These tables should be moved to Marten's schema + entityTable.Identifier.Schema.ShouldBe(martenSchema, + "Entity table should be moved to Marten's schema"); + entityTypeTable.Identifier.Schema.ShouldBe(martenSchema, + "EntityType table should be moved to Marten's schema"); + + // FK references should also point to Marten's schema + var entityFk = entityTable.ForeignKeys.FirstOrDefault(); + entityFk.ShouldNotBeNull("Entity should have a FK to EntityType"); + entityFk.LinkedTable!.Schema.ShouldBe(martenSchema, + "Foreign keys should also be updated to reference the correct schema"); + } }