From 1e01dd7fc4891d85d66704f7c0b9f85b64419603 Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Thu, 27 Nov 2025 23:53:36 +0100 Subject: [PATCH] test: add comprehensive scaffolding and BulkCopy tests Add extensive test coverage for TimescaleDatabaseModelFactory (scaffolding pipeline), BulkCopy extension methods, and additional edge cases for differs, extractors, and generators. Update coverage configuration to exclude service registration files. --- README.md | 9 +- codecov.yml | 30 +- .../Internals/WhereClauseEpressionVisitor.cs | 1 + .../Differs/ContinuousAggregateDifferTests.cs | 208 ++++ .../Differs/HypertableDifferTests.cs | 495 ++++++++ .../Extensions/BulkCopyExtensionsTests.cs | 993 +++++++++++++++ .../ContinuousAggregateModelExtractorTests.cs | 414 +++++++ .../HypertableModelExtractorTests.cs | 175 +++ .../ReorderPolicyModelExtractorTests.cs | 139 +++ ...bleOperationGeneratorComprehensiveTests.cs | 143 +++ ...eCSharpMigrationOperationGeneratorTests.cs | 252 ++++ .../Integration/HypertableIntegrationTests.cs | 90 ++ .../TimescaleDatabaseModelFactoryTests.cs | 1092 +++++++++++++++++ tests/Eftdb.Tests/coverlet.runsettings | 9 +- 14 files changed, 4013 insertions(+), 37 deletions(-) create mode 100644 tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs diff --git a/README.md b/README.md index 4206014..bf3f1a6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ![CmdScale Project](https://github.com/cmdscale/.github/raw/main/profile/assets/CmdShield.svg) [![Test Workflow](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/actions/workflows/run-tests.yml/badge.svg)](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/actions/workflows/run-tests.yml) +[![codecov](https://codecov.io/gh/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/graph/badge.svg?token=YP3YCJLQ41)](https://codecov.io/gh/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB) [![GitHub release (latest by date)](https://img.shields.io/github/v/tag/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB)](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/tags) [![GitHub issues](https://img.shields.io/github/issues/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB)](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/issues) [![GitHub license](https://img.shields.io/github/license/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB)](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/blob/main/LICENSE) @@ -176,14 +177,14 @@ Generate an HTML coverage report using [ReportGenerator](https://github.com/dani # Install ReportGenerator (once) dotnet tool install -g dotnet-reportgenerator-globaltool -# Run tests with coverage collection (output to ./TestResults) -dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults +# Run tests with coverage collection +dotnet test tests/Eftdb.Tests --settings tests/Eftdb.Tests/coverlet.runsettings --collect:"XPlat Code Coverage" # Generate HTML report from coverage files -reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"TestResults/CoverageReport" -reporttypes:Html +reportgenerator -reports:"tests/Eftdb.Tests/TestResults/**/coverage.cobertura.xml" -targetdir:"tests/Eftdb.Tests/TestResults/CoverageReport" -reporttypes:Html ``` -The HTML report will be generated at `TestResults/CoverageReport/index.html`. +The HTML report will be generated at `tests/Eftdb.Tests/TestResults/CoverageReport/index.html`. ### Mutation Testing diff --git a/codecov.yml b/codecov.yml index 977850a..076e35a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,36 +9,8 @@ coverage: target: auto threshold: 2% -component_management: - individual_components: - - component_id: eftdb - name: CmdScale.EntityFrameworkCore.TimescaleDB - paths: - - src/CmdScale.EntityFrameworkCore.TimescaleDB/** - - component_id: eftdb-design - name: CmdScale.EntityFrameworkCore.TimescaleDB.Design - paths: - - src/CmdScale.EntityFrameworkCore.TimescaleDB.Design/** - -flag_management: - individual_flags: - - name: eftdb - paths: - - src/CmdScale.EntityFrameworkCore.TimescaleDB/** - carryforward: true - statuses: - - type: project - target: 80% - - name: eftdb-design - paths: - - src/CmdScale.EntityFrameworkCore.TimescaleDB.Design/** - carryforward: true - statuses: - - type: project - target: 80% - comment: - layout: "header, diff, flags, components, files" + layout: "header, diff, files" behavior: default require_changes: true diff --git a/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs b/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs index 775ba0e..f8f0d45 100644 --- a/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs +++ b/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs @@ -7,6 +7,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals { + // TODO: This is not in use, yet and will need more work to be functional. Therefore, this class will be ignored by code coverage tools. Don't forget to remove the exclusion when you start using it. /// /// A simplified visitor to translate a WHERE clause LambdaExpression into a SQL string. /// This must be used by your IMigrationsModelDiffer, not the SQL generator. diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs index a6738fc..4c2aa24 100644 --- a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs @@ -1880,4 +1880,212 @@ public void Should_Handle_Both_Null_Models() } #endregion + + #region Should_Drop_And_Recreate_When_AggregateFunctions_Count_Differs + + private class MetricEntity22 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate22 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MetricAggregateMultiple22 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public double MaxValue { get; set; } + } + + private class SingleAggregateFunctionContext22 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class MultipleAggregateFunctionsContext22 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_AggregateFunctions_Count_Differs() + { + using SingleAggregateFunctionContext22 sourceContext = new(); + using MultipleAggregateFunctionsContext22 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_GroupByColumns_Count_Differs + + private class MetricEntity23 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + public string? Region { get; set; } + } + + private class MetricAggregateSingleGroupBy23 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregateMultipleGroupBy23 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + public string? Region { get; set; } + } + + private class SingleGroupByColumnContext23 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.Category); + }); + } + } + + private class MultipleGroupByColumnsContext23 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.Category) + .AddGroupByColumn(x => x.Region); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_GroupByColumns_Count_Differs() + { + using SingleGroupByColumnContext23 sourceContext = new(); + using MultipleGroupByColumnsContext23 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index c098521..c2a3f88 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -840,4 +840,499 @@ public void Should_Handle_Both_Null_Models() } #endregion + + #region Should_Detect_Dimension_Type_Change + + private class MetricEntity14 + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class HashDimensionContext14 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + private class RangeDimensionContext14 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("DeviceId", "1000")); + }); + } + } + + [Fact] + public void Should_Detect_Dimension_Type_Change() + { + using HashDimensionContext14 sourceContext = new(); + using RangeDimensionContext14 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.NotNull(alterOp.AdditionalDimensions); + Assert.Equal(EDimensionType.Hash, alterOp.OldAdditionalDimensions![0].Type); + Assert.Equal(EDimensionType.Range, alterOp.AdditionalDimensions![0].Type); + } + + #endregion + + #region Should_Detect_RangeDimension_Added_WithIntegerInterval + + private class MetricEntity14b + { + public DateTime Timestamp { get; set; } + public int SequenceId { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext14b : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class RangeDimensionIntegerContext14b : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("SequenceId", "10000")); + }); + } + } + + [Fact] + public void Should_Detect_RangeDimension_Added_WithIntegerInterval() + { + using BasicHypertableContext14b sourceContext = new(); + using RangeDimensionIntegerContext14b targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.AdditionalDimensions); + Dimension dimension = Assert.Single(alterOp.AdditionalDimensions); + Assert.Equal("SequenceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("10000", dimension.Interval); + } + + #endregion + + #region Should_Detect_RangeDimension_Added_WithTimeInterval + + private class MetricEntity14c + { + public DateTime Timestamp { get; set; } + public DateTime ProcessedTime { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext14c : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class RangeDimensionTimeContext14c : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("ProcessedTime", "1 day")); + }); + } + } + + [Fact] + public void Should_Detect_RangeDimension_Added_WithTimeInterval() + { + using BasicHypertableContext14c sourceContext = new(); + using RangeDimensionTimeContext14c targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.AdditionalDimensions); + Dimension dimension = Assert.Single(alterOp.AdditionalDimensions); + Assert.Equal("ProcessedTime", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("1 day", dimension.Interval); + } + + #endregion + + #region Should_Detect_Dimension_Partitions_Change + + private class MetricEntity15 + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class HashDimension4PartitionsContext15 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + private class HashDimension8PartitionsContext15 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 8)); + }); + } + } + + [Fact] + public void Should_Detect_Dimension_Partitions_Change() + { + using HashDimension4PartitionsContext15 sourceContext = new(); + using HashDimension8PartitionsContext15 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.NotNull(alterOp.AdditionalDimensions); + Assert.Equal(4, alterOp.OldAdditionalDimensions![0].NumberOfPartitions); + Assert.Equal(8, alterOp.AdditionalDimensions![0].NumberOfPartitions); + } + + #endregion + + #region Should_Detect_Dimension_Removed + + private class MetricEntity16 + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class WithDimensionContext16 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + private class WithoutDimensionContext16 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Dimension_Removed() + { + using WithDimensionContext16 sourceContext = new(); + using WithoutDimensionContext16 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.Single(alterOp.OldAdditionalDimensions); + Assert.Null(alterOp.AdditionalDimensions); + } + + #endregion + + #region Should_Detect_Compression_Disabled + + private class MetricEntity17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionEnabledContext17 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(); + }); + } + } + + private class CompressionDisabledContext17 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Compression_Disabled() + { + using CompressionEnabledContext17 sourceContext = new(); + using CompressionDisabledContext17 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.True(alterOp.OldEnableCompression); + Assert.False(alterOp.EnableCompression); + } + + #endregion + + #region Should_Detect_ChunkSkipColumns_Removed + + private class MetricEntity18 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChunkSkippingEnabledContext18 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.Value); + }); + } + } + + private class ChunkSkippingDisabledContext18 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_ChunkSkipColumns_Removed() + { + using ChunkSkippingEnabledContext18 sourceContext = new(); + using ChunkSkippingDisabledContext18 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldChunkSkipColumns); + Assert.Single(alterOp.OldChunkSkipColumns); + Assert.Null(alterOp.ChunkSkipColumns); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs new file mode 100644 index 0000000..49ec497 --- /dev/null +++ b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs @@ -0,0 +1,993 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using NpgsqlTypes; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extensions; + +/// +/// Integration tests for TimescaleDbCopyExtensions bulk copy functionality. +/// These tests verify bulk copy operations with various configurations, data types, and scenarios. +/// +public class BulkCopyExtensionsTests : 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 Should_BulkCopy_With_Default_Config + + private class DefaultConfigEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + } + + private class DefaultConfigContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("DefaultConfigEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired(); + entity.Property(e => e.Timestamp).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Default_Config() + { + // Arrange + using DefaultConfigContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = + [ + new() { Id = 1, Name = "Test1", Timestamp = DateTime.UtcNow }, + new() { Id = 2, Name = "Test2", Timestamp = DateTime.UtcNow.AddHours(1) }, + new() { Id = 3, Name = "Test3", Timestamp = DateTime.UtcNow.AddHours(2) } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(3, count); + + List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(); + Assert.Equal("Test1", inserted[0].Name); + Assert.Equal("Test2", inserted[1].Name); + Assert.Equal("Test3", inserted[2].Name); + } + + #endregion + + #region Should_BulkCopy_With_Custom_Table_Name + + private class CustomTableEntity + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + private class CustomTableContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("MyCustomTable"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Value).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Custom_Table_Name() + { + // Arrange + using CustomTableContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = + [ + new() { Id = 1, Value = "Alpha" }, + new() { Id = 2, Value = "Beta" } + ]; + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("MyCustomTable"); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(2, count); + } + + #endregion + + #region Should_BulkCopy_With_Custom_Workers_And_BatchSize + + private class WorkerConfigEntity + { + public int Id { get; set; } + public string Data { get; set; } = string.Empty; + } + + private class WorkerConfigContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("WorkerConfigEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Data).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Custom_Workers_And_BatchSize() + { + // Arrange + using WorkerConfigContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = []; + for (int i = 1; i <= 100; i++) + { + data.Add(new WorkerConfigEntity { Id = i, Data = $"Data_{i}" }); + } + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("WorkerConfigEntity") + .WithWorkers(8) + .WithBatchSize(25); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(100, count); + } + + #endregion + + #region Should_BulkCopy_With_Manual_Column_Mapping + + private class MappingEntity + { + public int Identifier { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Amount { get; set; } + } + + private class MappingContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("MappingEntity"); + entity.HasKey(e => e.Identifier); + entity.Property(e => e.Description).IsRequired(); + entity.Property(e => e.Amount).HasPrecision(18, 2); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Manual_Column_Mapping() + { + // Arrange + using MappingContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = + [ + new() { Identifier = 1, Description = "Item1", Amount = 100.50m }, + new() { Identifier = 2, Description = "Item2", Amount = 200.75m } + ]; + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("MappingEntity") + .MapColumn("Identifier", e => e.Identifier, NpgsqlDbType.Integer) + .MapColumn("Description", e => e.Description, NpgsqlDbType.Text) + .MapColumn("Amount", e => e.Amount, NpgsqlDbType.Numeric); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(2, count); + + List inserted = await context.Set().OrderBy(e => e.Identifier).ToListAsync(); + Assert.Equal(100.50m, inserted[0].Amount); + Assert.Equal(200.75m, inserted[1].Amount); + } + + #endregion + + #region Should_BulkCopy_Various_Data_Types + + private class DataTypeEntity + { + public int IntValue { get; set; } + public long LongValue { get; set; } + public short ShortValue { get; set; } + public double DoubleValue { get; set; } + public float FloatValue { get; set; } + public decimal DecimalValue { get; set; } + public bool BoolValue { get; set; } + public string StringValue { get; set; } = string.Empty; + public DateTime DateTimeValue { get; set; } + public Guid GuidValue { get; set; } + public byte[] ByteArrayValue { get; set; } = []; + } + + private class DataTypeContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("DataTypeEntity"); + entity.HasKey(e => e.IntValue); + entity.Property(e => e.StringValue).IsRequired(); + entity.Property(e => e.ByteArrayValue).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_Various_Data_Types() + { + // Arrange + using DataTypeContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + Guid testGuid = Guid.NewGuid(); + DateTime testDateTime = DateTime.UtcNow; + byte[] testBytes = [1, 2, 3, 4, 5]; + + List data = + [ + new() + { + IntValue = 42, + LongValue = 9223372036854775807L, + ShortValue = 32767, + DoubleValue = 3.14159, + FloatValue = 2.71828f, + DecimalValue = 123.456m, + BoolValue = true, + StringValue = "Test", + DateTimeValue = testDateTime, + GuidValue = testGuid, + ByteArrayValue = testBytes + } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + DataTypeEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(42, inserted.IntValue); + Assert.Equal(9223372036854775807L, inserted.LongValue); + Assert.Equal(32767, inserted.ShortValue); + Assert.Equal(3.14159, inserted.DoubleValue, 5); + Assert.Equal(2.71828f, inserted.FloatValue, 5); + Assert.Equal(123.456m, inserted.DecimalValue); + Assert.True(inserted.BoolValue); + Assert.Equal("Test", inserted.StringValue); + Assert.Equal(testGuid, inserted.GuidValue); + Assert.Equal(testBytes, inserted.ByteArrayValue); + } + + #endregion + + #region Should_BulkCopy_To_Hypertable + + private class HypertableEntity + { + public DateTime Timestamp { get; set; } + public int SensorId { get; set; } + public double Temperature { get; set; } + public double Humidity { get; set; } + } + + private class HypertableContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("HypertableEntity"); + entity.HasKey(e => new { e.Timestamp, e.SensorId }); + entity.IsHypertable(e => e.Timestamp); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_To_Hypertable() + { + // Arrange + using HypertableContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + DateTime baseTime = DateTime.UtcNow; + List data = + [ + new() { Timestamp = baseTime, SensorId = 1, Temperature = 22.5, Humidity = 45.0 }, + new() { Timestamp = baseTime.AddMinutes(1), SensorId = 1, Temperature = 22.6, Humidity = 45.2 }, + new() { Timestamp = baseTime.AddMinutes(2), SensorId = 2, Temperature = 23.1, Humidity = 46.5 } + ]; + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("HypertableEntity") + .WithWorkers(2) + .WithBatchSize(1000); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(3, count); + + // Verify data integrity + List inserted = await context.Set() + .OrderBy(e => e.Timestamp) + .ThenBy(e => e.SensorId) + .ToListAsync(); + Assert.Equal(22.5, inserted[0].Temperature); + Assert.Equal(45.0, inserted[0].Humidity); + } + + #endregion + + #region Should_BulkCopy_Empty_Collection + + private class EmptyCollectionEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class EmptyCollectionContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("EmptyCollectionEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_Empty_Collection() + { + // Arrange + using EmptyCollectionContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = []; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(0, count); + } + + #endregion + + #region Should_BulkCopy_Single_Item + + private class SingleItemEntity + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + private class SingleItemContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("SingleItemEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Value).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_Single_Item() + { + // Arrange + using SingleItemContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = + [ + new() { Id = 1, Value = "OnlyOne" } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(1, count); + + SingleItemEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal("OnlyOne", inserted.Value); + } + + #endregion + + #region Should_BulkCopy_Large_Dataset + + private class LargeDatasetEntity + { + public int Id { get; set; } + public string Data { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + } + + private class LargeDatasetContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("LargeDatasetEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Data).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_Large_Dataset() + { + // Arrange + using LargeDatasetContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = []; + DateTime baseTime = DateTime.UtcNow; + for (int i = 1; i <= 10000; i++) + { + data.Add(new LargeDatasetEntity + { + Id = i, + Data = $"Record_{i}", + CreatedAt = baseTime.AddSeconds(i) + }); + } + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("LargeDatasetEntity") + .WithWorkers(4) + .WithBatchSize(2500); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(10000, count); + } + + #endregion + + #region Should_BulkCopy_With_Nullable_Types + + private class NullableTypeEntity + { + public int Id { get; set; } + public int? NullableInt { get; set; } + public DateTime? NullableDateTime { get; set; } + public bool? NullableBool { get; set; } + public double? NullableDouble { get; set; } + public string? NullableString { get; set; } + } + + private class NullableTypeContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("NullableTypeEntity"); + entity.HasKey(e => e.Id); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Nullable_Types() + { + // Arrange + using NullableTypeContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + List data = + [ + new() { Id = 1, NullableInt = 42, NullableDateTime = DateTime.UtcNow, NullableBool = true, NullableDouble = 3.14, NullableString = "Test" }, + new() { Id = 2, NullableInt = null, NullableDateTime = null, NullableBool = null, NullableDouble = null, NullableString = null } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(2, count); + + List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(); + + // First record with values + Assert.Equal(42, inserted[0].NullableInt); + Assert.NotNull(inserted[0].NullableDateTime); + Assert.True(inserted[0].NullableBool); + Assert.Equal(3.14, inserted[0].NullableDouble); + Assert.Equal("Test", inserted[0].NullableString); + + // Second record with nulls + Assert.Null(inserted[1].NullableInt); + Assert.Null(inserted[1].NullableDateTime); + Assert.Null(inserted[1].NullableBool); + Assert.Null(inserted[1].NullableDouble); + Assert.Null(inserted[1].NullableString); + } + + #endregion + + #region Should_BulkCopy_With_DateOnly_And_TimeOnly + + private class DateTimeOnlyEntity + { + public int Id { get; set; } + public DateOnly DateValue { get; set; } + public TimeOnly TimeValue { get; set; } + } + + private class DateTimeOnlyContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("DateTimeOnlyEntity"); + entity.HasKey(e => e.Id); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_DateOnly_And_TimeOnly() + { + // Arrange + using DateTimeOnlyContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + DateOnly testDate = new(2024, 3, 15); + TimeOnly testTime = new(14, 30, 45); + + List data = + [ + new() { Id = 1, DateValue = testDate, TimeValue = testTime } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + DateTimeOnlyEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(testDate, inserted.DateValue); + Assert.Equal(testTime, inserted.TimeValue); + } + + #endregion + + #region Should_BulkCopy_With_TimeSpan + + private class TimeSpanEntity + { + public int Id { get; set; } + public TimeSpan Duration { get; set; } + } + + private class TimeSpanContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("TimeSpanEntity"); + entity.HasKey(e => e.Id); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_TimeSpan() + { + // Arrange + using TimeSpanContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + TimeSpan testDuration = new(2, 30, 45); // 2 hours, 30 minutes, 45 seconds + + List data = + [ + new() { Id = 1, Duration = testDuration } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + TimeSpanEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(testDuration, inserted.Duration); + } + + #endregion + + #region Should_BulkCopy_With_Guid + + private class GuidEntity + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class GuidContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("GuidEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Guid() + { + // Arrange + using GuidContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + Guid testGuid = Guid.NewGuid(); + + List data = + [ + new() { Id = testGuid, Name = "GuidTest" } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + GuidEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(testGuid, inserted.Id); + Assert.Equal("GuidTest", inserted.Name); + } + + #endregion + + #region Should_BulkCopy_With_Multiple_Workers_Small_Dataset + + private class MultiWorkerSmallEntity + { + public int Id { get; set; } + public string Data { get; set; } = string.Empty; + } + + private class MultiWorkerSmallContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("MultiWorkerSmallEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Data).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Multiple_Workers_Small_Dataset() + { + // Arrange + using MultiWorkerSmallContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + // Only 3 items but 10 workers - should handle gracefully + List data = + [ + new() { Id = 1, Data = "Item1" }, + new() { Id = 2, Data = "Item2" }, + new() { Id = 3, Data = "Item3" } + ]; + + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("MultiWorkerSmallEntity") + .WithWorkers(10); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + int count = await context.Set().CountAsync(); + Assert.Equal(3, count); + } + + #endregion + + #region Should_BulkCopy_With_Byte_Array + + private class ByteArrayEntity + { + public int Id { get; set; } + public byte[] BinaryData { get; set; } = []; + } + + private class ByteArrayContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ByteArrayEntity"); + entity.HasKey(e => e.Id); + entity.Property(e => e.BinaryData).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_With_Byte_Array() + { + // Arrange + using ByteArrayContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + byte[] testBytes = [0xFF, 0xAA, 0x55, 0x00, 0x11, 0x22, 0x33, 0x44]; + + List data = + [ + new() { Id = 1, BinaryData = testBytes } + ]; + + // Act + await data.BulkCopyAsync(_connectionString!); + + // Assert + ByteArrayEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(testBytes, inserted.BinaryData); + } + + #endregion + + #region Should_BulkCopy_Respecting_Column_Order + + private class ColumnOrderEntity + { + public string Column3 { get; set; } = string.Empty; + public int Column1 { get; set; } + public DateTime Column2 { get; set; } + } + + private class ColumnOrderContext(string connectionString) : DbContext + { + private readonly string _connectionString = connectionString; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString).UseTimescaleDb(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ColumnOrderEntity"); + entity.HasKey(e => e.Column1); + entity.Property(e => e.Column3).IsRequired(); + }); + } + } + + [Fact] + public async Task Should_BulkCopy_Respecting_Column_Order() + { + // Arrange + using ColumnOrderContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + DateTime testTime = DateTime.UtcNow; + + List data = + [ + new() { Column1 = 100, Column2 = testTime, Column3 = "Test" } + ]; + + // Map columns in specific order to match database + TimescaleCopyConfig config = new TimescaleCopyConfig() + .ToTable("ColumnOrderEntity") + .MapColumn("Column1", e => e.Column1, NpgsqlDbType.Integer) + .MapColumn("Column2", e => e.Column2, NpgsqlDbType.TimestampTz) + .MapColumn("Column3", e => e.Column3, NpgsqlDbType.Text); + + // Act + await data.BulkCopyAsync(_connectionString!, config); + + // Assert + ColumnOrderEntity? inserted = await context.Set().FirstOrDefaultAsync(); + Assert.NotNull(inserted); + Assert.Equal(100, inserted.Column1); + Assert.Equal("Test", inserted.Column3); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 1bb9ef4..e04bba4 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -1203,4 +1203,418 @@ public void Should_Extract_Fully_Configured_ContinuousAggregate() } #endregion + + #region Should_Extract_Sum_AggregateFunction + + private class SumAggregateFunctionSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SumAggregateFunctionHourlyMetric + { + public DateTime Bucket { get; set; } + public double TotalValue { get; set; } + } + + private class SumAggregateFunctionContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.TotalValue, x => x.Value, EAggregateFunction.Sum); + }); + } + } + + [Fact] + public void Should_Extract_Sum_AggregateFunction() + { + using SumAggregateFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("TotalValue:Sum:Value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_Count_AggregateFunction + + private class CountAggregateFunctionSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CountAggregateFunctionHourlyMetric + { + public DateTime Bucket { get; set; } + public long RecordCount { get; set; } + } + + private class CountAggregateFunctionContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.RecordCount, x => x.Value, EAggregateFunction.Count); + }); + } + } + + [Fact] + public void Should_Extract_Count_AggregateFunction() + { + using CountAggregateFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("RecordCount:Count:Value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_First_AggregateFunction + + private class FirstAggregateFunctionSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FirstAggregateFunctionHourlyMetric + { + public DateTime Bucket { get; set; } + public double FirstValue { get; set; } + } + + private class FirstAggregateFunctionContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.FirstValue, x => x.Value, EAggregateFunction.First); + }); + } + } + + [Fact] + public void Should_Extract_First_AggregateFunction() + { + using FirstAggregateFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("FirstValue:First:Value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_Last_AggregateFunction + + private class LastAggregateFunctionSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LastAggregateFunctionHourlyMetric + { + public DateTime Bucket { get; set; } + public double LastValue { get; set; } + } + + private class LastAggregateFunctionContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.LastValue, x => x.Value, EAggregateFunction.Last); + }); + } + } + + [Fact] + public void Should_Extract_Last_AggregateFunction() + { + using LastAggregateFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("LastValue:Last:Value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_Custom_Schema + + private class CustomSchemaSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class CustomSchemaContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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", "custom_schema"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ); + }); + } + } + + [Fact] + public void Should_Extract_Custom_Schema() + { + using CustomSchemaContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("custom_schema", operations[0].Schema); + } + + #endregion + + #region Should_Extract_GroupByColumn_With_Explicit_Column_Name + + private class ExplicitGroupByColumnSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ExplicitGroupByColumnHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class ExplicitGroupByColumnContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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.Property(x => x.DeviceId).HasColumnName("device_identifier"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_GroupByColumn_With_Explicit_Column_Name() + { + using ExplicitGroupByColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].GroupByColumns); + Assert.Equal("device_identifier", operations[0].GroupByColumns[0]); + } + + #endregion + + #region Should_Extract_AggregateFunction_With_Explicit_Source_Column_Name + + private class ExplicitSourceColumnSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ExplicitSourceColumnHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ExplicitSourceColumnContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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.Property(x => x.Value).HasColumnName("sensor_value"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Extract_AggregateFunction_With_Explicit_Source_Column_Name() + { + using ExplicitSourceColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("AvgValue:Avg:sensor_value", operations[0].AggregateFunctions[0]); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index bf49615..3c7e5ea 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -936,4 +936,179 @@ public void Should_Extract_MigrateData_From_Attribute() } #endregion + + #region Should_Extract_Custom_Schema + + private class CustomSchemaMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaContext : 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", "custom_schema"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_Custom_Schema() + { + using CustomSchemaContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("custom_schema", operations[0].Schema); + } + + #endregion + + #region Should_Extract_TimeColumn_With_Explicit_Column_Name + + private class ExplicitColumnNameMetric + { + public DateTime CreatedAt { get; set; } + public double Value { get; set; } + } + + private class ExplicitColumnNameContext : 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.Property(x => x.CreatedAt).HasColumnName("timestamp_col"); + entity.IsHypertable(x => x.CreatedAt); + }); + } + } + + [Fact] + public void Should_Extract_TimeColumn_With_Explicit_Column_Name() + { + using ExplicitColumnNameContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("timestamp_col", operations[0].TimeColumnName); + } + + #endregion + + #region Should_Extract_ChunkSkipColumn_With_Explicit_Column_Name + + private class ExplicitChunkSkipColumnMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ExplicitChunkSkipColumnContext : 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.Property(x => x.DeviceId).HasColumnName("device_identifier"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_ChunkSkipColumn_With_Explicit_Column_Name() + { + using ExplicitChunkSkipColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.NotNull(operations[0].ChunkSkipColumns); + string column = Assert.Single(operations[0].ChunkSkipColumns!); + Assert.Equal("device_identifier", column); + } + + #endregion + + #region Should_Extract_Dimension_With_Explicit_Column_Name + + private class ExplicitDimensionColumnMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ExplicitDimensionColumnContext : 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.Property(x => x.DeviceId).HasColumnName("device_identifier"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void Should_Extract_Dimension_With_Explicit_Column_Name() + { + using ExplicitDimensionColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.NotNull(operations[0].AdditionalDimensions); + Dimension dimension = Assert.Single(operations[0].AdditionalDimensions!); + Assert.Equal("device_identifier", dimension.ColumnName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs index 387afd0..8659bf3 100644 --- a/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs @@ -676,4 +676,143 @@ public void Should_Extract_Fully_Configured_ReorderPolicy() } #endregion + + #region Should_Extract_ReorderPolicy_From_Attribute + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_attr_idx")] + private class ReorderPolicyAttributeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderPolicyAttributeContext : 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_ReorderPolicy_From_Attribute() + { + using ReorderPolicyAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + AddReorderPolicyOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("metrics_attr_idx", operation.IndexName); + } + + #endregion + + #region Should_Extract_Fully_Configured_ReorderPolicy_From_Attribute + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_full_attr_idx", + InitialStart = "2025-06-01T00:00:00Z", + ScheduleInterval = "12:00:00", + MaxRuntime = "01:30:00", + MaxRetries = 5, + RetryPeriod = "00:20:00")] + private class FullyConfiguredReorderPolicyAttributeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredReorderPolicyAttributeContext : 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_Fully_Configured_ReorderPolicy_From_Attribute() + { + using FullyConfiguredReorderPolicyAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + AddReorderPolicyOperation operation = operations[0]; + Assert.Equal("metrics_full_attr_idx", operation.IndexName); + Assert.NotNull(operation.InitialStart); + Assert.Equal("12:00:00", operation.ScheduleInterval); + Assert.Equal("01:30:00", operation.MaxRuntime); + Assert.Equal(5, operation.MaxRetries); + Assert.Equal("00:20:00", operation.RetryPeriod); + } + + #endregion + + #region Should_Extract_Custom_Schema + + private class CustomSchemaReorderMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaReorderPolicyContext : 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", "custom_schema"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Extract_Custom_Schema() + { + using CustomSchemaReorderPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("custom_schema", operations[0].Schema); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs index b2df5d4..b1d4040 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -286,6 +286,78 @@ public void Runtime_Create_WithRangeDimension_GeneratesByRangeSyntax() Assert.Contains("add_dimension('public.\"ranged\"', by_range('secondary_time', INTERVAL '30 days'))", result); } + [Fact] + public void Runtime_Create_WithRangeDimension_IntegerInterval_GeneratesNumericByRangeSyntax() + { + // Arrange - Range dimension with integer interval (no INTERVAL keyword) + CreateHypertableOperation operation = new() + { + TableName = "integer_ranged", + Schema = "public", + TimeColumnName = "time", + AdditionalDimensions = + [ + Dimension.CreateRange("sensor_id", "10000") + ] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Integer intervals should NOT use INTERVAL keyword + Assert.Contains("add_dimension('public.\"integer_ranged\"', by_range('sensor_id', 10000))", result); + Assert.DoesNotContain("INTERVAL", result); + } + + [Fact] + public void Runtime_Create_WithRangeDimension_TimeInterval_GeneratesIntervalByRangeSyntax() + { + // Arrange - Range dimension with time-based interval + CreateHypertableOperation operation = new() + { + TableName = "time_ranged", + Schema = "public", + TimeColumnName = "event_time", + AdditionalDimensions = + [ + Dimension.CreateRange("processed_time", "1 hour") + ] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Time-based intervals should use INTERVAL keyword + Assert.Contains("add_dimension('public.\"time_ranged\"', by_range('processed_time', INTERVAL '1 hour'))", result); + } + + [Fact] + public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrectCode() + { + // Arrange - Design-time code for integer range dimension + CreateHypertableOperation operation = new() + { + TableName = "integer_partitions", + Schema = "analytics", + TimeColumnName = "timestamp", + AdditionalDimensions = + [ + Dimension.CreateRange("partition_key", "5000") + ] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('analytics.""""integer_partitions""""', 'timestamp'); + SELECT add_dimension('analytics.""""integer_partitions""""', by_range('partition_key', 5000)); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + #endregion #region AlterHypertableOperation - Design Time Tests @@ -596,6 +668,77 @@ public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() Assert.Contains("enable_chunk_skipping(''public.\"skip_test\"'', ''new_col'')", result); } + [Fact] + public void Runtime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorrectSQL() + { + // Arrange - Adding range dimension with integer interval + AlterHypertableOperation operation = new() + { + TableName = "events", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateRange("event_id", "1000") + ], + OldAdditionalDimensions = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Integer range should not use INTERVAL keyword + Assert.Contains("add_dimension('public.\"events\"', by_range('event_id', 1000))", result); + Assert.DoesNotContain("INTERVAL", result); + } + + [Fact] + public void Runtime_Alter_AddingRangeDimension_WithTimeInterval_GeneratesCorrectSQL() + { + // Arrange - Adding range dimension with time-based interval + AlterHypertableOperation operation = new() + { + TableName = "logs", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateRange("ingestion_time", "2 hours") + ], + OldAdditionalDimensions = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Time-based range should use INTERVAL keyword + Assert.Contains("add_dimension('public.\"logs\"', by_range('ingestion_time', INTERVAL '2 hours'))", result); + } + + [Fact] + public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorrectCode() + { + // Arrange - Design-time code for adding integer range dimension + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "analytics", + AdditionalDimensions = + [ + Dimension.CreateRange("metric_id", "50000") + ], + OldAdditionalDimensions = [] + }; + + string expected = @".Sql(@"" + SELECT add_dimension('analytics.""""metrics""""', by_range('metric_id', 50000)); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + #endregion #region TimescaleDB Constraint Validation Tests diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index 30ec4e0..445b06a 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -212,6 +212,258 @@ public void Generate_DropContinuousAggregate_GeneratesValidCSharp() Assert.DoesNotContain("migrationBuilder;", result); } + [Fact] + public void Generate_AlterReorderPolicy_WithIndexChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + IndexName = "new_index", + OldIndexName = "old_index", + InitialStart = DateTime.UtcNow, + OldInitialStart = DateTime.UtcNow.AddDays(-1) + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + // When index changes, policy is dropped and recreated + Assert.Contains("remove_reorder_policy", result); + Assert.Contains("add_reorder_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterReorderPolicy_WithScheduleIntervalChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + IndexName = "sensor_data_idx", + OldIndexName = "sensor_data_idx", // Same index name + InitialStart = null, + OldInitialStart = null, // Same initial start + ScheduleInterval = "1 day", + OldScheduleInterval = "4 days" // Different schedule interval + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + // When only schedule changes, uses alter_job + Assert.Contains("alter_job", result); + Assert.Contains("schedule_interval", result); + // Should not drop and recreate + Assert.DoesNotContain("remove_reorder_policy", result); + Assert.DoesNotContain("add_reorder_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterReorderPolicy_WithNoChanges_GeneratesValidCSharpOrNoOp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + // An alter operation with no actual changes + AlterReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + IndexName = "sensor_data_idx", + OldIndexName = "sensor_data_idx", + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = null, + OldScheduleInterval = null, + MaxRuntime = null, + OldMaxRuntime = null, + MaxRetries = null, + OldMaxRetries = null, + RetryPeriod = null, + OldRetryPeriod = null + }; + + // 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_AlterContinuousAggregate_WithChunkIntervalChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ChunkInterval = "7 days", + OldChunkInterval = "1 day", + CreateGroupIndexes = true, + OldCreateGroupIndexes = true, + MaterializedOnly = false, + OldMaterializedOnly = false + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("ALTER MATERIALIZED VIEW", result); + Assert.Contains("SET", result); + Assert.Contains("timescaledb.chunk_interval", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterContinuousAggregate_WithMaterializedOnlyChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = null, + CreateGroupIndexes = true, + OldCreateGroupIndexes = true, + MaterializedOnly = true, + OldMaterializedOnly = false // Changed from false to true + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("ALTER MATERIALIZED VIEW", result); + Assert.Contains("SET", result); + Assert.Contains("timescaledb.materialized_only", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterContinuousAggregate_WithCreateGroupIndexesChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = null, + CreateGroupIndexes = false, + OldCreateGroupIndexes = true, // Changed from true to false + MaterializedOnly = false, + OldMaterializedOnly = false + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("ALTER MATERIALIZED VIEW", result); + Assert.Contains("SET", result); + Assert.Contains("timescaledb.create_group_indexes", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterContinuousAggregate_WithNoChanges_GeneratesValidCSharpOrNoOp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + // An alter operation with no actual changes + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = null, + CreateGroupIndexes = true, + OldCreateGroupIndexes = true, + MaterializedOnly = false, + OldMaterializedOnly = false + }; + + // 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); + } + } + } + #endregion #region Helper Methods diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index 01f0c5d..43bf7fe 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -489,6 +489,96 @@ public async Task Should_Create_Hypertable_With_RangeDimension() #endregion + #region Should_Create_Hypertable_With_RangeDimension_IntegerInterval + + private class IntegerRangeDimensionData + { + public DateTime Timestamp { get; set; } + public int SequenceNumber { get; set; } + public double Value { get; set; } + } + + private class IntegerRangeDimensionContext(string connectionString) : DbContext + { + public DbSet SequencedData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("sequenced_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("SequenceNumber", "10000")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension_IntegerInterval() + { + await using IntegerRangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "sequenced_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "SequenceNumber"); + Assert.NotNull(rangeDimension); + Assert.Null(rangeDimension.NumberPartitions); + } + + #endregion + + #region Should_Create_Hypertable_With_RangeDimension_TimeInterval + + private class TimeRangeDimensionData + { + public DateTime EventTime { get; set; } + public DateTime ProcessingTime { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class TimeRangeDimensionContext(string connectionString) : DbContext + { + public DbSet DualTimeData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("dual_time_events"); + entity.HasNoKey(); + entity.IsHypertable(x => x.EventTime) + .HasDimension(Dimension.CreateRange("ProcessingTime", "2 hours")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension_TimeInterval() + { + await using TimeRangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "dual_time_events"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessingTime"); + Assert.NotNull(rangeDimension); + Assert.Null(rangeDimension.NumberPartitions); + } + + #endregion + #region Should_Create_Hypertable_With_MultipleDimensions private class MultipleDimensionsData diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs new file mode 100644 index 0000000..8b337db --- /dev/null +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -0,0 +1,1092 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Diagnostics.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.Logging; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; +using System.Diagnostics; +using Testcontainers.PostgreSql; + +#pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Integration tests for TimescaleDatabaseModelFactory. +/// Tests the full scaffolding pipeline from database to DatabaseModel with annotations. +/// +public class TimescaleDatabaseModelFactoryTests : 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(); + } + } + + private async Task GetTestConnectionStringAsync() + { + string testDbName = $"test_db_{Guid.NewGuid():N}"; + + await using NpgsqlConnection adminConnection = new(_connectionString); + await adminConnection.OpenAsync(); + + await using (NpgsqlCommand createCmd = new($"CREATE DATABASE {testDbName}", adminConnection)) + { + await createCmd.ExecuteNonQueryAsync(); + } + + string testConnectionString = _connectionString!.Replace("test_db", testDbName); + await using NpgsqlConnection testConnection = new(testConnectionString); + await testConnection.OpenAsync(); + await using (NpgsqlCommand extCmd = new("CREATE EXTENSION IF NOT EXISTS timescaledb", testConnection)) + { + await extCmd.ExecuteNonQueryAsync(); + } + + return testConnectionString; + } + + private static TimescaleDatabaseModelFactory CreateFactory() + { + LoggerFactory loggerFactory = new(); + DiagnosticsLogger logger = new( + loggerFactory, + new LoggingOptions(), + new DiagnosticListener("Test"), + new NpgsqlLoggingDefinitions(), + new NullDbContextLogger()); + + return new TimescaleDatabaseModelFactory(logger); + } + + private sealed class NullDbContextLogger : IDbContextLogger + { + public void Log(EventData eventData) { } + public bool ShouldLog(EventId eventId, LogLevel logLevel) => false; + } + + #region Should_Scaffold_Minimal_Hypertable + + private class MinimalHypertableMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalHypertableContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Minimal_Hypertable() + { + await using MinimalHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + // Verify hypertable annotations + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal("Timestamp", metricsTable[HypertableAnnotations.HypertableTimeColumn]); + Assert.NotNull(metricsTable[HypertableAnnotations.ChunkTimeInterval]); + Assert.Equal(false, metricsTable[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Compression + + private class CompressionHypertableMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class CompressionHypertableContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Compression() + { + await using CompressionHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Hash_Dimension + + private class HashDimensionMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class HashDimensionContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Hash_Dimension() + { + await using HashDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.NotNull(metricsTable[HypertableAnnotations.AdditionalDimensions]); + + string? dimensionsJson = metricsTable[HypertableAnnotations.AdditionalDimensions] as string; + Assert.NotNull(dimensionsJson); + Assert.Contains("DeviceId", dimensionsJson); + // EDimensionType.Hash = 1 in the enum, serialized as integer + Assert.Contains("\"Type\":1", dimensionsJson); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Reorder_Policy + + private class ReorderPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderPolicyContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.HasIndex(x => x.Timestamp, "metrics_time_idx"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Reorder_Policy() + { + await using ReorderPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + // Verify both hypertable and reorder policy annotations + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("metrics_time_idx", metricsTable[ReorderPolicyAnnotations.IndexName]); + } + + #endregion + + #region Should_Scaffold_Reorder_Policy_With_Custom_Parameters + + private class CustomReorderPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomReorderPolicyContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.HasIndex(x => x.Timestamp, "metrics_time_idx"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Reorder_Policy_With_Custom_Parameters() + { + await using CustomReorderPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("12:00:00", metricsTable[ReorderPolicyAnnotations.ScheduleInterval]); + Assert.Equal(5, metricsTable[ReorderPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Scaffold_Continuous_Aggregate + + private class CaggSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class CaggHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ContinuousAggregateContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("hourly_metrics"); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Continuous_Aggregate() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using ContinuousAggregateContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Verify source hypertable + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + + // Verify continuous aggregate + DatabaseTable? caggTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics"); + Assert.NotNull(caggTable); + Assert.Equal("hourly_metrics", caggTable[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("Metrics", caggTable[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion + + #region Should_Scaffold_Continuous_Aggregate_With_MaterializedOnly + + private class MatOnlySourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MatOnlyHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("hourly_metrics_mat"); + entity.IsContinuousAggregate( + "hourly_metrics_mat", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ).MaterializedOnly(true); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Continuous_Aggregate_With_MaterializedOnly() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MaterializedOnlyContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics_mat"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? caggTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics_mat"); + Assert.NotNull(caggTable); + Assert.Equal(true, caggTable[ContinuousAggregateAnnotations.MaterializedOnly]); + } + + #endregion + + #region Should_Not_Apply_Hypertable_Annotations_To_Regular_Table + + private class RegularEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class RegularTableContext(string connectionString) : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.ToTable("Entities"); + }); + } + } + + [Fact] + public async Task Should_Not_Apply_Hypertable_Annotations_To_Regular_Table() + { + await using RegularTableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Entities"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? entitiesTable = model.Tables.FirstOrDefault(t => t.Name == "Entities"); + Assert.NotNull(entitiesTable); + + // Should NOT have any TimescaleDB annotations + Assert.Null(entitiesTable[HypertableAnnotations.IsHypertable]); + Assert.Null(entitiesTable[HypertableAnnotations.HypertableTimeColumn]); + Assert.Null(entitiesTable[ReorderPolicyAnnotations.HasReorderPolicy]); + } + + #endregion + + #region Should_Scaffold_Multiple_Hypertables + + private class MultiMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiMetric2 + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultipleHypertablesContext(string connectionString) : DbContext + { + public DbSet Metrics1 => Set(); + public DbSet Metrics2 => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics1"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics2"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Multiple_Hypertables() + { + await using MultipleHypertablesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics1", "Metrics2"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? table1 = model.Tables.FirstOrDefault(t => t.Name == "Metrics1"); + DatabaseTable? table2 = model.Tables.FirstOrDefault(t => t.Name == "Metrics2"); + + Assert.NotNull(table1); + Assert.NotNull(table2); + + Assert.Equal(true, table1[HypertableAnnotations.IsHypertable]); + Assert.Equal(false, table1[HypertableAnnotations.EnableCompression]); + + Assert.Equal(true, table2[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, table2[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Scaffold_Mixed_Regular_And_Hypertables + + private class MixedRegularEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class MixedHypertableMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MixedContext(string connectionString) : DbContext + { + public DbSet Entities => Set(); + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.ToTable("Entities"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Mixed_Regular_And_Hypertables() + { + await using MixedContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Entities", "Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? entitiesTable = model.Tables.FirstOrDefault(t => t.Name == "Entities"); + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + + Assert.NotNull(entitiesTable); + Assert.NotNull(metricsTable); + + // Regular table should NOT have hypertable annotations + Assert.Null(entitiesTable[HypertableAnnotations.IsHypertable]); + + // Hypertable should have annotations + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + } + + #endregion + + #region Should_Handle_Table_With_Null_Schema + + private class NullSchemaMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullSchemaContext(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.HasNoKey(); + entity.ToTable("Metrics", schema: "public"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Handle_Table_In_Public_Schema() + { + await using NullSchemaContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + Assert.Equal("public", metricsTable.Schema); + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + } + + #endregion + + #region Should_Scaffold_Full_Configuration + + private class FullConfigMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { get; set; } + } + + private class FullConfigHourly + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class FullConfigurationContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.HasIndex(x => x.Timestamp, "metrics_time_idx"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression() + .HasDimension(Dimension.CreateHash("DeviceId", 4)) + .WithReorderPolicy("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("hourly_metrics"); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Full_Configuration() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using FullConfigurationContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Verify hypertable with all features + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); + Assert.NotNull(metricsTable[HypertableAnnotations.AdditionalDimensions]); + Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("metrics_time_idx", metricsTable[ReorderPolicyAnnotations.IndexName]); + + // Verify continuous aggregate + DatabaseTable? caggTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics"); + Assert.NotNull(caggTable); + Assert.Equal("hourly_metrics", caggTable[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("Metrics", caggTable[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion + + #region Should_Scaffold_With_Already_Open_Connection + + private class OpenConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OpenConnectionContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Scaffold_With_Already_Open_Connection() + { + await using OpenConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + connection.Open(); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Connection should remain open + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + } + + #endregion + + #region Should_Handle_Empty_Database + + private class EmptyDbEntity + { + public int Id { get; set; } + } + + private class EmptyDatabaseContext(string connectionString) : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + } + + [Fact] + public async Task Should_Handle_Empty_Database() + { + // Don't create any tables - just scaffold an empty database + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: [], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Model should be valid but have no TimescaleDB-specific tables + Assert.NotNull(model); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Custom_Chunk_Interval + + private class CustomChunkMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomChunkIntervalContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("86400000"); // 1 day in milliseconds + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Custom_Chunk_Interval() + { + await using CustomChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + + // Verify chunk interval is extracted + object? chunkInterval = metricsTable[HypertableAnnotations.ChunkTimeInterval]; + Assert.NotNull(chunkInterval); + } + + #endregion + + #region Should_Preserve_Base_Model_Structure + + private class PreserveStructureMetric + { + public DateTime Timestamp { get; set; } + public string Name { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class PreserveStructureContext(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.HasNoKey(); + entity.ToTable("Metrics"); + entity.Property(x => x.Name).HasMaxLength(100); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Preserve_Base_Model_Structure() + { + await using PreserveStructureContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + // Verify columns are preserved + Assert.Contains(metricsTable.Columns, c => c.Name == "Timestamp"); + Assert.Contains(metricsTable.Columns, c => c.Name == "Name"); + Assert.Contains(metricsTable.Columns, c => c.Name == "Value"); + + // Verify TimescaleDB annotations are added + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + } + + #endregion + + #region Should_Scaffold_Multiple_Continuous_Aggregates + + private class MultiCaggSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiCaggHourly + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MultiCaggDaily + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MultipleCaggContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + public DbSet DailyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("hourly_metrics"); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("daily_metrics"); + entity.IsContinuousAggregate( + "daily_metrics", + "1 day", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Multiple_Continuous_Aggregates() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MultipleCaggContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics", "daily_metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? hourlyTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics"); + DatabaseTable? dailyTable = model.Tables.FirstOrDefault(t => t.Name == "daily_metrics"); + + Assert.NotNull(hourlyTable); + Assert.NotNull(dailyTable); + + Assert.Equal("hourly_metrics", hourlyTable[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("daily_metrics", dailyTable[ContinuousAggregateAnnotations.MaterializedViewName]); + + // Both should reference the same source + Assert.Equal("Metrics", hourlyTable[ContinuousAggregateAnnotations.ParentName]); + Assert.Equal("Metrics", dailyTable[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/coverlet.runsettings b/tests/Eftdb.Tests/coverlet.runsettings index bf4bfcd..fb55f96 100644 --- a/tests/Eftdb.Tests/coverlet.runsettings +++ b/tests/Eftdb.Tests/coverlet.runsettings @@ -5,9 +5,7 @@ cobertura,lcov - - [CmdScale.EntityFrameworkCore.TimescaleDB]CmdScale.EntityFrameworkCore.TimescaleDB.Internals.WhereClauseExpressionVisitor - + Obsolete, GeneratedCodeAttribute, @@ -15,7 +13,10 @@ ExcludeFromCodeCoverageAttribute - **/Migrations/*.cs + **/Migrations/*.cs, + **/WhereClauseEpressionVisitor.cs, + **/TimescaleDBDesignTimeServices.cs, + **/TimescaleDbServiceCollectionExtensions.cs false true