diff --git a/.gitignore b/.gitignore index 074688b..0b4ca51 100644 --- a/.gitignore +++ b/.gitignore @@ -428,10 +428,10 @@ FodyWeavers.xsd *.msp # Ignore all generated migration files -CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess/Migrations/ +samples/Eftdb.Samples.Shared/Migrations/ # Ignore all scaffolded models and the DbContext from the DbFirst project -CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.DbFirst/ +samples/Eftdb.Samples.DatabaseFirst/**/*.cs # AI CLAUDE.md diff --git a/README.md b/README.md index c155c61..0db0205 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Seamlessly define and manage **TimescaleDB hypertables** using standard EF Core - **Time Partitioning**: Easily specify the primary time column and set the `chunk_time_interval`. - **Space Partitioning**: Add additional dimensions for hash or range partitioning to further optimize queries. - **Chunk Time Interval**: Configure chunk intervals to balance performance and storage efficiency. +- **Data Migration**: Control whether existing data should be migrated when converting a regular table to a hypertable using `migrate_data`. - **Compression & Chunk Skipping**: Enable TimescaleDB's native compression and configure chunk skipping to improve query performance. ### Reorder Policies @@ -97,7 +98,9 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration // Optional: Enable chunk skipping for faster queries on this column. .WithChunkSkipping(x => x.Time) // Optional: Set the chunk interval. Can be a string ("7 days") or long (microseconds). - .WithChunkTimeInterval("86400000"); + .WithChunkTimeInterval("86400000") + // Optional: Migrate existing data when converting to hypertable (defaults to false). + .WithMigrateData(true); } } ``` @@ -108,7 +111,10 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration For simpler configurations, you can use the [Hypertable] attribute directly on your model class. ```csharp -[Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "86400000")] +[Hypertable(nameof(Time), + ChunkSkipColumns = new[] { "Time" }, + ChunkTimeInterval = "86400000", + MigrateData = true)] [PrimaryKey(nameof(Id), nameof(Time))] public class DeviceReading { diff --git a/samples/Eftdb.Samples.CodeFirst/README.md b/samples/Eftdb.Samples.CodeFirst/README.md index ed3990b..7f6afba 100644 --- a/samples/Eftdb.Samples.CodeFirst/README.md +++ b/samples/Eftdb.Samples.CodeFirst/README.md @@ -1,69 +1,78 @@ -๏ปฟ# EF Core Code-First Example with TimescaleDB +# EF Core Code-First Example with TimescaleDB This project demonstrates how to use the **Code-First** approach with [TimescaleDB](https://www.timescale.com/) using the `CmdScale.EntityFrameworkCore.TimescaleDB` package. --- -## ๐Ÿš€ Migrations and Database Management +## Migrations and Database Management Use the following commands to manage your EF Core migrations and database updates. -### ๐Ÿ“Œ Add a new migration +> **Note:** Run all commands from the repository root directory. + +### Add a new migration ```bash -dotnet ef migrations add --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example +dotnet ef migrations add --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst ``` -### โœ… Apply migrations to the database +### Apply migrations to the database ```bash -dotnet ef database update --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example +dotnet ef database update --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst ``` -### โŒ Remove last migration (if not applied to the database, yet) -``` - dotnet ef migrations remove --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example +### Remove last migration (if not applied to the database yet) + +```bash +dotnet ef migrations remove --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst ``` -### ๐Ÿงน Reset all migrations (rollback to initial state) +### Reset all migrations (rollback to initial state) ```bash -dotnet ef database update 0 --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example +dotnet ef database update 0 --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst ``` --- -## ๐Ÿ“ Project Structure +## Project Structure ```text -CmdScale.EntityFrameworkCore.TimescaleDB.Example/ -โ”‚ -โ”œโ”€โ”€ CmdScale.EntityFrameworkCore.TimescaleDB.Example/ # Startup project -โ”œโ”€โ”€ CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess/ # Contains DbContext and migrations -โ””โ”€โ”€ docker-compose.yml # Sets up TimescaleDB container (in Solution Items) +samples/ +โ”œโ”€โ”€ Eftdb.Samples.CodeFirst/ # Startup project (contains Program.cs and design-time services) +โ”œโ”€โ”€ Eftdb.Samples.Shared/ # Contains DbContext, entities, and migrations +โ””โ”€โ”€ Eftdb.Samples.DatabaseFirst/ # Database-first scaffolding example ``` --- -## ๐Ÿณ Docker -- This project assumes you have an existing TimescaleDB-compatible PostgreSQL database. -- A `docker-compose.yml` file is included in the **Solution Items** folder to spin up a TimescaleDB container for local development and testing. +## Docker + +This project assumes you have an existing TimescaleDB-compatible PostgreSQL database. A `docker-compose.yml` file is included in the repository root to spin up a TimescaleDB container for local development and testing. - To start the container, run: +To start the container, run from the repository root: - ```bash - docker-compose up -d - ``` +```bash +docker-compose up -d +``` + +To stop and reset the database: + +```bash +docker-compose down -v +``` -- Connection string settings should match the configuration in your `docker-compose.yml`. +Connection string settings should match the configuration in your `docker-compose.yml`. --- -## ๐Ÿง  Notes -- Depending on if you're using project- or package-references you might to (un)comment adding the services in `TimescaleDBDesignTimeService.cs` +## Notes +- Depending on if you're using project- or package-references you might need to (un)comment adding the services in `TimescaleDBDesignTimeService.cs` +- The `Eftdb.Samples.Shared` project contains the `TimescaleContext` and entity models shared between samples -## ๐Ÿ“š Resources +## Resources - [Entity Framework Core Documentation](https://learn.microsoft.com/en-us/ef/core/) - [TimescaleDB Documentation](https://docs.timescale.com/) diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index dd6160c..423126b 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -17,7 +17,7 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null; ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null; - List statements = []; + List statements; bool suppressTransaction = false; switch (operation) @@ -60,7 +60,14 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui default: base.Generate(operation, builder); - break; + return; + } + + // Guard: if no statements were generated, output a no-op SQL comment to maintain valid C# syntax. + if (statements.Count == 0) + { + builder.Append(".Sql(@\"-- No SQL generated for this operation\")"); + return; } SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction); diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs index 86fbabe..de65d3e 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs @@ -8,6 +8,7 @@ public static class HypertableAnnotations public const string IsHypertable = "TimescaleDB:IsHypertable"; public const string HypertableTimeColumn = "TimescaleDB:TimeColumnName"; public const string EnableCompression = "TimescaleDB:EnableCompression"; + public const string MigrateData = "TimescaleDB:MigrateData"; public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval"; public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns"; public const string AdditionalDimensions = "TimescaleDB:AdditionalDimensions"; diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs index e3c1ef8..1d49e3e 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs @@ -10,10 +10,15 @@ public sealed class HypertableAttribute : Attribute public string TimeColumnName { get; } = string.Empty; /// - /// + /// Specifies whether compression is enabled on the hypertable. /// public bool EnableCompression { get; set; } = false; + /// + /// Specifies whether existing data should be migrated when converting a table to a hypertable. + /// + public bool MigrateData { get; set; } = false; + /// /// Defines the duration of time covered by each chunk in a hypertable. /// diff --git a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs index 6628a91..aa670c5 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs @@ -37,6 +37,11 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); } + if (attribute.MigrateData == true) + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, true); + } + if (attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0) { /// Chunk skipping requires compression to be enabled diff --git a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs index fa5cf7a..9f6d781 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs @@ -128,6 +128,25 @@ public static EntityTypeBuilder EnableCompression( return entityTypeBuilder; } + /// + /// Specifies whether existing data should be migrated when converting a table to a hypertable. + /// + /// + /// When converting an existing table to a hypertable, this parameter controls whether existing data + /// is migrated into chunks. If set to false, only new data will be stored in chunks. + /// Defaults to false to match TimescaleDB's default behavior. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// A boolean indicating whether to migrate existing data. Defaults to true. + public static EntityTypeBuilder WithMigrateData( + this EntityTypeBuilder entityTypeBuilder, + bool migrateData = true) where TEntity : class + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, migrateData); + return entityTypeBuilder; + } + /// /// Extracts the property name from a member access lambda expression. /// diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index f4b765e..d3004f0 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -24,9 +24,11 @@ public List Generate(CreateHypertableOperation operation) string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema); + string migrateDataParam = operation.MigrateData ? ", migrate_data => true" : ""; + List statements = [ - $"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}');" + $"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'{migrateDataParam});" ]; // ChunkTimeInterval diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs index 782cf34..a1eaa15 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs @@ -76,6 +76,7 @@ public static IEnumerable GetHypertables(IRelationalM string chunkTimeInterval = entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value as string ?? DefaultValues.ChunkTimeInterval; bool enableCompression = entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value as bool? ?? false; + bool migrateData = entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value as bool? ?? false; yield return new CreateHypertableOperation { @@ -84,6 +85,7 @@ public static IEnumerable GetHypertables(IRelationalM TimeColumnName = timeColumnName, ChunkTimeInterval = chunkTimeInterval ?? DefaultValues.ChunkTimeInterval, EnableCompression = enableCompression, + MigrateData = migrateData, ChunkSkipColumns = chunkSkipColumns, AdditionalDimensions = additionalDimensions }; diff --git a/src/Eftdb/Operations/CreateHypertableOperation.cs b/src/Eftdb/Operations/CreateHypertableOperation.cs index 30850d4..95fb84d 100644 --- a/src/Eftdb/Operations/CreateHypertableOperation.cs +++ b/src/Eftdb/Operations/CreateHypertableOperation.cs @@ -10,6 +10,7 @@ public class CreateHypertableOperation : MigrationOperation public string TimeColumnName { get; set; } = string.Empty; public string ChunkTimeInterval { get; set; } = string.Empty; public bool EnableCompression { get; set; } + public bool MigrateData { get; set; } = false; public IReadOnlyList? ChunkSkipColumns { get; set; } public IReadOnlyList? AdditionalDimensions { get; set; } } diff --git a/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs b/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs index 23c8a97..f079b23 100644 --- a/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs +++ b/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs @@ -169,5 +169,43 @@ public void ChunkSkipColumns_CanBeSetToEmptyArray() Assert.Empty(attr.ChunkSkipColumns); } + [Fact] + public void MigrateData_DefaultsToFalse() + { + // Arrange & Act + HypertableAttribute attr = new("Timestamp"); + + // Assert + Assert.False(attr.MigrateData); + } + + [Fact] + public void MigrateData_CanBeSetToTrue() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + MigrateData = true + }; + + // Assert + Assert.True(attr.MigrateData); + } + + [Fact] + public void MigrateData_CanBeSetToFalse() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + MigrateData = false + }; + + // Assert + Assert.False(attr.MigrateData); + } + #endregion } diff --git a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs index c0348ef..85010c3 100644 --- a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs @@ -595,4 +595,202 @@ public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() } #endregion + + #region Should_Process_Hypertable_With_MigrateData_True + + [Hypertable("Timestamp", MigrateData = true)] + private class MigrateDataTrueEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataTrueContext : 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.HasNoKey(); + entity.ToTable("MigrateDataTrue"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_MigrateData_True() + { + using MigrateDataTrueContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataTrueEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + } + + #endregion + + #region Should_Not_Apply_MigrateData_When_False + + [Hypertable("Timestamp", MigrateData = false)] + private class MigrateDataFalseEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataFalseContext : 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.HasNoKey(); + entity.ToTable("MigrateDataFalse"); + }); + } + } + + [Fact] + public void Should_Not_Apply_MigrateData_When_False() + { + using MigrateDataFalseContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataFalseEntity))!; + + // MigrateData annotation should be null when the attribute property is false + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData)); + // But IsHypertable should still be set + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region Should_Not_Apply_MigrateData_By_Default + + [Hypertable("Timestamp")] + private class MigrateDataDefaultEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataDefaultContext : 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.HasNoKey(); + entity.ToTable("MigrateDataDefault"); + }); + } + } + + [Fact] + public void Should_Not_Apply_MigrateData_By_Default() + { + using MigrateDataDefaultContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataDefaultEntity))!; + + // When MigrateData is not explicitly set in attribute, annotation should be null + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData)); + // But IsHypertable should still be set + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI + + [Hypertable("Timestamp", MigrateData = true)] + private class MigrateDataAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataFluentEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataAttributeContext : 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.HasNoKey(); + entity.ToTable("MigrateDataEquivalence"); + }); + } + } + + private class MigrateDataFluentContext : 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.HasNoKey(); + entity.ToTable("MigrateDataEquivalence"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(true); + }); + } + } + + [Fact] + public void MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI() + { + using MigrateDataAttributeContext attributeContext = new(); + using MigrateDataFluentContext fluentContext = new(); + + IModel attributeModel = GetModel(attributeContext); + IModel fluentModel = GetModel(fluentContext); + + IEntityType attributeEntity = attributeModel.FindEntityType(typeof(MigrateDataAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(MigrateDataFluentEntity))!; + + Assert.Equal( + attributeEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value + ); + Assert.Equal(true, attributeEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Eftdb.Tests.csproj b/tests/Eftdb.Tests/Eftdb.Tests.csproj index af4cf48..59664c9 100644 --- a/tests/Eftdb.Tests/Eftdb.Tests.csproj +++ b/tests/Eftdb.Tests/Eftdb.Tests.csproj @@ -17,10 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index 17731ac..bf49615 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -770,4 +770,170 @@ public void Should_Extract_Fully_Configured_Hypertable() } #endregion + + #region Should_Extract_MigrateData_False_By_Default + + private class DefaultMigrateDataMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMigrateDataContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_MigrateData_False_By_Default() + { + using DefaultMigrateDataContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.False(operations[0].MigrateData); + } + + #endregion + + #region Should_Extract_MigrateData_True + + private class MigrateDataTrueMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataTrueContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(true); + }); + } + } + + [Fact] + public void Should_Extract_MigrateData_True() + { + using MigrateDataTrueContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].MigrateData); + } + + #endregion + + #region Should_Extract_MigrateData_False_When_Explicitly_Set + + private class MigrateDataFalseMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataFalseContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(false); + }); + } + } + + [Fact] + public void Should_Extract_MigrateData_False_When_Explicitly_Set() + { + using MigrateDataFalseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.False(operations[0].MigrateData); + } + + #endregion + + #region Should_Extract_MigrateData_From_Attribute + + [Hypertable("Timestamp", MigrateData = true)] + private class MigrateDataAttributeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataAttributeContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + }); + } + } + + [Fact] + public void Should_Extract_MigrateData_From_Attribute() + { + using MigrateDataAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].MigrateData); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs index 451ba6c..618f72b 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -203,5 +203,112 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress // Assert Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } + + // --- Tests for MigrateData Parameter --- + + [Fact] + public void Generate_Create_When_MigrateData_Is_False_Does_Not_Include_Migrate_Data_Parameter() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "Metrics", + Schema = "public", + TimeColumnName = "Timestamp", + MigrateData = false + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""Metrics""""', 'Timestamp'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Create_When_MigrateData_Is_True_Includes_Migrate_Data_Parameter() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "Metrics", + Schema = "public", + TimeColumnName = "Timestamp", + MigrateData = true + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""Metrics""""', 'Timestamp', migrate_data => true); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Comprehensive_Sql() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "CompleteTable", + Schema = "custom_schema", + TimeColumnName = "EventTime", + MigrateData = true, + ChunkTimeInterval = "1 day", + EnableCompression = true, + ChunkSkipColumns = ["DeviceId"], + AdditionalDimensions = + [ + Dimension.CreateHash("LocationId", 4) + ] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('custom_schema.""""CompleteTable""""', 'EventTime', migrate_data => true); + SELECT set_chunk_time_interval('custom_schema.""""CompleteTable""""', INTERVAL '1 day'); + ALTER TABLE """"custom_schema"""".""""CompleteTable"""" SET (timescaledb.compress = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""""CompleteTable""""', 'DeviceId'); + SELECT add_dimension('custom_schema.""""CompleteTable""""', by_hash('LocationId', 4)); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Create_Default_MigrateData_Does_Not_Include_Parameter() + { + // Arrange - CreateHypertableOperation with default MigrateData (false) + CreateHypertableOperation operation = new() + { + TableName = "DefaultTable", + Schema = "public", + TimeColumnName = "Timestamp" + // MigrateData not explicitly set, defaults to false + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""DefaultTable""""', 'Timestamp'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("migrate_data", result); + } } } diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs new file mode 100644 index 0000000..30ec4e0 --- /dev/null +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -0,0 +1,229 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Moq; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ +#pragma warning disable EF1001 // Internal EF Core API usage. + + /// + /// Tests for TimescaleCSharpMigrationOperationGenerator to ensure proper C# code generation. + /// + public class TimescaleCSharpMigrationOperationGeneratorTests + { + #region Empty Statements Guard Tests + + [Fact] + public void Generate_CreateHypertable_WithValidOperation_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "timestamp", + ChunkTimeInterval = "7 days" + }; + + // Act + // Note: We call the protected method via the public interface indirectly + // by using reflection or by testing via the public Generate method + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("create_hypertable", result); + Assert.Contains(";", result); + Assert.DoesNotContain("migrationBuilder;", result); // This would be invalid C# + } + + [Fact] + public void Generate_CreateHypertable_WithMigrateData_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "timestamp", + ChunkTimeInterval = "7 days", + MigrateData = true + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrate_data => true", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterHypertable_WithNoChanges_GeneratesValidCSharpOrNoOp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + // An alter operation with no actual changes should still generate valid C# + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + + // The result should either be empty (no operation generated) or contain valid C# + // It should NEVER contain just "migrationBuilder;" without a method call + if (!string.IsNullOrWhiteSpace(result)) + { + Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); + // If there's content, it should have a proper method call + if (result.Contains("migrationBuilder")) + { + Assert.Contains(".Sql(@\"", result); + } + } + } + + [Fact] + public void Generate_AddReorderPolicy_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + IndexName = "sensor_data_time_idx" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("add_reorder_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_DropReorderPolicy_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + DropReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("remove_reorder_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_CreateContinuousAggregate_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ParentName = "sensor_data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["COUNT(*)"] + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("CREATE MATERIALIZED VIEW", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_DropContinuousAggregate_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("DROP MATERIALIZED VIEW", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + #endregion + + #region Helper Methods + + private static CSharpMigrationOperationGeneratorDependencies CreateDependencies() + { + Mock mockCSharpHelper = new(); + return new CSharpMigrationOperationGeneratorDependencies(mockCSharpHelper.Object); + } + + #endregion + } + +#pragma warning restore EF1001 // Internal EF Core API usage. +} diff --git a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs new file mode 100644 index 0000000..b508a40 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs @@ -0,0 +1,422 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class HypertableMigrateDataIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task IsHypertableAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task GetRowCountAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\";"; + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is long longResult ? (int)longResult : + result is int intResult ? intResult : 0; + } + + #endregion + + #region Should_Generate_Migration_SQL_With_MigrateData_True_FluentAPI + + private class MigrateDataFluentApiEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataFluentApiContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("MigrateDataFluentApi"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(true); // <-- Configure MigrateData via Fluent API + }); + } + } + + [Fact] + public void Should_Generate_Migration_SQL_With_MigrateData_True_FluentAPI() + { + // Arrange + using MigrateDataFluentApiContext context = new(_connectionString!); + + // Act - Generate migration operations + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert - Verify CreateHypertableOperation contains MigrateData = true + CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(hypertableOp); + Assert.Equal("MigrateDataFluentApi", hypertableOp.TableName); + Assert.True(hypertableOp.MigrateData); + + // Act - Generate SQL from operations + IMigrationsSqlGenerator sqlGenerator = context.GetService(); + IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); + + // Assert - Verify SQL contains migrate_data => true + string allSql = string.Join("\n", commands.Select(c => c.CommandText)); + Assert.Contains("migrate_data => true", allSql); + } + + #endregion + + #region Should_Generate_Migration_SQL_With_MigrateData_True_Attribute + + [Hypertable("Timestamp", MigrateData = true)] // <-- Configure MigrateData via Attribute + private class MigrateDataAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataAttributeContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("MigrateDataAttribute"); + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Generate_Migration_SQL_With_MigrateData_True_Attribute() + { + // Arrange + using MigrateDataAttributeContext context = new(_connectionString!); + + // Act - Generate migration operations + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert - Verify CreateHypertableOperation contains MigrateData = true + CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(hypertableOp); + Assert.Equal("MigrateDataAttribute", hypertableOp.TableName); + Assert.True(hypertableOp.MigrateData); + + // Act - Generate SQL from operations + IMigrationsSqlGenerator sqlGenerator = context.GetService(); + IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); + + // Assert - Verify SQL contains migrate_data => true + string allSql = string.Join("\n", commands.Select(c => c.CommandText)); + Assert.Contains("migrate_data => true", allSql); + } + + #endregion + + #region Should_Generate_Migration_SQL_Without_MigrateData_By_Default + + private class DefaultMigrateDataEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMigrateDataContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("DefaultMigrateData"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); // <-- No MigrateData configured + }); + } + } + + [Fact] + public void Should_Generate_Migration_SQL_Without_MigrateData_By_Default() + { + // Arrange + using DefaultMigrateDataContext context = new(_connectionString!); + + // Act - Generate migration operations + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert - Verify CreateHypertableOperation has default MigrateData = false + CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(hypertableOp); + Assert.Equal("DefaultMigrateData", hypertableOp.TableName); + Assert.False(hypertableOp.MigrateData); + + // Act - Generate SQL from operations + IMigrationsSqlGenerator sqlGenerator = context.GetService(); + IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); + + // Assert - Verify SQL does NOT contain migrate_data parameter + string allSql = string.Join("\n", commands.Select(c => c.CommandText)); + Assert.DoesNotContain("migrate_data", allSql); + } + + #endregion + + #region Should_Migrate_Existing_Data_When_Converting_To_Hypertable + + private class ExistingDataEntity + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } + } + + private class InitialRegularTableContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("SensorDataMigration"); + entity.HasNoKey(); + // <-- Not configured as hypertable yet + }); + } + } + + private class ConvertedHypertableContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("SensorDataMigration"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(true); // <-- Changed: Convert to hypertable with data migration + }); + } + } + + [Fact] + public async Task Should_Migrate_Existing_Data_When_Converting_To_Hypertable() + { + // Arrange - Create initial regular table with data + await using InitialRegularTableContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + // Insert test data into regular table + await initialContext.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""SensorDataMigration"" (""Timestamp"", ""DeviceId"", ""Temperature"") + VALUES + ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}), + ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {21.0}), + ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_3"}, {19.5})"); + + // Verify data exists before conversion + int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataMigration"); + Assert.Equal(3, countBeforeConversion); + + // Act - Convert to hypertable with MigrateData = true + await using ConvertedHypertableContext convertedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, convertedContext); + + // Assert - Verify table is now a hypertable + bool isHypertable = await IsHypertableAsync(convertedContext, "SensorDataMigration"); + Assert.True(isHypertable); + + // Assert - Verify existing data was preserved + int countAfterConversion = await GetRowCountAsync(convertedContext, "SensorDataMigration"); + Assert.Equal(3, countAfterConversion); + + // Assert - Verify data can still be queried via EF Core + List data = await convertedContext.SensorData.ToListAsync(); + Assert.Equal(3, data.Count); + Assert.Contains(data, d => d.DeviceId == "device_1" && d.Temperature == 20.5); + Assert.Contains(data, d => d.DeviceId == "device_2" && d.Temperature == 21.0); + Assert.Contains(data, d => d.DeviceId == "device_3" && d.Temperature == 19.5); + } + + #endregion + + #region Should_Apply_MigrateData_False_When_Converting_To_Hypertable + + private class MigrateDataFalseEntity + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } + } + + private class InitialRegularTableMigrateDataFalseContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("SensorDataNoMigration"); + entity.HasNoKey(); + // <-- Not configured as hypertable yet + }); + } + } + + private class ConvertedHypertableMigrateDataFalseContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("SensorDataNoMigration"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(false); // <-- Changed: Explicitly set MigrateData = false + }); + } + } + + [Fact] + public async Task Should_Apply_MigrateData_False_When_Converting_To_Hypertable() + { + // Arrange - Create initial regular table with data + await using InitialRegularTableMigrateDataFalseContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + // Insert test data into regular table + await initialContext.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""SensorDataNoMigration"" (""Timestamp"", ""DeviceId"", ""Temperature"") + VALUES + ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5})"); + + // Verify data exists before conversion + int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataNoMigration"); + Assert.Equal(1, countBeforeConversion); + + // Act - Convert to hypertable with MigrateData = false + await using ConvertedHypertableMigrateDataFalseContext convertedContext = new(_connectionString!); + + // Generate migration operations + IReadOnlyList operations = GenerateMigrationOperations(initialContext, convertedContext); + + // Assert - Verify CreateHypertableOperation has MigrateData = false + CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(hypertableOp); + Assert.False(hypertableOp.MigrateData); + + // Generate SQL from operations + IMigrationsSqlGenerator sqlGenerator = convertedContext.GetService(); + IReadOnlyList commands = sqlGenerator.Generate(operations, convertedContext.Model); + + // Assert - Verify SQL does NOT contain migrate_data parameter + string allSql = string.Join("\n", commands.Select(c => c.CommandText)); + Assert.DoesNotContain("migrate_data", allSql); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs index 309b5da..6a54a85 100644 --- a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -661,4 +661,168 @@ public void FluentAPI_Should_Support_Method_Chaining() } #endregion + + #region WithMigrateData_Should_Set_MigrateData_Annotation_True_By_Default + + private class MigrateDataDefaultEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataDefaultContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(); + }); + } + } + + [Fact] + public void WithMigrateData_Should_Set_MigrateData_Annotation_True_By_Default() + { + using MigrateDataDefaultContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataDefaultEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + } + + #endregion + + #region WithMigrateData_Should_Support_Explicit_True + + private class MigrateDataExplicitTrueEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataExplicitTrueContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(true); + }); + } + } + + [Fact] + public void WithMigrateData_Should_Support_Explicit_True() + { + using MigrateDataExplicitTrueContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataExplicitTrueEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + } + + #endregion + + #region WithMigrateData_Should_Support_Explicit_False + + private class MigrateDataExplicitFalseEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataExplicitFalseContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData(false); + }); + } + } + + [Fact] + public void WithMigrateData_Should_Support_Explicit_False() + { + using MigrateDataExplicitFalseContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataExplicitFalseEntity))!; + + Assert.Equal(false, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + } + + #endregion + + #region WithMigrateData_Should_Support_Method_Chaining + + private class MigrateDataChainingEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MigrateDataChainingContext : 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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithMigrateData() + .WithChunkTimeInterval("1 hour") + .EnableCompression(); + }); + } + } + + [Fact] + public void WithMigrateData_Should_Support_Method_Chaining() + { + using MigrateDataChainingContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MigrateDataChainingEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion }