diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs index a3ebbb3..c82affe 100644 --- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs +++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs @@ -37,9 +37,8 @@ public override IReadOnlyList GetDifferences(IRelationalMode } // Sort the entire list based on the priority defined in the helper method - allOperations.Sort((op1, op2) => GetOperationPriority(op1).CompareTo(GetOperationPriority(op2))); - - return allOperations; + List sortedOperations = [.. allOperations.OrderBy(GetOperationPriority)]; + return sortedOperations; } /// diff --git a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs new file mode 100644 index 0000000..b1dbe96 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs @@ -0,0 +1,830 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Tests that verify migration operation ordering in TimescaleMigrationsModelDiffer. +/// These tests ensure that operations are generated in the correct dependency order, +/// which is critical for successful migration execution. +/// +/// +/// +/// Issue #15 Background: The original bug was that TimescaleMigrationsModelDiffer used List.Sort() +/// which is an unstable sort and destroyed the correct dependency order from base.GetDifferences(). +/// The fix was to use OrderBy() which is a stable sort that preserves the relative order +/// of elements with equal priority values. +/// +/// +/// Why Unstable Sorts Don't Reliably Fail Tests: +/// List.Sort() is an unstable sort, meaning it does not guarantee preservation of relative order +/// for elements with equal sort keys. However, it doesn't randomly shuffle elements - it uses an +/// efficient algorithm (IntroSort) that may or may not maintain relative order depending on: +/// 1. The specific input data +/// 2. The number of elements +/// 3. The distribution of priorities +/// +/// +/// In practice, for small lists (like in these tests), List.Sort() often appears stable even though +/// it's not guaranteed to be. The bug was intermittent in production because: +/// - EF Core orders CreateTable before CreateIndex (stable order from base differ) +/// - All standard EF Core operations have priority 0 +/// - List.Sort() might preserve their order... or might not +/// - The issue manifested unpredictably, especially with larger/more complex models +/// +/// +/// These tests verify correct behavior but cannot reliably detect the unstable sort bug. +/// They serve as regression tests to ensure OrderBy() is used and the correct ordering is maintained. +/// Manual code review is needed to verify the stable sort is in place. +/// +/// +public class MigrationOperationOrderingTests : MigrationTestBase +{ + #region Should_Order_CreateTable_Before_CreateIndex + + private class OrderingEntity1 + { + public int Id { get; set; } + public DateTime Timestamp { get; set; } + public string? Name { get; set; } + } + + private class OrderingContext1 : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingTable1"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("idx_ordering1_timestamp"); + entity.HasIndex(x => x.Name).HasDatabaseName("idx_ordering1_name"); + }); + } + } + + /// + /// Verifies that CreateTableOperation appears before CreateIndexOperation. + /// This is essential because indexes cannot be created on tables that don't exist yet. + /// + [Fact] + public void Should_Order_CreateTable_Before_CreateIndex() + { + // Arrange + using OrderingContext1 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int createTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingTable1"); + int firstIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering1_timestamp"); + int secondIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering1_name"); + + Assert.NotEqual(-1, createTableIndex); + Assert.NotEqual(-1, firstIndexIndex); + Assert.NotEqual(-1, secondIndexIndex); + Assert.True(createTableIndex < firstIndexIndex, + $"CreateTable (index {createTableIndex}) should appear before first CreateIndex (index {firstIndexIndex})"); + Assert.True(createTableIndex < secondIndexIndex, + $"CreateTable (index {createTableIndex}) should appear before second CreateIndex (index {secondIndexIndex})"); + } + + #endregion + + #region Should_Order_CreateTable_Before_Foreign_Key_Index + + private class OrderingParent2 + { + public int Id { get; set; } + public string? Name { get; set; } + } + + private class OrderingChild2 + { + public int Id { get; set; } + public int ParentId { get; set; } + public OrderingParent2? Parent { get; set; } + } + + private class OrderingContext2 : DbContext + { + public DbSet Parents => Set(); + public DbSet Children => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingParents2"); + entity.HasKey(x => x.Id); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingChildren2"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Parent) + .WithMany() + .HasForeignKey(x => x.ParentId); + }); + } + } + + /// + /// Verifies that CreateTableOperation appears before CreateIndexOperation for foreign key index. + /// EF Core includes foreign keys in the CreateTableOperation, but generates a separate + /// CreateIndexOperation for the foreign key index. + /// + [Fact] + public void Should_Order_CreateTable_Before_Foreign_Key_Index() + { + // Arrange + using OrderingContext2 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int parentTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingParents2"); + int childTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingChildren2"); + int foreignKeyIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && + createIndex.Table == "OrderingChildren2" && + createIndex.Columns.Contains("ParentId")); + + Assert.NotEqual(-1, parentTableIndex); + Assert.NotEqual(-1, childTableIndex); + Assert.NotEqual(-1, foreignKeyIndexIndex); + Assert.True(parentTableIndex < foreignKeyIndexIndex, + $"CreateTable for parent (index {parentTableIndex}) should appear before foreign key index (index {foreignKeyIndexIndex})"); + Assert.True(childTableIndex < foreignKeyIndexIndex, + $"CreateTable for child (index {childTableIndex}) should appear before foreign key index (index {foreignKeyIndexIndex})"); + } + + #endregion + + #region Should_Order_CreateTable_Before_CreateHypertable + + private class OrderingMetric3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderingContext3 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics3"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + /// + /// Verifies that CreateTableOperation appears before CreateHypertableOperation. + /// TimescaleDB requires the table to exist before it can be converted to a hypertable. + /// + [Fact] + public void Should_Order_CreateTable_Before_CreateHypertable() + { + // Arrange + using OrderingContext3 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int createTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingMetrics3"); + int createHypertableIndex = operations.ToList().FindIndex(op => + op is CreateHypertableOperation hypertable && hypertable.TableName == "OrderingMetrics3"); + + Assert.NotEqual(-1, createTableIndex); + Assert.NotEqual(-1, createHypertableIndex); + Assert.True(createTableIndex < createHypertableIndex, + $"CreateTable (index {createTableIndex}) should appear before CreateHypertable (index {createHypertableIndex})"); + } + + #endregion + + #region Should_Order_CreateIndex_Before_AddReorderPolicy + + private class OrderingMetric4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderingContext4 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics4"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("idx_ordering4_timestamp"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("idx_ordering4_timestamp"); + }); + } + } + + /// + /// Verifies that CreateIndexOperation appears before AddReorderPolicyOperation. + /// Reorder policies reference an index, so the index must exist first. + /// + [Fact] + public void Should_Order_CreateIndex_Before_AddReorderPolicy() + { + // Arrange + using OrderingContext4 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int createIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering4_timestamp"); + int addReorderPolicyIndex = operations.ToList().FindIndex(op => + op is AddReorderPolicyOperation policy && policy.IndexName == "idx_ordering4_timestamp"); + + Assert.NotEqual(-1, createIndexIndex); + Assert.NotEqual(-1, addReorderPolicyIndex); + Assert.True(createIndexIndex < addReorderPolicyIndex, + $"CreateIndex (index {createIndexIndex}) should appear before AddReorderPolicy (index {addReorderPolicyIndex})"); + } + + #endregion + + #region Should_Preserve_Order_For_Multiple_Tables_With_Indexes + + private class OrderingEntity5A + { + public int Id { get; set; } + public DateTime Created { get; set; } + public string? Name { get; set; } + } + + private class OrderingEntity5B + { + public int Id { get; set; } + public DateTime Updated { get; set; } + public string? Description { get; set; } + } + + private class OrderingEntity5C + { + public int Id { get; set; } + public DateTime Deleted { get; set; } + public string? Reason { get; set; } + } + + private class OrderingContext5 : DbContext + { + public DbSet EntitiesA => Set(); + public DbSet EntitiesB => Set(); + public DbSet EntitiesC => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingTableA5"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.Created).HasDatabaseName("idx_ordering5a_created"); + entity.HasIndex(x => x.Name).HasDatabaseName("idx_ordering5a_name"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingTableB5"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.Updated).HasDatabaseName("idx_ordering5b_updated"); + entity.HasIndex(x => x.Description).HasDatabaseName("idx_ordering5b_description"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingTableC5"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.Deleted).HasDatabaseName("idx_ordering5c_deleted"); + entity.HasIndex(x => x.Reason).HasDatabaseName("idx_ordering5c_reason"); + }); + } + } + + /// + /// Verifies that when multiple tables have indexes, each table's CreateTableOperation + /// appears before its associated CreateIndexOperations. This test is particularly sensitive + /// to unstable sorts because all standard EF Core operations have priority 0. + /// + [Fact] + public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() + { + // Arrange + using OrderingContext5 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + // Table A and its indexes + int tableAIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingTableA5"); + int indexA1Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5a_created"); + int indexA2Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5a_name"); + + // Table B and its indexes + int tableBIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingTableB5"); + int indexB1Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5b_updated"); + int indexB2Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5b_description"); + + // Table C and its indexes + int tableCIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingTableC5"); + int indexC1Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5c_deleted"); + int indexC2Index = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5c_reason"); + + // Verify all operations were found + Assert.NotEqual(-1, tableAIndex); + Assert.NotEqual(-1, indexA1Index); + Assert.NotEqual(-1, indexA2Index); + Assert.NotEqual(-1, tableBIndex); + Assert.NotEqual(-1, indexB1Index); + Assert.NotEqual(-1, indexB2Index); + Assert.NotEqual(-1, tableCIndex); + Assert.NotEqual(-1, indexC1Index); + Assert.NotEqual(-1, indexC2Index); + + // Verify Table A comes before its indexes + Assert.True(tableAIndex < indexA1Index, + $"TableA (index {tableAIndex}) should appear before its first index (index {indexA1Index})"); + Assert.True(tableAIndex < indexA2Index, + $"TableA (index {tableAIndex}) should appear before its second index (index {indexA2Index})"); + + // Verify Table B comes before its indexes + Assert.True(tableBIndex < indexB1Index, + $"TableB (index {tableBIndex}) should appear before its first index (index {indexB1Index})"); + Assert.True(tableBIndex < indexB2Index, + $"TableB (index {tableBIndex}) should appear before its second index (index {indexB2Index})"); + + // Verify Table C comes before its indexes + Assert.True(tableCIndex < indexC1Index, + $"TableC (index {tableCIndex}) should appear before its first index (index {indexC1Index})"); + Assert.True(tableCIndex < indexC2Index, + $"TableC (index {tableCIndex}) should appear before its second index (index {indexC2Index})"); + } + + #endregion + + #region Should_Order_Complex_Migration_With_All_Operation_Types + + private class OrderingParent6 + { + public int Id { get; set; } + public string? Name { get; set; } + } + + private class OrderingChild6 + { + public int Id { get; set; } + public int ParentId { get; set; } + public OrderingParent6? Parent { get; set; } + } + + private class OrderingMetric6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class OrderingContext6 : DbContext + { + public DbSet Parents => Set(); + public DbSet Children => Set(); + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingParents6"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.Name).HasDatabaseName("idx_ordering6_parent_name"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingChildren6"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Parent) + .WithMany() + .HasForeignKey(x => x.ParentId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics6"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + entity.WithReorderPolicy("idx_ordering6_metric_timestamp"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("idx_ordering6_metric_timestamp"); + entity.HasIndex(x => x.DeviceId).HasDatabaseName("idx_ordering6_metric_device"); + }); + } + } + + /// + /// Verifies correct ordering in a complex migration with multiple operation types: + /// CreateTable, CreateIndex, AddForeignKey, CreateHypertable, and AddReorderPolicy. + /// This comprehensive test ensures the stable sort preserves all necessary dependencies. + /// + [Fact] + public void Should_Order_Complex_Migration_With_All_Operation_Types() + { + // Arrange + using OrderingContext6 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + // Find all operation indices + int parentTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingParents6"); + int childTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingChildren6"); + int metricsTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingMetrics6"); + + int parentIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering6_parent_name"); + int metricsTimestampIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering6_metric_timestamp"); + int metricsDeviceIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering6_metric_device"); + + int foreignKeyIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && + createIndex.Table == "OrderingChildren6" && + createIndex.Columns.Contains("ParentId")); + int hypertableIndex = operations.ToList().FindIndex(op => + op is CreateHypertableOperation hypertable && hypertable.TableName == "OrderingMetrics6"); + int reorderPolicyIndex = operations.ToList().FindIndex(op => + op is AddReorderPolicyOperation); + + // Verify all operations were found + Assert.NotEqual(-1, parentTableIndex); + Assert.NotEqual(-1, childTableIndex); + Assert.NotEqual(-1, metricsTableIndex); + Assert.NotEqual(-1, parentIndexIndex); + Assert.NotEqual(-1, metricsTimestampIndexIndex); + Assert.NotEqual(-1, metricsDeviceIndexIndex); + Assert.NotEqual(-1, foreignKeyIndexIndex); + Assert.NotEqual(-1, hypertableIndex); + Assert.NotEqual(-1, reorderPolicyIndex); + + // Verify CreateTable operations come before their dependent operations + Assert.True(parentTableIndex < parentIndexIndex, + "Parent table should be created before its index"); + Assert.True(parentTableIndex < foreignKeyIndexIndex, + "Parent table should be created before foreign key index"); + Assert.True(childTableIndex < foreignKeyIndexIndex, + "Child table should be created before foreign key index"); + + // Verify metrics table dependencies + Assert.True(metricsTableIndex < metricsTimestampIndexIndex, + "Metrics table should be created before its timestamp index"); + Assert.True(metricsTableIndex < metricsDeviceIndexIndex, + "Metrics table should be created before its device index"); + Assert.True(metricsTableIndex < hypertableIndex, + "Metrics table should be created before hypertable operation"); + + // Verify hypertable comes before reorder policy + Assert.True(hypertableIndex < reorderPolicyIndex, + "Hypertable should be created before reorder policy"); + + // Verify index comes before reorder policy + Assert.True(metricsTimestampIndexIndex < reorderPolicyIndex, + "Index should be created before reorder policy that references it"); + } + + #endregion + + #region Should_Preserve_Relative_Order_Of_Standard_Operations + + private class OrderingMaster7 + { + public int Id { get; set; } + public string? Code { get; set; } + } + + private class OrderingDetail7 + { + public int Id { get; set; } + public int MasterId { get; set; } + public OrderingMaster7? Master { get; set; } + } + + private class OrderingContext7 : DbContext + { + public DbSet Masters => Set(); + public DbSet Details => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMasters7"); + entity.HasKey(x => x.Id); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingDetails7"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Master) + .WithMany() + .HasForeignKey(x => x.MasterId); + }); + } + } + + /// + /// Verifies that the stable sort preserves the relative order of standard EF Core operations. + /// All standard operations have priority 0, so using an unstable sort (List.Sort) would + /// scramble their order. This test ensures tables are created before their dependent indexes. + /// + [Fact] + public void Should_Preserve_Relative_Order_Of_Standard_Operations() + { + // Arrange + using OrderingContext7 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int masterTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingMasters7"); + int detailTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingDetails7"); + int foreignKeyIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && + createIndex.Table == "OrderingDetails7" && + createIndex.Columns.Contains("MasterId")); + + Assert.NotEqual(-1, masterTableIndex); + Assert.NotEqual(-1, detailTableIndex); + Assert.NotEqual(-1, foreignKeyIndexIndex); + + // Both tables must be created before the foreign key index + Assert.True(masterTableIndex < foreignKeyIndexIndex, + $"Master table (index {masterTableIndex}) should be created before foreign key index (index {foreignKeyIndexIndex})"); + Assert.True(detailTableIndex < foreignKeyIndexIndex, + $"Detail table (index {detailTableIndex}) should be created before foreign key index (index {foreignKeyIndexIndex})"); + } + + #endregion + + #region Should_Order_Hypertable_And_Indexes_Correctly + + private class OrderingMetric8 + { + public DateTime Timestamp { get; set; } + public double Temperature { get; set; } + public double Humidity { get; set; } + public int SensorId { get; set; } + } + + private class OrderingContext8 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics8"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour"); + entity.HasIndex(x => x.Temperature).HasDatabaseName("idx_ordering8_temperature"); + entity.HasIndex(x => x.Humidity).HasDatabaseName("idx_ordering8_humidity"); + entity.HasIndex(x => x.SensorId).HasDatabaseName("idx_ordering8_sensor"); + }); + } + } + + /// + /// Verifies that in a hypertable with multiple indexes: + /// 1. CreateTable comes first + /// 2. CreateHypertable comes after table creation + /// 3. All CreateIndex operations come after table creation + /// + [Fact] + public void Should_Order_Hypertable_And_Indexes_Correctly() + { + // Arrange + using OrderingContext8 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int tableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingMetrics8"); + int hypertableIndex = operations.ToList().FindIndex(op => + op is CreateHypertableOperation hypertable && hypertable.TableName == "OrderingMetrics8"); + int tempIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering8_temperature"); + int humidityIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering8_humidity"); + int sensorIndexIndex = operations.ToList().FindIndex(op => + op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering8_sensor"); + + Assert.NotEqual(-1, tableIndex); + Assert.NotEqual(-1, hypertableIndex); + Assert.NotEqual(-1, tempIndexIndex); + Assert.NotEqual(-1, humidityIndexIndex); + Assert.NotEqual(-1, sensorIndexIndex); + + // Table must come before hypertable + Assert.True(tableIndex < hypertableIndex, + $"Table (index {tableIndex}) should be created before hypertable (index {hypertableIndex})"); + + // Table must come before all indexes + Assert.True(tableIndex < tempIndexIndex, + $"Table (index {tableIndex}) should be created before temperature index (index {tempIndexIndex})"); + Assert.True(tableIndex < humidityIndexIndex, + $"Table (index {tableIndex}) should be created before humidity index (index {humidityIndexIndex})"); + Assert.True(tableIndex < sensorIndexIndex, + $"Table (index {tableIndex}) should be created before sensor index (index {sensorIndexIndex})"); + } + + #endregion + + #region Should_Fail_With_Unstable_Sort_Many_Tables + + // Many entity classes to generate enough operations to trigger unstable sort behavior + private class LargeEntity01 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity02 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity03 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity04 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity05 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity06 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity07 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity08 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity09 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + private class LargeEntity10 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } + + private class LargeModelContext : DbContext + { + public DbSet Entities01 => Set(); + public DbSet Entities02 => Set(); + public DbSet Entities03 => Set(); + public DbSet Entities04 => Set(); + public DbSet Entities05 => Set(); + public DbSet Entities06 => Set(); + public DbSet Entities07 => Set(); + public DbSet Entities08 => Set(); + public DbSet Entities09 => Set(); + public DbSet Entities10 => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Configure 10 tables, each with 2 indexes = 30 operations (well above 16 threshold) + ConfigureEntity(modelBuilder, "LargeTable01"); + ConfigureEntity(modelBuilder, "LargeTable02"); + ConfigureEntity(modelBuilder, "LargeTable03"); + ConfigureEntity(modelBuilder, "LargeTable04"); + ConfigureEntity(modelBuilder, "LargeTable05"); + ConfigureEntity(modelBuilder, "LargeTable06"); + ConfigureEntity(modelBuilder, "LargeTable07"); + ConfigureEntity(modelBuilder, "LargeTable08"); + ConfigureEntity(modelBuilder, "LargeTable09"); + ConfigureEntity(modelBuilder, "LargeTable10"); + } + + private static void ConfigureEntity(ModelBuilder modelBuilder, string tableName) where T : class + { + modelBuilder.Entity(entity => + { + entity.ToTable(tableName); + entity.HasKey("Id"); + entity.HasIndex("Field1").HasDatabaseName($"idx_{tableName.ToLower()}_field1"); + entity.HasIndex("Field2").HasDatabaseName($"idx_{tableName.ToLower()}_field2"); + }); + } + } + + /// + /// This test uses a large model (10 tables with 2 indexes each = 30+ operations) to trigger + /// the unstable sort behavior in List.Sort(). IntroSort uses InsertionSort for lists under 16 elements, + /// which is stable. For larger lists, it uses QuickSort which is unstable. + /// This test should FAIL when using Sort() and PASS when using OrderBy(). + /// + [Fact] + public void Should_Maintain_Order_With_Many_Tables_And_Indexes() + { + // Arrange + using LargeModelContext context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert - verify each table's CreateTable comes before its indexes + List opsList = [.. operations]; + + for (int i = 1; i <= 10; i++) + { + string tableName = $"LargeTable{i:D2}"; + string idx1Name = $"idx_largetable{i:D2}_field1"; + string idx2Name = $"idx_largetable{i:D2}_field2"; + + int tableIndex = opsList.FindIndex(op => + op is CreateTableOperation ct && ct.Name == tableName); + int index1Index = opsList.FindIndex(op => + op is CreateIndexOperation ci && ci.Name == idx1Name); + int index2Index = opsList.FindIndex(op => + op is CreateIndexOperation ci && ci.Name == idx2Name); + + Assert.NotEqual(-1, tableIndex); + Assert.NotEqual(-1, index1Index); + Assert.NotEqual(-1, index2Index); + + Assert.True(tableIndex < index1Index, + $"{tableName} (pos {tableIndex}) must come before {idx1Name} (pos {index1Index})"); + Assert.True(tableIndex < index2Index, + $"{tableName} (pos {tableIndex}) must come before {idx2Name} (pos {index2Index})"); + } + } + + #endregion +}