From 55349ebfdc41a10bb8b716a22d8770ce97fea60e Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Tue, 25 Nov 2025 03:26:02 +0100 Subject: [PATCH 1/3] Unit Tests: - Model extractors (Hypertable, ReorderPolicy, ContinuousAggregate) - Differs for detecting migration changes - Conventions for data annotation processing - TypeBuilders for Fluent API configuration - Operation generators for SQL/C# code generation Integration Tests (Testcontainers): - Hypertable creation and modification - Reorder policy lifecycle - Continuous aggregate materialized views - Scaffolding extractors for database-first scenarios - Full migration lifecycle tests Configuration: - Add coverlet.runsettings for code coverage exclusions - Add stryker-config.json for mutation testing - Update .gitignore for test artifacts --- .gitignore | 10 +- .../ContinuousAggregateAnnotationApplier.cs | 2 +- ...ContinuousAggregateScaffoldingExtractor.cs | 4 +- .../HypertableAnnotationApplier.cs | 2 +- .../HypertableScaffoldingExtractor.cs | 17 +- .../Scaffolding/IAnnotationApplier.cs | 2 +- .../Scaffolding/ITimescaleFeatureExtractor.cs | 2 +- .../ReorderPolicyAnnotationApplier.cs | 2 +- .../ReorderPolicyScaffoldingExtractor.cs | 4 +- ...tityFrameworkCore.TimescaleDB.Tests.csproj | 3 +- .../ContinuousAggregateConventionTests.cs | 840 ++++++++ .../Conventions/HypertableConventionTests.cs | 389 ++++ .../ReorderPolicyConventionTests.cs | 511 +++++ .../Differs/ContinuousAggregateDifferTests.cs | 1883 +++++++++++++++++ .../Differs/HypertableDifferTests.cs | 843 ++++++++ .../Differs/ReorderPolicyDifferTests.cs | 886 ++++++++ .../ContinuousAggregateModelExtractorTests.cs | 1206 +++++++++++ .../HypertableModelExtractorTests.cs | 773 +++++++ .../ReorderPolicyModelExtractorTests.cs | 679 ++++++ ...tinuousAggregateOperationGeneratorTests.cs | 703 ++++++ ...bleOperationGeneratorComprehensiveTests.cs | 736 +++++++ ...icyOperationGeneratorComprehensiveTests.cs | 615 ++++++ .../ContinuousAggregateIntegrationTests.cs | 1320 ++++++------ ...nuousAggregateScaffoldingExtractorTests.cs | 565 +++++ .../Integration/HypertableIntegrationTests.cs | 790 +++++++ .../HypertableScaffoldingExtractorTests.cs | 397 ++++ .../Integration/MigrationLifecycleTests.cs | 823 +++++++ .../Integration/MigrationTestBase.cs | 107 + .../ReorderPolicyIntegrationTests.cs | 1014 +++++++++ .../ReorderPolicyScaffoldingExtractorTests.cs | 501 +++++ .../STRYKER_README.md | 216 ++ .../ContinuousAggregateBuilderTests.cs | 1583 ++++++++++++++ .../HypertableTypeBuilderTests.cs | 664 ++++++ .../ReorderPolicyTypeBuilderTests.cs | 619 ++++++ .../coverlet.runsettings | 30 + .../stryker-config.json | 66 + CmdScale.EntityFrameworkCore.TimescaleDB.sln | 4 +- .../ContinuousAggregateTypeBuilder.cs | 6 +- .../Hypertable/HypertableConvention.cs | 8 +- .../ReorderPolicy/ReorderPolicyConvention.cs | 2 +- .../ContinuousAggregateOperationGenerator.cs | 4 +- .../HypertableOperationGenerator.cs | 16 +- .../ContinuousAggregateDiffer.cs | 4 +- .../ContinuousAggregateModelExtractor.cs | 18 +- .../Features/Hypertables/HypertableDiffer.cs | 2 +- .../Hypertables/HypertableModelExtractor.cs | 2 +- .../ReorderPolicies/ReorderPolicyDiffer.cs | 2 +- .../ReorderPolicyModelExtractor.cs | 2 +- .../CreateContinuousAggregateOperation.cs | 4 +- README.md | 65 +- 50 files changed, 18245 insertions(+), 701 deletions(-) create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ContinuousAggregateConventionTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ReorderPolicyConventionTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ContinuousAggregateDifferTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/HypertableDifferTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ReorderPolicyDifferTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/HypertableModelExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ReorderPolicyModelExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableIntegrationTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationLifecycleTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationTestBase.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyIntegrationTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/STRYKER_README.md create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/HypertableTypeBuilderTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/stryker-config.json diff --git a/.gitignore b/.gitignore index 2a6a2dc..074688b 100644 --- a/.gitignore +++ b/.gitignore @@ -435,4 +435,12 @@ CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.DbFirst/ # AI CLAUDE.md -.claude \ No newline at end of file +.claude + +# Code coverage report +coverage +nul + +# Stryker.NET mutation testing output +**/StrykerOutput/ +stryker-config.json.user \ No newline at end of file diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs index 5cb44f9..de7be14 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs @@ -8,7 +8,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// Applies continuous aggregate annotations to scaffolded database views. /// Note: Continuous aggregates in TimescaleDB are materialized views, so they appear as tables/views in scaffolding. /// - internal sealed class ContinuousAggregateAnnotationApplier : IAnnotationApplier + public sealed class ContinuousAggregateAnnotationApplier : IAnnotationApplier { public void ApplyAnnotations(DatabaseTable table, object featureInfo) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs index 73e870a..1187470 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs @@ -6,9 +6,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Extracts continuous aggregate metadata from a TimescaleDB database for scaffolding. /// - internal sealed class ContinuousAggregateScaffoldingExtractor : ITimescaleFeatureExtractor + public sealed class ContinuousAggregateScaffoldingExtractor : ITimescaleFeatureExtractor { - internal sealed record ContinuousAggregateInfo( + public sealed record ContinuousAggregateInfo( string MaterializedViewName, string Schema, string ViewDefinition, diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableAnnotationApplier.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableAnnotationApplier.cs index a2448f2..29c5029 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableAnnotationApplier.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableAnnotationApplier.cs @@ -8,7 +8,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Applies hypertable annotations to scaffolded database tables. /// - internal sealed class HypertableAnnotationApplier : IAnnotationApplier + public sealed class HypertableAnnotationApplier : IAnnotationApplier { public void ApplyAnnotations(DatabaseTable table, object featureInfo) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableScaffoldingExtractor.cs index e43fe33..adba391 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -7,9 +7,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Extracts hypertable metadata from a TimescaleDB database for scaffolding. /// - internal sealed class HypertableScaffoldingExtractor : ITimescaleFeatureExtractor + public sealed class HypertableScaffoldingExtractor : ITimescaleFeatureExtractor { - internal sealed record HypertableInfo( + public sealed record HypertableInfo( string TimeColumnName, string ChunkTimeInterval, bool CompressionEnabled, @@ -74,7 +74,8 @@ private static void GetHypertableSettings( column_name, dimension_number, num_partitions, - EXTRACT(EPOCH FROM time_interval) * 1000 AS time_interval_microseconds + EXTRACT(EPOCH FROM time_interval) * 1000 AS time_interval_microseconds, + integer_interval FROM timescaledb_information.dimensions ORDER BY hypertable_schema, hypertable_name, dimension_number;"; @@ -111,15 +112,21 @@ FROM timescaledb_information.dimensions if (!reader.IsDBNull(4) && reader.GetInt32(4) > 0) { - // Space dimension + // Hash dimension (space partitioning) dimension = Dimension.CreateHash(columnName, reader.GetInt32(4)); } else if (!reader.IsDBNull(5)) { - // Time dimension + // Time-based range dimension long interval = (long)reader.GetDouble(5); dimension = Dimension.CreateRange(columnName, interval.ToString()); } + else if (!reader.IsDBNull(6)) + { + // Integer-based range dimension + long integerInterval = reader.GetInt64(6); + dimension = Dimension.CreateRange(columnName, integerInterval.ToString()); + } else continue; info.AdditionalDimensions.Add(dimension); diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/IAnnotationApplier.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/IAnnotationApplier.cs index be78345..4e93d80 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/IAnnotationApplier.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/IAnnotationApplier.cs @@ -5,7 +5,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Interface for applying TimescaleDB feature annotations to scaffolded database tables. /// - internal interface IAnnotationApplier + public interface IAnnotationApplier { /// /// Applies annotations to the database table based on the feature metadata. diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ITimescaleFeatureExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ITimescaleFeatureExtractor.cs index 92a5d23..2f3d691 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ITimescaleFeatureExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ITimescaleFeatureExtractor.cs @@ -5,7 +5,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Interface for extracting TimescaleDB feature metadata from a database connection. /// - internal interface ITimescaleFeatureExtractor + public interface ITimescaleFeatureExtractor { /// /// Extracts feature metadata from the database and returns a dictionary keyed by (schema, tableName). diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyAnnotationApplier.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyAnnotationApplier.cs index fc4c1aa..d903e61 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyAnnotationApplier.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyAnnotationApplier.cs @@ -7,7 +7,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Applies reorder policy annotations to scaffolded database tables. /// - internal sealed class ReorderPolicyAnnotationApplier : IAnnotationApplier + public sealed class ReorderPolicyAnnotationApplier : IAnnotationApplier { public void ApplyAnnotations(DatabaseTable table, object featureInfo) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs index 75b32be..e331a92 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs @@ -6,9 +6,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding /// /// Extracts reorder policy metadata from a TimescaleDB database for scaffolding. /// - internal sealed class ReorderPolicyScaffoldingExtractor : ITimescaleFeatureExtractor + public sealed class ReorderPolicyScaffoldingExtractor : ITimescaleFeatureExtractor { - internal sealed record ReorderPolicyInfo( + public sealed record ReorderPolicyInfo( string IndexName, DateTime? InitialStart, string? ScheduleInterval, diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/CmdScale.EntityFrameworkCore.TimescaleDB.Tests.csproj b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/CmdScale.EntityFrameworkCore.TimescaleDB.Tests.csproj index aa7afc4..fbcff62 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/CmdScale.EntityFrameworkCore.TimescaleDB.Tests.csproj +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/CmdScale.EntityFrameworkCore.TimescaleDB.Tests.csproj @@ -15,7 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -31,6 +31,7 @@ + diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ContinuousAggregateConventionTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ContinuousAggregateConventionTests.cs new file mode 100644 index 0000000..2574773 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ContinuousAggregateConventionTests.cs @@ -0,0 +1,840 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +/// +/// Tests that verify ContinuousAggregateConvention processes [ContinuousAggregate], [TimeBucket], and [Aggregate] attributes correctly +/// and applies the same annotations as the Fluent API. +/// +public class ContinuousAggregateConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Process_Minimal_ContinuousAggregate_Attributes + + [Hypertable("Timestamp")] + private class MinimalSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics")] + [TimeBucket("1 hour", "Timestamp")] + private class MinimalContinuousAggregateEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class MinimalAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_Minimal_ContinuousAggregate_Attributes() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalContinuousAggregateEntity))!; + + Assert.NotNull(entityType); + Assert.Equal("hourly_metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value); + Assert.Equal("Metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value); + } + + #endregion + + #region Should_Configure_Entity_As_View_Not_Table + + [Hypertable("Timestamp")] + private class ViewSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics")] + [TimeBucket("1 hour", "Timestamp")] + private class ViewContinuousAggregateEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class ViewAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Configure_Entity_As_View_Not_Table() + { + using ViewAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ViewContinuousAggregateEntity))!; + + Assert.Equal("hourly_metrics", entityType.GetViewName()); + Assert.Null(entityType.GetTableName()); + } + + #endregion + + #region Should_Process_AggregateAttribute_On_Properties + + [Hypertable("Timestamp")] + private class AggregateSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics")] + [TimeBucket("1 hour", "Timestamp")] + private class AggregateContinuousAggregateEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class AggregateAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_AggregateAttribute_On_Properties() + { + using AggregateAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateContinuousAggregateEntity))!; + + object? aggregateFunctionsValue = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value; + Assert.NotNull(aggregateFunctionsValue); + + List? aggregateFunctions = aggregateFunctionsValue as List; + Assert.NotNull(aggregateFunctions); + Assert.Single(aggregateFunctions); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + } + + #endregion + + #region Should_Process_Multiple_AggregateAttributes + + [Hypertable("Timestamp")] + private class MultipleAggregatesSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics")] + [TimeBucket("1 hour", "Timestamp")] + private class MultipleAggregatesEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + + [Aggregate(EAggregateFunction.Min, "Value")] + public double MinValue { get; set; } + + [Aggregate(EAggregateFunction.Max, "Value")] + public double MaxValue { get; set; } + } + + private class MultipleAggregatesContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_Multiple_AggregateAttributes() + { + using MultipleAggregatesContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MultipleAggregatesEntity))!; + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Equal(3, aggregateFunctions.Count); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + Assert.Contains("MinValue:Min:Value", aggregateFunctions); + Assert.Contains("MaxValue:Max:Value", aggregateFunctions); + } + + #endregion + + #region Should_Process_ChunkInterval_Option + + [Hypertable("Timestamp")] + private class ChunkIntervalSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics", ChunkInterval = "30 days")] + [TimeBucket("1 hour", "Timestamp")] + private class ChunkIntervalEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class ChunkIntervalAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_ChunkInterval_Option() + { + using ChunkIntervalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ChunkIntervalEntity))!; + + Assert.Equal("30 days", entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value); + } + + #endregion + + #region Should_Process_WithNoData_Option + + [Hypertable("Timestamp")] + private class WithNoDataSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics", WithNoData = true)] + [TimeBucket("1 hour", "Timestamp")] + private class WithNoDataEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class WithNoDataAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_WithNoData_Option() + { + using WithNoDataAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(WithNoDataEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value); + } + + #endregion + + #region Should_Process_CreateGroupIndexes_Option + + [Hypertable("Timestamp")] + private class CreateGroupIndexesSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics", CreateGroupIndexes = false)] + [TimeBucket("1 hour", "Timestamp")] + private class CreateGroupIndexesEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class CreateGroupIndexesAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_CreateGroupIndexes_Option() + { + using CreateGroupIndexesAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CreateGroupIndexesEntity))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value); + } + + #endregion + + #region Should_Process_MaterializedOnly_Option + + [Hypertable("Timestamp")] + private class MaterializedOnlySourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics", MaterializedOnly = true)] + [TimeBucket("1 hour", "Timestamp")] + private class MaterializedOnlyEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class MaterializedOnlyAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_MaterializedOnly_Option() + { + using MaterializedOnlyAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaterializedOnlyEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value); + } + + #endregion + + #region Should_Process_WhereClause_Option + + [Hypertable("Timestamp")] + private class WhereClauseSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics", Where = "DeviceId > 100")] + [TimeBucket("1 hour", "Timestamp")] + private class WhereClauseEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class WhereClauseAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_WhereClause_Option() + { + using WhereClauseAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(WhereClauseEntity))!; + + Assert.Equal("DeviceId > 100", entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value); + } + + #endregion + + #region Should_Process_TimeBucketAttribute_GroupBy + + [Hypertable("Timestamp")] + private class TimeBucketGroupBySourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "Metrics")] + [TimeBucket("1 hour", "Timestamp", GroupBy = false)] + private class TimeBucketGroupByEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class TimeBucketGroupByContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_TimeBucketAttribute_GroupBy() + { + using TimeBucketGroupByContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(TimeBucketGroupByEntity))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy)?.Value); + } + + #endregion + + #region Should_Process_Fully_Configured_ContinuousAggregate + + [Hypertable("Timestamp")] + private class FullyConfiguredSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate( + MaterializedViewName = "hourly_metrics", + ParentName = "Metrics", + ChunkInterval = "30 days", + WithNoData = true, + CreateGroupIndexes = false, + MaterializedOnly = true, + Where = "DeviceId > 100")] + [TimeBucket("1 hour", "Timestamp")] + private class FullyConfiguredEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + + [Aggregate(EAggregateFunction.Max, "Value")] + public double MaxValue { get; set; } + } + + private class FullyConfiguredAttributeContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_Fully_Configured_ContinuousAggregate() + { + using FullyConfiguredAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal("hourly_metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value); + Assert.Equal("Metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value); + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value); + Assert.Equal("DeviceId > 100", entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value); + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Equal(2, aggregateFunctions.Count); + } + + #endregion + + #region Should_Not_Process_Entity_Without_Attribute + + private class PlainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Plain"); + }); + } + } + + [Fact] + public void Should_Not_Process_Entity_Without_Attribute() + { + using NoAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(PlainEntity))!; + + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)); + Assert.Null(entityType.GetViewName()); + } + + #endregion + + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI + + [Hypertable("Timestamp")] + private class EquivalenceSourceMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics", ParentName = "EquivalenceSourceMetricEntity", ChunkInterval = "30 days")] + [TimeBucket("1 hour", "Timestamp")] + private class EquivalenceAttributeEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class EquivalenceFluentEntity + { + public DateTime Timestamp { get; set; } + public double AvgValue { get; set; } + } + + private class AttributeBasedContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + private class FluentApiBasedContext : 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() + { + using AttributeBasedContext attributeContext = new(); + using FluentApiBasedContext fluentContext = new(); + + IModel attributeModel = GetModel(attributeContext); + IModel fluentModel = GetModel(fluentContext); + + IEntityType attributeEntity = attributeModel.FindEntityType(typeof(EquivalenceAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(EquivalenceFluentEntity))!; + + Assert.Equal( + attributeEntity.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value, + fluentEntity.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value, + fluentEntity.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value, + fluentEntity.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value, + fluentEntity.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value + ); + + List? attributeAggregates = attributeEntity.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + List? fluentAggregates = fluentEntity.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + + Assert.NotNull(attributeAggregates); + Assert.NotNull(fluentAggregates); + Assert.Equal(attributeAggregates.Count, fluentAggregates.Count); + Assert.All(attributeAggregates, agg => Assert.Contains(agg, fluentAggregates)); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs new file mode 100644 index 0000000..57616ac --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs @@ -0,0 +1,389 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +/// +/// Tests that verify HypertableConvention processes [Hypertable] attribute correctly +/// and applies the same annotations as the Fluent API. +/// +public class HypertableConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Process_Minimal_Hypertable_Attribute + + [Hypertable("Timestamp")] + private class MinimalHypertableEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MinimalHypertable"); + }); + } + } + + [Fact] + public void Should_Process_Minimal_Hypertable_Attribute() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalHypertableEntity))!; + + Assert.NotNull(entityType); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + } + + #endregion + + #region Should_Process_Hypertable_With_ChunkTimeInterval + + [Hypertable("Timestamp", ChunkTimeInterval = "1 day")] + private class ChunkIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChunkIntervalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ChunkInterval"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_ChunkTimeInterval() + { + using ChunkIntervalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ChunkIntervalEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + } + + #endregion + + #region Should_Process_Hypertable_With_Compression_Enabled + + [Hypertable("Timestamp", EnableCompression = true)] + private class CompressionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Compression"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_Compression_Enabled() + { + using CompressionAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressionEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Process_Hypertable_With_ChunkSkipColumns + + [Hypertable("Timestamp", ChunkSkipColumns = ["Value", "DeviceId"])] + private class ChunkSkippingEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class ChunkSkippingAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ChunkSkipping"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_ChunkSkipColumns() + { + using ChunkSkippingAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ChunkSkippingEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + Assert.Equal("Value,DeviceId", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + } + + #endregion + + #region Should_Auto_Enable_Compression_When_ChunkSkipColumns_Present + + [Hypertable("Timestamp", ChunkSkipColumns = ["Value", "DeviceId"])] + private class AutoCompressionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class AutoCompressionContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("AutoCompression"); + }); + } + } + + [Fact] + public void Should_Auto_Enable_Compression_When_ChunkSkipColumns_Present() + { + using AutoCompressionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AutoCompressionEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Process_Fully_Configured_Hypertable + + [Hypertable("Timestamp", ChunkTimeInterval = "1 day", EnableCompression = true, ChunkSkipColumns = ["Value"])] + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("FullyConfigured"); + }); + } + } + + [Fact] + public void Should_Process_Fully_Configured_Hypertable() + { + using FullyConfiguredAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + Assert.Equal("Value", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + } + + #endregion + + #region Should_Not_Process_Entity_Without_Attribute + + private class PlainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Plain"); + }); + } + } + + [Fact] + public void Should_Not_Process_Entity_Without_Attribute() + { + using NoAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(PlainEntity))!; + + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.IsHypertable)); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)); + } + + #endregion + + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI + + [Hypertable("Timestamp", ChunkTimeInterval = "1 hour", EnableCompression = true)] + private class EquivalenceAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EquivalenceFluentEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttributeBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + }); + } + } + + private class FluentApiBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour") + .EnableCompression(); + }); + } + } + + [Fact] + public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() + { + using AttributeBasedContext attributeContext = new(); + using FluentApiBasedContext fluentContext = new(); + + IModel attributeModel = GetModel(attributeContext); + IModel fluentModel = GetModel(fluentContext); + + IEntityType attributeEntity = attributeModel.FindEntityType(typeof(EquivalenceAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(EquivalenceFluentEntity))!; + + Assert.Equal( + attributeEntity.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value + ); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ReorderPolicyConventionTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ReorderPolicyConventionTests.cs new file mode 100644 index 0000000..8543bb7 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/ReorderPolicyConventionTests.cs @@ -0,0 +1,511 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +/// +/// Tests that verify ReorderPolicyConvention processes [ReorderPolicy] attribute correctly +/// and applies the same annotations as the Fluent API. +/// +public class ReorderPolicyConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Process_Minimal_ReorderPolicy_Attribute + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx")] + private class MinimalReorderPolicyEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MinimalReorderPolicy"); + }); + } + } + + [Fact] + public void Should_Process_Minimal_ReorderPolicy_Attribute() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalReorderPolicyEntity))!; + + Assert.NotNull(entityType); + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("metrics_time_idx", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + } + + #endregion + + #region Should_Process_ReorderPolicy_With_ScheduleInterval + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", ScheduleInterval = "12:00:00")] + private class ScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ScheduleInterval"); + }); + } + } + + [Fact] + public void Should_Process_ReorderPolicy_With_ScheduleInterval() + { + using ScheduleIntervalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScheduleIntervalEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("12:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region Should_Process_ReorderPolicy_With_InitialStart + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", InitialStart = "2025-01-01T00:00:00Z")] + private class InitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("InitialStart"); + }); + } + } + + [Fact] + public void Should_Process_ReorderPolicy_With_InitialStart() + { + using InitialStartAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(InitialStartEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + + object? initialStartValue = entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + + DateTime initialStart = (DateTime)initialStartValue; + DateTime utcStart = initialStart.ToUniversalTime(); + Assert.Equal(2025, utcStart.Year); + Assert.Equal(1, utcStart.Month); + Assert.Equal(1, utcStart.Day); + Assert.Equal(0, utcStart.Hour); + Assert.Equal(0, utcStart.Minute); + Assert.Equal(0, utcStart.Second); + } + + #endregion + + #region Should_Throw_When_InitialStart_Has_Invalid_Format + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", InitialStart = "invalid-date-format")] + private class InvalidInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InvalidInitialStartContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("InvalidInitialStart"); + }); + } + } + + [Fact] + public void Should_Throw_When_InitialStart_Has_Invalid_Format() + { + InvalidOperationException exception = Assert.Throws(() => + { + using InvalidInitialStartContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("InitialStart", exception.Message); + Assert.Contains("not a valid DateTime format", exception.Message); + } + + #endregion + + #region Should_Process_ReorderPolicy_With_MaxRetries + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", MaxRetries = 5)] + private class MaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MaxRetries"); + }); + } + } + + [Fact] + public void Should_Process_ReorderPolicy_With_MaxRetries() + { + using MaxRetriesAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRetriesEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal(5, entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + } + + #endregion + + #region Should_Not_Set_MaxRetries_When_Using_Default_Value + + [Fact] + public void Should_Not_Set_MaxRetries_When_Using_Default_Value() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalReorderPolicyEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)); + } + + #endregion + + #region Should_Process_ReorderPolicy_With_MaxRuntime + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", MaxRuntime = "01:00:00")] + private class MaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MaxRuntime"); + }); + } + } + + [Fact] + public void Should_Process_ReorderPolicy_With_MaxRuntime() + { + using MaxRuntimeAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRuntimeEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("01:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + } + + #endregion + + #region Should_Process_ReorderPolicy_With_RetryPeriod + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", RetryPeriod = "00:10:00")] + private class RetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("RetryPeriod"); + }); + } + } + + [Fact] + public void Should_Process_ReorderPolicy_With_RetryPeriod() + { + using RetryPeriodAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RetryPeriodEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("00:10:00", entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region Should_Process_Fully_Configured_ReorderPolicy + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", ScheduleInterval = "06:00:00", MaxRuntime = "02:00:00", MaxRetries = 3, RetryPeriod = "00:15:00")] + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("FullyConfigured"); + }); + } + } + + [Fact] + public void Should_Process_Fully_Configured_ReorderPolicy() + { + using FullyConfiguredAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("metrics_time_idx", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + Assert.Equal("06:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("02:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:15:00", entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region Should_Not_Process_Entity_Without_Attribute + + [Hypertable("Timestamp")] + private class PlainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Plain"); + }); + } + } + + [Fact] + public void Should_Not_Process_Entity_Without_Attribute() + { + using NoAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(PlainEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)); + } + + #endregion + + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI + + [Hypertable("Timestamp")] + [ReorderPolicy("metrics_time_idx", ScheduleInterval = "12:00:00", MaxRuntime = "01:00:00")] + private class EquivalenceAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [Hypertable("Timestamp")] + private class EquivalenceFluentEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttributeBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + }); + } + } + + private class FluentApiBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() + { + using AttributeBasedContext attributeContext = new(); + using FluentApiBasedContext fluentContext = new(); + + IModel attributeModel = GetModel(attributeContext); + IModel fluentModel = GetModel(fluentContext); + + IEntityType attributeEntity = attributeModel.FindEntityType(typeof(EquivalenceAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(EquivalenceFluentEntity))!; + + Assert.Equal( + attributeEntity.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value, + fluentEntity.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value, + fluentEntity.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value, + fluentEntity.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value, + fluentEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value + ); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ContinuousAggregateDifferTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ContinuousAggregateDifferTests.cs new file mode 100644 index 0000000..a6738fc --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ContinuousAggregateDifferTests.cs @@ -0,0 +1,1883 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class ContinuousAggregateDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region Should_Detect_New_ContinuousAggregate + + private class MetricEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate1 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HypertableOnlyContext1 : 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 BasicContinuousAggregateContext1 : 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); + }); + } + } + + [Fact] + public void Should_Detect_New_ContinuousAggregate() + { + using HypertableOnlyContext1 sourceContext = new(); + using BasicContinuousAggregateContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + Assert.Equal("hourly_metrics", createOp.MaterializedViewName); + Assert.Equal("1 hour", createOp.TimeBucketWidth); + Assert.Contains("AvgValue:Avg:Value", createOp.AggregateFunctions); + } + + #endregion + + #region Should_Detect_Multiple_New_ContinuousAggregates + + private class MetricEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate2 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DailyMetricAggregate2 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HypertableOnlyContext2 : 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 MultipleContinuousAggregatesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + public DbSet DailyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "daily_metrics", + "1 day", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_New_ContinuousAggregates() + { + using HypertableOnlyContext2 sourceContext = new(); + using MultipleContinuousAggregatesContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List createOps = [.. operations.OfType()]; + Assert.Equal(2, createOps.Count); + Assert.Contains(createOps, op => op.MaterializedViewName == "hourly_metrics"); + Assert.Contains(createOps, op => op.MaterializedViewName == "daily_metrics"); + } + + #endregion + + #region Should_Detect_ChunkInterval_Change + + private class MetricEntity3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate3 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext3 : 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 ModifiedChunkIntervalContext3 : 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, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Detect_ChunkInterval_Change() + { + using BasicContinuousAggregateContext3 sourceContext = new(); + using ModifiedChunkIntervalContext3 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterContinuousAggregateOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Null(alterOp.OldChunkInterval); + Assert.Equal("30 days", alterOp.ChunkInterval); + } + + #endregion + + #region Should_Detect_CreateGroupIndexes_Change + + private class MetricEntity4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate4 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext4 : 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 GroupIndexesEnabledContext4 : 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) + .CreateGroupIndexes(true); + }); + } + } + + [Fact] + public void Should_Detect_CreateGroupIndexes_Change() + { + using BasicContinuousAggregateContext4 sourceContext = new(); + using GroupIndexesEnabledContext4 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterContinuousAggregateOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.False(alterOp.OldCreateGroupIndexes); + Assert.True(alterOp.CreateGroupIndexes); + } + + #endregion + + #region Should_Detect_MaterializedOnly_Change + + private class MetricEntity5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate5 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext5 : 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 MaterializedOnlyEnabledContext5 : 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) + .MaterializedOnly(true); + }); + } + } + + [Fact] + public void Should_Detect_MaterializedOnly_Change() + { + using BasicContinuousAggregateContext5 sourceContext = new(); + using MaterializedOnlyEnabledContext5 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterContinuousAggregateOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.False(alterOp.OldMaterializedOnly); + Assert.True(alterOp.MaterializedOnly); + } + + #endregion + + #region Should_Drop_And_Recreate_When_TimeBucketWidth_Changes + + private class MetricEntity6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate6 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext6 : 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 DifferentTimeBucketContext6 : 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 day", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_TimeBucketWidth_Changes() + { + using BasicContinuousAggregateContext6 sourceContext = new(); + using DifferentTimeBucketContext6 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); + + DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault(); + CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault(); + + Assert.NotNull(dropOp); + Assert.NotNull(createOp); + Assert.Equal("hourly_metrics", dropOp.MaterializedViewName); + Assert.Equal("hourly_metrics", createOp.MaterializedViewName); + Assert.Equal("1 day", createOp.TimeBucketWidth); + } + + #endregion + + #region Should_Drop_And_Recreate_When_AggregateFunction_Changes + + private class MetricEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate7 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MetricAggregateMax7 + { + public DateTime TimeBucket { get; set; } + public double MaxValue { get; set; } + } + + private class BasicContinuousAggregateContext7 : 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 DifferentAggregateFunctionContext7 : 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.MaxValue, x => x.Value, EAggregateFunction.Max); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_AggregateFunction_Changes() + { + using BasicContinuousAggregateContext7 sourceContext = new(); + using DifferentAggregateFunctionContext7 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_Change + + private class MetricEntity8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregate8 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MetricAggregateWithCategory8 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class BasicContinuousAggregateContext8 : 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 WithGroupByColumnsContext8 : 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_GroupByColumns_Change() + { + using BasicContinuousAggregateContext8 sourceContext = new(); + using WithGroupByColumnsContext8 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_ParentName_Changes + + private class MetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlternateMetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate9 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext9 : 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 DifferentParentContext9 : DbContext + { + public DbSet AlternateMetrics => 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("AlternateMetrics"); + 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_ParentName_Changes() + { + using BasicContinuousAggregateContext9 sourceContext = new(); + using DifferentParentContext9 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_Not_Drop_And_Recreate_When_Only_Alterable_Properties_Change + + private class MetricEntity10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate10 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext10 : 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 OnlyAlterableChangesContext10 : 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, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(true) + .MaterializedOnly(true); + }); + } + } + + [Fact] + public void Should_Not_Drop_And_Recreate_When_Only_Alterable_Properties_Change() + { + using BasicContinuousAggregateContext10 sourceContext = new(); + using OnlyAlterableChangesContext10 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + Assert.Contains(operations, op => op is AlterContinuousAggregateOperation); + } + + #endregion + + #region Should_Detect_Dropped_ContinuousAggregate + + private class MetricEntity11 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate11 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext11 : 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 HypertableOnlyContext11 : 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_Dropped_ContinuousAggregate() + { + using BasicContinuousAggregateContext11 sourceContext = new(); + using HypertableOnlyContext11 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + Assert.Equal("hourly_metrics", dropOp.MaterializedViewName); + } + + #endregion + + #region Should_Detect_Multiple_Dropped_ContinuousAggregates + + private class MetricEntity12 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate12 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DailyMetricAggregate12 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MultipleContinuousAggregatesContext12 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + public DbSet DailyMetrics => 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); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "daily_metrics", + "1 day", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class HypertableOnlyContext12 : 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_Multiple_Dropped_ContinuousAggregates() + { + using MultipleContinuousAggregatesContext12 sourceContext = new(); + using HypertableOnlyContext12 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List dropOps = [.. operations.OfType()]; + Assert.Equal(2, dropOps.Count); + } + + #endregion + + #region Should_Not_Generate_Operations_When_No_Changes + + private class MetricEntity13 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate13 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext13 : 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); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_No_Changes() + { + using BasicContinuousAggregateContext13 sourceContext = new(); + using BasicContinuousAggregateContext13 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Not_Drop_And_Recreate_When_Both_AggregateFunctions_Are_Null + + private class MetricEntity14 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate14 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullAggregateFunctionsContext14 : 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); + }); + } + } + + [Fact] + public void Should_Not_Drop_And_Recreate_When_Both_AggregateFunctions_Are_Null() + { + using NullAggregateFunctionsContext14 sourceContext = new(); + using NullAggregateFunctionsContext14 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_Source_AggregateFunctions_Null_And_Target_Has_Functions + + private class MetricEntity15 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate15 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullAggregateFunctionsContext15 : 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); + }); + } + } + + private class BasicContinuousAggregateContext15 : 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_Source_AggregateFunctions_Null_And_Target_Has_Functions() + { + using NullAggregateFunctionsContext15 sourceContext = new(); + using BasicContinuousAggregateContext15 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_Source_Has_AggregateFunctions_And_Target_Null + + private class MetricEntity16 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate16 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext16 : 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 NullAggregateFunctionsContext16 : 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_Source_Has_AggregateFunctions_And_Target_Null() + { + using BasicContinuousAggregateContext16 sourceContext = new(); + using NullAggregateFunctionsContext16 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_Not_Drop_And_Recreate_When_Both_GroupByColumns_Are_Null + + private class MetricEntity17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate17 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoGroupByColumnsContext17 : 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); + }); + } + } + + [Fact] + public void Should_Not_Drop_And_Recreate_When_Both_GroupByColumns_Are_Null() + { + using NoGroupByColumnsContext17 sourceContext = new(); + using NoGroupByColumnsContext17 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_Source_GroupByColumns_Null_And_Target_Has_Columns + + private class MetricEntity18 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregate18 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MetricAggregateWithCategory18 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class NoGroupByColumnsContext18 : 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 WithGroupByColumnsContext18 : 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_Source_GroupByColumns_Null_And_Target_Has_Columns() + { + using NoGroupByColumnsContext18 sourceContext = new(); + using WithGroupByColumnsContext18 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_Source_Has_GroupByColumns_And_Target_Null + + private class MetricEntity19 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregate19 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MetricAggregateWithCategory19 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class WithGroupByColumnsContext19 : 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 NoGroupByColumnsContext19 : 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); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_Source_Has_GroupByColumns_And_Target_Null() + { + using WithGroupByColumnsContext19 sourceContext = new(); + using NoGroupByColumnsContext19 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_Handle_Null_Source_Model + + private class MetricEntity20 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate20 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext20 : 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); + }); + } + } + + [Fact] + public void Should_Handle_Null_Source_Model() + { + using BasicContinuousAggregateContext20 targetContext = new(); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, targetModel); + + CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + } + + #endregion + + #region Should_Handle_Null_Target_Model + + private class MetricEntity21 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate21 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext21 : 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); + }); + } + } + + [Fact] + public void Should_Handle_Null_Target_Model() + { + using BasicContinuousAggregateContext21 sourceContext = new(); + IRelationalModel sourceModel = GetModel(sourceContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, null); + + DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + } + + #endregion + + #region Should_Handle_Both_Null_Models + + [Fact] + public void Should_Handle_Both_Null_Models() + { + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, null); + + Assert.Empty(operations); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/HypertableDifferTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/HypertableDifferTests.cs new file mode 100644 index 0000000..c098521 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/HypertableDifferTests.cs @@ -0,0 +1,843 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class HypertableDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region Should_Detect_New_Hypertable + + private class MetricEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyContext1 : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + } + } + + private class BasicHypertableContext1 : 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_New_Hypertable() + { + using EmptyContext1 emptyContext = new(); + using BasicHypertableContext1 hypertableContext = new(); + + IRelationalModel sourceModel = GetModel(emptyContext); + IRelationalModel targetModel = GetModel(hypertableContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + CreateHypertableOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + Assert.Equal("Metrics", createOp.TableName); + Assert.Equal("Timestamp", createOp.TimeColumnName); + Assert.Equal("7 days", createOp.ChunkTimeInterval); + } + + #endregion + + #region Should_Detect_Multiple_New_Hypertables + + private class MetricEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity2 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class EmptyContext2 : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + } + } + + private class MultipleHypertablesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_New_Hypertables() + { + using EmptyContext2 emptyContext = new(); + using MultipleHypertablesContext2 multiContext = new(); + + IRelationalModel sourceModel = GetModel(emptyContext); + IRelationalModel targetModel = GetModel(multiContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List createOps = [.. operations.OfType()]; + Assert.Equal(2, createOps.Count); + Assert.Contains(createOps, op => op.TableName == "Metrics"); + Assert.Contains(createOps, op => op.TableName == "Logs"); + } + + #endregion + + #region Should_Detect_Hypertable_With_Custom_ChunkInterval + + private class MetricEntity3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyContext3 : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + } + } + + private class CustomChunkIntervalContext3 : 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) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public void Should_Detect_Hypertable_With_Custom_ChunkInterval() + { + using EmptyContext3 emptyContext = new(); + using CustomChunkIntervalContext3 customContext = new(); + + IRelationalModel sourceModel = GetModel(emptyContext); + IRelationalModel targetModel = GetModel(customContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + CreateHypertableOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + Assert.Equal("1 day", createOp.ChunkTimeInterval); + } + + #endregion + + #region Should_Detect_ChunkTimeInterval_Change + + private class MetricEntity4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext4 : 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 CustomChunkIntervalContext4 : 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) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public void Should_Detect_ChunkTimeInterval_Change() + { + using BasicHypertableContext4 sourceContext = new(); + using CustomChunkIntervalContext4 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.Equal("Metrics", alterOp.TableName); + Assert.Equal("7 days", alterOp.OldChunkTimeInterval); + Assert.Equal("1 day", alterOp.ChunkTimeInterval); + } + + #endregion + + #region Should_Detect_EnableCompression_Change + + private class MetricEntity5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext5 : 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 CompressionEnabledContext5 : 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(); + }); + } + } + + [Fact] + public void Should_Detect_EnableCompression_Change() + { + using BasicHypertableContext5 sourceContext = new(); + using CompressionEnabledContext5 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.False(alterOp.OldEnableCompression); + Assert.True(alterOp.EnableCompression); + } + + #endregion + + #region Should_Detect_ChunkSkipColumns_Added + + private class MetricEntity6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext6 : 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 ChunkSkippingEnabledContext6 : 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); + }); + } + } + + [Fact] + public void Should_Detect_ChunkSkipColumns_Added() + { + using BasicHypertableContext6 sourceContext = new(); + using ChunkSkippingEnabledContext6 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.Null(alterOp.OldChunkSkipColumns); + Assert.NotNull(alterOp.ChunkSkipColumns); + Assert.Contains("Value", alterOp.ChunkSkipColumns); + } + + #endregion + + #region Should_Detect_ChunkSkipColumns_Modified + + private class MetricEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class ChunkSkippingEnabledContext7 : 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 ChunkSkippingModifiedContext7 : 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, x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Detect_ChunkSkipColumns_Modified() + { + using ChunkSkippingEnabledContext7 sourceContext = new(); + using ChunkSkippingModifiedContext7 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.Single(alterOp.OldChunkSkipColumns!); + Assert.Equal(2, alterOp.ChunkSkipColumns!.Count); + } + + #endregion + + #region Should_Detect_AdditionalDimensions_Added + + private class MetricEntity8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class BasicHypertableContext8 : 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 AdditionalDimensionsContext8 : 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)); + }); + } + } + + [Fact] + public void Should_Detect_AdditionalDimensions_Added() + { + using BasicHypertableContext8 sourceContext = new(); + using AdditionalDimensionsContext8 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.Null(alterOp.OldAdditionalDimensions); + Assert.NotNull(alterOp.AdditionalDimensions); + Dimension dimension = Assert.Single(alterOp.AdditionalDimensions); + Assert.Equal("DeviceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + } + + #endregion + + #region Should_Detect_Multiple_Property_Changes + + private class MetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class BasicHypertableContext9 : 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 FullyConfiguredHypertableContext9 : 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) + .WithChunkTimeInterval("1 day") + .EnableCompression() + .WithChunkSkipping(x => x.Value) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Property_Changes() + { + using BasicHypertableContext9 sourceContext = new(); + using FullyConfiguredHypertableContext9 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.Equal("1 day", alterOp.ChunkTimeInterval); + Assert.True(alterOp.EnableCompression); + Assert.NotNull(alterOp.ChunkSkipColumns); + Assert.NotNull(alterOp.AdditionalDimensions); + } + + #endregion + + #region Should_Not_Generate_Operations_When_No_Changes + + private class MetricEntity10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext10 : 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_Not_Generate_Operations_When_No_Changes() + { + using BasicHypertableContext10 sourceContext = new(); + using BasicHypertableContext10 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Not_Detect_Change_When_ChunkSkipColumns_Order_Different + + private class MetricEntity11 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class ChunkSkippingTwoColumnsContext11 : 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, x => x.DeviceId); + }); + } + } + + private class ChunkSkippingTwoColumnsReorderedContext11 : 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.DeviceId, x => x.Value); + }); + } + } + + [Fact] + public void Should_Not_Detect_Change_When_ChunkSkipColumns_Order_Different() + { + using ChunkSkippingTwoColumnsContext11 sourceContext = new(); + using ChunkSkippingTwoColumnsReorderedContext11 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Handle_Null_Source_Model + + private class MetricEntity12 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext12 : 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_Handle_Null_Source_Model() + { + using BasicHypertableContext12 targetContext = new(); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, targetModel); + + CreateHypertableOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + } + + #endregion + + #region Should_Handle_Null_Target_Model + + private class MetricEntity13 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext13 : 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_Handle_Null_Target_Model() + { + using BasicHypertableContext13 sourceContext = new(); + IRelationalModel sourceModel = GetModel(sourceContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, null); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Handle_Both_Null_Models + + [Fact] + public void Should_Handle_Both_Null_Models() + { + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, null); + + Assert.Empty(operations); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ReorderPolicyDifferTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ReorderPolicyDifferTests.cs new file mode 100644 index 0000000..dbff871 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Differs/ReorderPolicyDifferTests.cs @@ -0,0 +1,886 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class ReorderPolicyDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region Should_Detect_New_ReorderPolicy + + private class MetricEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext1 : 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); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class BasicReorderPolicyContext1 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_New_ReorderPolicy() + { + using HypertableWithoutPolicyContext1 sourceContext = new(); + using BasicReorderPolicyContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AddReorderPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("Metrics", addOp.TableName); + Assert.Equal("metrics_time_idx", addOp.IndexName); + Assert.Equal("1 day", addOp.ScheduleInterval); + } + + #endregion + + #region Should_Detect_Multiple_New_Policies + + private class MetricEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity2 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleHypertablesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("logs_time_idx"); + }); + } + } + + private class MultipleReorderPoliciesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("logs_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("logs_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_New_Policies() + { + using MultipleHypertablesContext2 sourceContext = new(); + using MultipleReorderPoliciesContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List addOps = [.. operations.OfType()]; + Assert.Equal(2, addOps.Count); + Assert.Contains(addOps, op => op.TableName == "Metrics"); + Assert.Contains(addOps, op => op.TableName == "Logs"); + } + + #endregion + + #region Should_Detect_Policy_With_Custom_Parameters + + private class MetricEntity3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext3 : 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); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class CustomScheduleReorderPolicyContext3 : 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); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00", + maxRetries: 5, + retryPeriod: "00:10:00" + ); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_Policy_With_Custom_Parameters() + { + using HypertableWithoutPolicyContext3 sourceContext = new(); + using CustomScheduleReorderPolicyContext3 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AddReorderPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("12:00:00", addOp.ScheduleInterval); + Assert.Equal("01:00:00", addOp.MaxRuntime); + Assert.Equal(5, addOp.MaxRetries); + Assert.Equal("00:10:00", addOp.RetryPeriod); + } + + #endregion + + #region Should_Detect_ScheduleInterval_Change + + private class MetricEntity4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext4 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class CustomScheduleReorderPolicyContext4 : 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); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00", + maxRetries: 5, + retryPeriod: "00:10:00" + ); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_ScheduleInterval_Change() + { + using BasicReorderPolicyContext4 sourceContext = new(); + using CustomScheduleReorderPolicyContext4 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterReorderPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("1 day", alterOp.OldScheduleInterval); + Assert.Equal("12:00:00", alterOp.ScheduleInterval); + } + + #endregion + + #region Should_Detect_IndexName_Change + + private class MetricEntity5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext5 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class DifferentIndexReorderPolicyContext5 : 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); + entity.WithReorderPolicy("metrics_timestamp_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_timestamp_idx"); + }); + } + } + + [Fact] + public void Should_Detect_IndexName_Change() + { + using BasicReorderPolicyContext5 sourceContext = new(); + using DifferentIndexReorderPolicyContext5 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterReorderPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("metrics_time_idx", alterOp.OldIndexName); + Assert.Equal("metrics_timestamp_idx", alterOp.IndexName); + } + + #endregion + + #region Should_Detect_MaxRetries_Change + + private class MetricEntity6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomScheduleReorderPolicyContext6 : 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); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00", + maxRetries: 5, + retryPeriod: "00:10:00" + ); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class ModifiedMaxRetriesContext6 : 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); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00", + maxRetries: 10, + retryPeriod: "00:10:00" + ); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_MaxRetries_Change() + { + using CustomScheduleReorderPolicyContext6 sourceContext = new(); + using ModifiedMaxRetriesContext6 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterReorderPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(5, alterOp.OldMaxRetries); + Assert.Equal(10, alterOp.MaxRetries); + } + + #endregion + + #region Should_Detect_Multiple_Parameter_Changes + + private class MetricEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext7 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class FullyCustomReorderPolicyContext7 : 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); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Parameter_Changes() + { + using BasicReorderPolicyContext7 sourceContext = new(); + using FullyCustomReorderPolicyContext7 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterReorderPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotEqual(alterOp.OldScheduleInterval, alterOp.ScheduleInterval); + Assert.NotEqual(alterOp.OldMaxRuntime, alterOp.MaxRuntime); + Assert.NotEqual(alterOp.OldMaxRetries, alterOp.MaxRetries); + Assert.NotEqual(alterOp.OldRetryPeriod, alterOp.RetryPeriod); + } + + #endregion + + #region Should_Detect_Dropped_ReorderPolicy + + private class MetricEntity8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext8 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class HypertableWithoutPolicyContext8 : 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); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_Dropped_ReorderPolicy() + { + using BasicReorderPolicyContext8 sourceContext = new(); + using HypertableWithoutPolicyContext8 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + DropReorderPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + Assert.Equal("Metrics", dropOp.TableName); + } + + #endregion + + #region Should_Detect_Multiple_Dropped_Policies + + private class MetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity9 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleReorderPoliciesContext9 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("logs_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("logs_time_idx"); + }); + } + } + + private class MultipleHypertablesContext9 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("logs_time_idx"); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Dropped_Policies() + { + using MultipleReorderPoliciesContext9 sourceContext = new(); + using MultipleHypertablesContext9 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List dropOps = [.. operations.OfType()]; + Assert.Equal(2, dropOps.Count); + } + + #endregion + + #region Should_Not_Generate_Operations_When_No_Changes + + private class MetricEntity10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext10 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_No_Changes() + { + using BasicReorderPolicyContext10 sourceContext = new(); + using BasicReorderPolicyContext10 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Handle_Null_Source_Model + + private class MetricEntity11 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext11 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Source_Model() + { + using BasicReorderPolicyContext11 targetContext = new(); + IRelationalModel targetModel = GetModel(targetContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, targetModel); + + AddReorderPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + } + + #endregion + + #region Should_Handle_Null_Target_Model + + private class MetricEntity12 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicReorderPolicyContext12 : 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); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Target_Model() + { + using BasicReorderPolicyContext12 sourceContext = new(); + IRelationalModel sourceModel = GetModel(sourceContext); + + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, null); + + DropReorderPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + } + + #endregion + + #region Should_Handle_Both_Null_Models + + [Fact] + public void Should_Handle_Both_Null_Models() + { + ReorderPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, null); + + Assert.Empty(operations); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs new file mode 100644 index 0000000..1bb9ef4 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -0,0 +1,1206 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; + +/// +/// Tests that verify ContinuousAggregateModelExtractor correctly extracts continuous aggregate configurations +/// from EF Core models and converts them to CreateContinuousAggregateOperation objects. +/// +public class ContinuousAggregateModelExtractorTests +{ + private static IRelationalModel GetRelationalModel(DbContext context) + { + IModel model = context.GetService().Model; + return model.GetRelationalModel(); + } + + #region Should_Extract_Minimal_ContinuousAggregate + + private class MinimalSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MinimalContinuousAggregateContext : 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 + ); + }); + } + } + + [Fact] + public void Should_Extract_Minimal_ContinuousAggregate() + { + using MinimalContinuousAggregateContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + CreateContinuousAggregateOperation operation = operations[0]; + Assert.Equal("hourly_metrics", operation.MaterializedViewName); + Assert.Equal("Metrics", operation.ParentName); + Assert.Equal("public", operation.Schema); + Assert.Equal("1 hour", operation.TimeBucketWidth); + Assert.Equal("Timestamp", operation.TimeBucketSourceColumn); + Assert.True(operation.TimeBucketGroupBy); + Assert.Null(operation.ChunkInterval); + Assert.False(operation.WithNoData); + Assert.False(operation.CreateGroupIndexes); + Assert.False(operation.MaterializedOnly); + Assert.Null(operation.WhereClause); + Assert.Empty(operation.AggregateFunctions); + Assert.Empty(operation.GroupByColumns); + } + + #endregion + + #region Should_Return_Empty_When_No_ContinuousAggregates + + private class NoAggregateSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoContinuousAggregateContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_No_ContinuousAggregates() + { + using NoContinuousAggregateContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Return_Empty_When_RelationalModel_Is_Null + + [Fact] + public void Should_Return_Empty_When_RelationalModel_Is_Null() + { + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(null)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Resolve_ParentName_To_TableName + + private class ParentNameSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ParentNameHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class ParentNameContext : 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 + ); + }); + } + } + + [Fact] + public void Should_Resolve_ParentName_To_TableName() + { + using ParentNameContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("Metrics", operations[0].ParentName); + } + + #endregion + + #region Should_Resolve_TimeBucketSourceColumn_With_Snake_Case_Convention + + private class SnakeCaseSourceMetric + { + public DateTime TimestampUtc { get; set; } + public double Value { get; set; } + } + + private class SnakeCaseHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class SnakeCaseContext : 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") + .UseSnakeCaseNamingConvention() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.TimestampUtc); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.TimestampUtc + ); + }); + } + } + + [Fact] + public void Should_Resolve_TimeBucketSourceColumn_With_Snake_Case_Convention() + { + using SnakeCaseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("timestamp_utc", operations[0].TimeBucketSourceColumn); + } + + #endregion + + #region Should_Extract_ChunkInterval + + private class ChunkIntervalSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChunkIntervalHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class ChunkIntervalContext : 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, + chunkInterval: "30 days" + ); + }); + } + } + + [Fact] + public void Should_Extract_ChunkInterval() + { + using ChunkIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("30 days", operations[0].ChunkInterval); + } + + #endregion + + #region Should_Extract_WithNoData_True + + private class WithNoDataSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class WithNoDataHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class WithNoDataContext : 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 + ).WithNoData(); + }); + } + } + + [Fact] + public void Should_Extract_WithNoData_True() + { + using WithNoDataContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].WithNoData); + } + + #endregion + + #region Should_Extract_CreateGroupIndexes_True + + private class CreateGroupIndexesSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateGroupIndexesHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class CreateGroupIndexesContext : 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 + ).CreateGroupIndexes(); + }); + } + } + + [Fact] + public void Should_Extract_CreateGroupIndexes_True() + { + using CreateGroupIndexesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].CreateGroupIndexes); + } + + #endregion + + #region Should_Extract_MaterializedOnly_True + + private class MaterializedOnlySourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnlyHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class MaterializedOnlyContext : 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 + ).MaterializedOnly(); + }); + } + } + + [Fact] + public void Should_Extract_MaterializedOnly_True() + { + using MaterializedOnlyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].MaterializedOnly); + } + + #endregion + + #region Should_Extract_TimeBucketGroupBy_False + + private class TimeBucketGroupByFalseSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimeBucketGroupByFalseHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class TimeBucketGroupByFalseContext : 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, + timeBucketGroupBy: false + ); + }); + } + } + + [Fact] + public void Should_Extract_TimeBucketGroupBy_False() + { + using TimeBucketGroupByFalseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.False(operations[0].TimeBucketGroupBy); + } + + #endregion + + #region Should_Extract_WhereClause + + private class WhereClauseSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class WhereClauseHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class WhereClauseContext : 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 + ).Where("Value > 0"); + }); + } + } + + [Fact] + public void Should_Extract_WhereClause() + { + using WhereClauseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("Value > 0", operations[0].WhereClause); + } + + #endregion + + #region Should_Extract_Single_AggregateFunction + + private class SingleAggregateFunctionSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SingleAggregateFunctionHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class SingleAggregateFunctionContext : 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.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public void Should_Extract_Single_AggregateFunction() + { + using SingleAggregateFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("AvgValue:Avg:Value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_Multiple_AggregateFunctions + + private class MultipleAggregateFunctionsSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleAggregateFunctionsHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + public double MinValue { get; set; } + public double MaxValue { get; set; } + } + + private class MultipleAggregateFunctionsContext : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MinValue, x => x.Value, EAggregateFunction.Min) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_AggregateFunctions() + { + using MultipleAggregateFunctionsContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(3, operations[0].AggregateFunctions.Count); + Assert.Contains("AvgValue:Avg:Value", operations[0].AggregateFunctions); + Assert.Contains("MinValue:Min:Value", operations[0].AggregateFunctions); + Assert.Contains("MaxValue:Max:Value", operations[0].AggregateFunctions); + } + + #endregion + + #region Should_Resolve_AggregateFunction_Column_Names_With_Naming_Convention + + private class AggregateFunctionSnakeCaseSourceMetric + { + public DateTime Timestamp { get; set; } + public double SensorValue { get; set; } + } + + private class AggregateFunctionSnakeCaseHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class AggregateFunctionSnakeCaseContext : 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") + .UseSnakeCaseNamingConvention() + .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.AvgValue, x => x.SensorValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Resolve_AggregateFunction_Column_Names_With_Naming_Convention() + { + using AggregateFunctionSnakeCaseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("avg_value:Avg:sensor_value", operations[0].AggregateFunctions[0]); + } + + #endregion + + #region Should_Extract_Single_GroupByColumn_From_Expression + + private class SingleGroupByColumnSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class SingleGroupByColumnHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class SingleGroupByColumnContext : 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 + ).AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_Single_GroupByColumn_From_Expression() + { + using SingleGroupByColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].GroupByColumns); + Assert.Equal("DeviceId", operations[0].GroupByColumns[0]); + } + + #endregion + + #region Should_Extract_Multiple_GroupByColumns + + private class MultipleGroupByColumnsSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class MultipleGroupByColumnsHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + } + + private class MultipleGroupByColumnsContext : 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 + ).AddGroupByColumn(x => x.DeviceId) + .AddGroupByColumn(x => x.Location); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_GroupByColumns() + { + using MultipleGroupByColumnsContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(2, operations[0].GroupByColumns.Count); + Assert.Contains("DeviceId", operations[0].GroupByColumns); + Assert.Contains("Location", operations[0].GroupByColumns); + } + + #endregion + + #region Should_Extract_RawSQL_GroupByColumn + + private class RawSQLGroupBySourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RawSQLGroupByHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class RawSQLGroupByContext : 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 + ).AddGroupByColumn("1, 2"); + }); + } + } + + [Fact] + public void Should_Extract_RawSQL_GroupByColumn() + { + using RawSQLGroupByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].GroupByColumns); + Assert.Equal("1, 2", operations[0].GroupByColumns[0]); + } + + #endregion + + #region Should_Resolve_GroupByColumn_Names_With_Naming_Convention + + private class GroupBySnakeCaseSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class GroupBySnakeCaseHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class GroupBySnakeCaseContext : 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") + .UseSnakeCaseNamingConvention() + .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 + ).AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Resolve_GroupByColumn_Names_With_Naming_Convention() + { + using GroupBySnakeCaseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].GroupByColumns); + Assert.Equal("device_id", operations[0].GroupByColumns[0]); + } + + #endregion + + #region Should_Extract_Multiple_ContinuousAggregates + + private class MultipleAggregatesSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleAggregatesHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class MultipleAggregatesSourceEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultipleAggregatesDailyEvent + { + public DateTime Bucket { get; set; } + public int EventCount { get; set; } + } + + private class MultipleAggregatesContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + public DbSet Events => Set(); + public DbSet DailyEvents => 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 + ); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Events"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "daily_events", + "1 day", + x => x.Timestamp + ); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_ContinuousAggregates() + { + using MultipleAggregatesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal(2, operations.Count); + Assert.Contains(operations, op => op.MaterializedViewName == "hourly_metrics"); + Assert.Contains(operations, op => op.MaterializedViewName == "daily_events"); + } + + #endregion + + #region Should_Extract_Fully_Configured_ContinuousAggregate + + private class FullyConfiguredSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class FullyConfiguredHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double AvgValue { get; set; } + public double MinValue { get; set; } + } + + private class FullyConfiguredContext : 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, + chunkInterval: "30 days" + ).WithNoData() + .CreateGroupIndexes() + .MaterializedOnly() + .Where("Value > 0") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MinValue, x => x.Value, EAggregateFunction.Min) + .AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_Fully_Configured_ContinuousAggregate() + { + using FullyConfiguredContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + CreateContinuousAggregateOperation operation = operations[0]; + Assert.Equal("hourly_metrics", operation.MaterializedViewName); + Assert.Equal("Metrics", operation.ParentName); + Assert.Equal("public", operation.Schema); + Assert.Equal("1 hour", operation.TimeBucketWidth); + Assert.Equal("Timestamp", operation.TimeBucketSourceColumn); + Assert.True(operation.TimeBucketGroupBy); + Assert.Equal("30 days", operation.ChunkInterval); + Assert.True(operation.WithNoData); + Assert.True(operation.CreateGroupIndexes); + Assert.True(operation.MaterializedOnly); + Assert.Equal("Value > 0", operation.WhereClause); + Assert.Equal(2, operation.AggregateFunctions.Count); + Assert.Single(operation.GroupByColumns); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/HypertableModelExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/HypertableModelExtractorTests.cs new file mode 100644 index 0000000..17731ac --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/HypertableModelExtractorTests.cs @@ -0,0 +1,773 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; + +public class HypertableModelExtractorTests +{ + private static IRelationalModel GetRelationalModel(DbContext context) + { + IModel model = context.GetService().Model; + return model.GetRelationalModel(); + } + + #region Should_Extract_Minimal_Hypertable + + private class MinimalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalHypertableContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_Minimal_Hypertable() + { + using MinimalHypertableContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Equal("Timestamp", operation.TimeColumnName); + Assert.Equal("7 days", operation.ChunkTimeInterval); + Assert.False(operation.EnableCompression); + Assert.Null(operation.ChunkSkipColumns); + Assert.Null(operation.AdditionalDimensions); + } + + #endregion + + #region Should_Return_Empty_When_No_Hypertables + + private class PlainEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class NoHypertableContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.ToTable("Plain"); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_No_Hypertables() + { + using NoHypertableContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Return_Empty_When_RelationalModel_Is_Null + + [Fact] + public void Should_Return_Empty_When_RelationalModel_Is_Null() + { + List operations = [.. HypertableModelExtractor.GetHypertables(null)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Resolve_Column_Names_With_Snake_Case_Convention + + private class SnakeCaseMetric + { + public DateTime TimestampUtc { get; set; } + public double Value { get; set; } + } + + private class SnakeCaseContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.TimestampUtc); + }); + } + } + + [Fact] + public void Should_Resolve_Column_Names_With_Snake_Case_Convention() + { + using SnakeCaseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + Assert.Equal("timestamp_utc", operation.TimeColumnName); + } + + #endregion + + #region Should_Extract_ChunkTimeInterval + + private class ChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChunkTimeIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public void Should_Extract_ChunkTimeInterval() + { + using ChunkTimeIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("1 day", operations[0].ChunkTimeInterval); + } + + #endregion + + #region Should_Use_Default_ChunkTimeInterval_When_Not_Specified + + private class DefaultChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultChunkIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Use_Default_ChunkTimeInterval_When_Not_Specified() + { + using DefaultChunkIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.ChunkTimeInterval, operations[0].ChunkTimeInterval); + } + + #endregion + + #region Should_Extract_EnableCompression_True + + private class CompressionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionEnabledContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(); + }); + } + } + + [Fact] + public void Should_Extract_EnableCompression_True() + { + using CompressionEnabledContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.True(operations[0].EnableCompression); + } + + #endregion + + #region Should_Extract_EnableCompression_False_By_Default + + private class NoCompressionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoCompressionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_EnableCompression_False_By_Default() + { + using NoCompressionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.False(operations[0].EnableCompression); + } + + #endregion + + #region Should_Extract_Single_ChunkSkipColumn + + private class SingleChunkSkipMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class SingleChunkSkipColumnContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_Single_ChunkSkipColumn() + { + using SingleChunkSkipColumnContext 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("DeviceId", column); + } + + #endregion + + #region Should_Extract_Multiple_ChunkSkipColumns + + private class MultipleChunkSkipMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class MultipleChunkSkipColumnsContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId, x => x.Location); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_ChunkSkipColumns() + { + using MultipleChunkSkipColumnsContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.NotNull(operations[0].ChunkSkipColumns); + Assert.Equal(2, operations[0].ChunkSkipColumns!.Count); + Assert.Contains("DeviceId", operations[0].ChunkSkipColumns!); + Assert.Contains("Location", operations[0].ChunkSkipColumns!); + } + + #endregion + + #region Should_Resolve_ChunkSkipColumns_With_Naming_Convention + + private class ChunkSkipSnakeCaseMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ChunkSkipSnakeCaseContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId); + }); + } + } + + [Fact] + public void Should_Resolve_ChunkSkipColumns_With_Naming_Convention() + { + using ChunkSkipSnakeCaseContext 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_id", column); + } + + #endregion + + #region Should_Extract_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 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void Should_Extract_Hash_Dimension() + { + using HashDimensionContext 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("DeviceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + Assert.Equal(4, dimension.NumberOfPartitions); + } + + #endregion + + #region Should_Extract_Range_Dimension + + private class RangeDimensionMetric + { + public DateTime Timestamp { get; set; } + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class RangeDimensionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("Location", "1000")); + }); + } + } + + [Fact] + public void Should_Extract_Range_Dimension() + { + using RangeDimensionContext 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("Location", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("1000", dimension.Interval); + } + + #endregion + + #region Should_Extract_Multiple_Dimensions + + private class MultipleDimensionsMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class MultipleDimensionsContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)) + .HasDimension(Dimension.CreateRange("Location", "1000")); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_Dimensions() + { + using MultipleDimensionsContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + Assert.NotNull(operations[0].AdditionalDimensions); + Assert.Equal(2, operations[0].AdditionalDimensions!.Count); + + Dimension hashDim = operations[0].AdditionalDimensions![0]; + Assert.Equal("DeviceId", hashDim.ColumnName); + Assert.Equal(EDimensionType.Hash, hashDim.Type); + Assert.Equal(4, hashDim.NumberOfPartitions); + + Dimension rangeDim = operations[0].AdditionalDimensions![1]; + Assert.Equal("Location", rangeDim.ColumnName); + Assert.Equal(EDimensionType.Range, rangeDim.Type); + Assert.Equal("1000", rangeDim.Interval); + } + + #endregion + + #region Should_Resolve_Dimension_Column_Names_With_Naming_Convention + + private class DimensionSnakeCaseMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class DimensionSnakeCaseContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() + .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 void Should_Resolve_Dimension_Column_Names_With_Naming_Convention() + { + using DimensionSnakeCaseContext 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_id", dimension.ColumnName); + } + + #endregion + + #region Should_Extract_Multiple_Hypertables + + private class MultipleHypertablesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleHypertablesEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultipleHypertablesContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => 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.ToTable("Events"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_Hypertables() + { + using MultipleHypertablesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Equal(2, operations.Count); + Assert.Contains(operations, op => op.TableName == "Metrics"); + Assert.Contains(operations, op => op.TableName == "Events"); + } + + #endregion + + #region Should_Extract_Fully_Configured_Hypertable + + private class FullyConfiguredMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class FullyConfiguredContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour") + .EnableCompression() + .WithChunkSkipping(x => x.DeviceId) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void Should_Extract_Fully_Configured_Hypertable() + { + using FullyConfiguredContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Equal("Timestamp", operation.TimeColumnName); + Assert.Equal("1 hour", operation.ChunkTimeInterval); + Assert.True(operation.EnableCompression); + Assert.NotNull(operation.ChunkSkipColumns); + Assert.Single(operation.ChunkSkipColumns); + Assert.Equal("DeviceId", operation.ChunkSkipColumns[0]); + Assert.NotNull(operation.AdditionalDimensions); + Dimension dimension = Assert.Single(operation.AdditionalDimensions); + Assert.Equal("DeviceId", dimension.ColumnName); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ReorderPolicyModelExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ReorderPolicyModelExtractorTests.cs new file mode 100644 index 0000000..387afd0 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Extractors/ReorderPolicyModelExtractorTests.cs @@ -0,0 +1,679 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; + +/// +/// Tests that verify ReorderPolicyModelExtractor correctly extracts reorder policy configurations +/// from EF Core models and converts them to AddReorderPolicyOperation objects. +/// +public class ReorderPolicyModelExtractorTests +{ + private static IRelationalModel GetRelationalModel(DbContext context) + { + IModel model = context.GetService().Model; + return model.GetRelationalModel(); + } + + #region Should_Extract_Minimal_ReorderPolicy + + private class MinimalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalReorderPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Extract_Minimal_ReorderPolicy() + { + using MinimalReorderPolicyContext 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("public", operation.Schema); + Assert.Equal("metrics_time_idx", operation.IndexName); + Assert.Null(operation.InitialStart); + Assert.Equal(DefaultValues.ReorderPolicyScheduleInterval, operation.ScheduleInterval); + Assert.Equal(DefaultValues.ReorderPolicyMaxRuntime, operation.MaxRuntime); + Assert.Equal(DefaultValues.ReorderPolicyMaxRetries, operation.MaxRetries); + Assert.Equal(DefaultValues.ReorderPolicyRetryPeriod, operation.RetryPeriod); + } + + #endregion + + #region Should_Return_Empty_When_No_ReorderPolicies + + private class NoReorderMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoReorderPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_No_ReorderPolicies() + { + using NoReorderPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Return_Empty_When_RelationalModel_Is_Null + + [Fact] + public void Should_Return_Empty_When_RelationalModel_Is_Null() + { + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(null)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Extract_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public void Should_Extract_InitialStart() + { + using InitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + DateTime? initialStart = operations[0].InitialStart; + Assert.NotNull(initialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, initialStart.Value); + } + + #endregion + + #region Should_Have_Null_InitialStart_When_Not_Specified + + private class NullInitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullInitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Have_Null_InitialStart_When_Not_Specified() + { + using NullInitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Null(operations[0].InitialStart); + } + + #endregion + + #region Should_Extract_ScheduleInterval + + private class ScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_ScheduleInterval() + { + using ScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("12:00:00", operations[0].ScheduleInterval); + } + + #endregion + + #region Should_Use_Default_ScheduleInterval_When_Not_Specified + + private class DefaultScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Use_Default_ScheduleInterval_When_Not_Specified() + { + using DefaultScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.ReorderPolicyScheduleInterval, operations[0].ScheduleInterval); + } + + #endregion + + #region Should_Extract_MaxRuntime + + private class MaxRuntimeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_MaxRuntime() + { + using MaxRuntimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("01:00:00", operations[0].MaxRuntime); + } + + #endregion + + #region Should_Use_Default_MaxRuntime_When_Not_Specified + + private class DefaultMaxRuntimeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Use_Default_MaxRuntime_When_Not_Specified() + { + using DefaultMaxRuntimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.ReorderPolicyMaxRuntime, operations[0].MaxRuntime); + } + + #endregion + + #region Should_Extract_MaxRetries + + private class MaxRetriesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public void Should_Extract_MaxRetries() + { + using MaxRetriesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(5, operations[0].MaxRetries); + } + + #endregion + + #region Should_Use_Default_MaxRetries_When_Not_Specified + + private class DefaultMaxRetriesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Use_Default_MaxRetries_When_Not_Specified() + { + using DefaultMaxRetriesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.ReorderPolicyMaxRetries, operations[0].MaxRetries); + } + + #endregion + + #region Should_Extract_RetryPeriod + + private class RetryPeriodMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_RetryPeriod() + { + using RetryPeriodContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("00:10:00", operations[0].RetryPeriod); + } + + #endregion + + #region Should_Use_Default_RetryPeriod_When_Not_Specified + + private class DefaultRetryPeriodMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultRetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void Should_Use_Default_RetryPeriod_When_Not_Specified() + { + using DefaultRetryPeriodContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.ReorderPolicyRetryPeriod, operations[0].RetryPeriod); + } + + #endregion + + #region Should_Extract_Multiple_ReorderPolicies + + private class MultipleMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultiplePoliciesContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => 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) + .WithReorderPolicy("metrics_time_idx"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Events"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("events_time_idx"); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_ReorderPolicies() + { + using MultiplePoliciesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + Assert.Equal(2, operations.Count); + Assert.Contains(operations, op => op.TableName == "Metrics"); + Assert.Contains(operations, op => op.TableName == "Events"); + } + + #endregion + + #region Should_Extract_Fully_Configured_ReorderPolicy + + private class FullyConfiguredMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy( + indexName: "metrics_time_idx", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_Fully_Configured_ReorderPolicy() + { + using FullyConfiguredContext 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("public", operation.Schema); + Assert.Equal("metrics_time_idx", operation.IndexName); + Assert.NotNull(operation.InitialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, operation.InitialStart.Value); + Assert.Equal("06:00:00", operation.ScheduleInterval); + Assert.Equal("02:00:00", operation.MaxRuntime); + Assert.Equal(3, operation.MaxRetries); + Assert.Equal("00:15:00", operation.RetryPeriod); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs new file mode 100644 index 0000000..8b8316f --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -0,0 +1,703 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + /// + /// Tests for ContinuousAggregateOperationGenerator ensuring correct SQL generation + /// according to TimescaleDB requirements for continuous aggregates. + /// + public class ContinuousAggregateOperationGeneratorTests + { + /// + /// Helper to run the generator and capture design-time C# code output. + /// + private static string GetDesignTimeCode(dynamic operation) + { + IndentedStringBuilder builder = new(); + ContinuousAggregateOperationGenerator generator = new(isDesignTime: true); + List statements = generator.Generate(operation); + SqlBuilderHelper.BuildQueryString(statements, builder); + return builder.ToString(); + } + + /// + /// Helper to run the generator and capture runtime SQL output. + /// + private static string GetRuntimeSql(dynamic operation) + { + ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); + List statements = generator.Generate(operation); + return string.Join("\n", statements); + } + + #region CreateContinuousAggregateOperation Tests - Design Time + + [Fact] + public void DesignTime_Create_MinimalAggregate_GeneratesCorrectCSharpCode() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_metrics", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"public"""".""""hourly_metrics"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS + SELECT time_bucket('1 hour', """"timestamp"""") AS time_bucket, AVG(""""value"""") AS """"avg_value"""" + FROM """"public"""".""""metrics"""" + GROUP BY time_bucket; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithAllStandardAggregates_GeneratesCorrectCode() + { + // Arrange - Test all standard aggregate functions (AVG, MAX, MIN, SUM, COUNT) + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "daily_stats", + Schema = "analytics", + ParentName = "sensor_data", + TimeBucketWidth = "1 day", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "avg_temp:Avg:temperature", + "max_temp:Max:temperature", + "min_temp:Min:temperature", + "total_readings:Count:id", + "sum_voltage:Sum:voltage" + ], + GroupByColumns = [], + CreateGroupIndexes = true, + MaterializedOnly = true, + WithNoData = true + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"analytics"""".""""daily_stats"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = true, timescaledb.materialized_only = true) AS + SELECT time_bucket('1 day', """"time"""") AS time_bucket, AVG(""""temperature"""") AS """"avg_temp"""", MAX(""""temperature"""") AS """"max_temp"""", MIN(""""temperature"""") AS """"min_temp"""", COUNT(""""id"""") AS """"total_readings"""", SUM(""""voltage"""") AS """"sum_voltage"""" + FROM """"analytics"""".""""sensor_data"""" + GROUP BY time_bucket + WITH NO DATA; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithTimescaleDBFirstLastFunctions_GeneratesCorrectSyntax() + { + // Arrange - TimescaleDB first() and last() require (value, time) parameter ordering + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "price_aggregates", + Schema = "public", + ParentName = "trades", + TimeBucketWidth = "5 minutes", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "first_price:First:price", + "last_price:Last:price" + ], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"public"""".""""price_aggregates"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS + SELECT time_bucket('5 minutes', """"timestamp"""") AS time_bucket, first(""""price"""", """"timestamp"""") AS """"first_price"""", last(""""price"""", """"timestamp"""") AS """"last_price"""" + FROM """"public"""".""""trades"""" + GROUP BY time_bucket; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithGroupByColumns_GeneratesCorrectGrouping() + { + // Arrange - Test GROUP BY with multiple columns + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "sales_by_region", + Schema = "public", + ParentName = "sales", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "sale_time", + TimeBucketGroupBy = true, + AggregateFunctions = ["total_amount:Sum:amount"], + GroupByColumns = ["region", "store_id"], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"public"""".""""sales_by_region"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS + SELECT time_bucket('1 hour', """"sale_time"""") AS time_bucket, """"region"""", """"store_id"""", SUM(""""amount"""") AS """"total_amount"""" + FROM """"public"""".""""sales"""" + GROUP BY time_bucket, """"region"""", """"store_id""""; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithWhereClause_GeneratesCorrectFiltering() + { + // Arrange - Test WHERE clause filtering + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "high_value_trades", + Schema = "public", + ParentName = "trades", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_price:Avg:price"], + GroupByColumns = [], + WhereClause = "\"price\" > 100 AND \"volume\" > 1000", + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"public"""".""""high_value_trades"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS + SELECT time_bucket('1 hour', """"timestamp"""") AS time_bucket, AVG(""""price"""") AS """"avg_price"""" + FROM """"public"""".""""trades"""" + WHERE """"price"""" > 100 AND """"volume"""" > 1000 + GROUP BY time_bucket; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithChunkInterval_GeneratesCorrectOption() + { + // Arrange - Test custom chunk_interval + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "monthly_summary", + Schema = "public", + ParentName = "events", + TimeBucketWidth = "1 month", + TimeBucketSourceColumn = "event_time", + TimeBucketGroupBy = true, + AggregateFunctions = ["event_count:Count:id"], + GroupByColumns = [], + ChunkInterval = "7 days", + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @".Sql(@"" + CREATE MATERIALIZED VIEW """"public"""".""""monthly_summary"""" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false, timescaledb.chunk_interval = '7 days') AS + SELECT time_bucket('1 month', """"event_time"""") AS time_bucket, COUNT(""""id"""") AS """"event_count"""" + FROM """"public"""".""""events"""" + GROUP BY time_bucket; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region CreateContinuousAggregateOperation Tests - Runtime + + [Fact] + public void Runtime_Create_MinimalAggregate_GeneratesCorrectSQL() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_metrics", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Runtime uses single quotes for SQL + Assert.Contains("CREATE MATERIALIZED VIEW \"public\".\"hourly_metrics\"", result); + Assert.Contains("WITH (timescaledb.continuous", result); + Assert.Contains("time_bucket('1 hour', \"timestamp\")", result); + Assert.Contains("AVG(\"value\") AS \"avg_value\"", result); + Assert.Contains("FROM \"public\".\"metrics\"", result); + Assert.Contains("GROUP BY time_bucket", result); + Assert.DoesNotContain("WITH NO DATA", result); + } + + [Fact] + public void Runtime_Create_WithFirstLast_UsesCorrectParameterOrder() + { + // Arrange - Verify TimescaleDB first()/last() parameter ordering + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "price_extremes", + Schema = "public", + ParentName = "trades", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "opening_price:First:price", + "closing_price:Last:price" + ], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - first() and last() must have (value, time) ordering + Assert.Contains("first(\"price\", \"time\") AS \"opening_price\"", result); + Assert.Contains("last(\"price\", \"time\") AS \"closing_price\"", result); + } + + [Fact] + public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() + { + // Arrange - Test comprehensive continuous aggregate + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "comprehensive_stats", + Schema = "analytics", + ParentName = "sensor_readings", + TimeBucketWidth = "30 minutes", + TimeBucketSourceColumn = "recorded_at", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "avg_temp:Avg:temperature", + "max_humidity:Max:humidity", + "min_pressure:Min:pressure", + "total_samples:Count:id", + "first_reading:First:temperature", + "last_reading:Last:temperature" + ], + GroupByColumns = ["sensor_id", "location"], + WhereClause = "\"temperature\" IS NOT NULL", + ChunkInterval = "1 day", + CreateGroupIndexes = true, + MaterializedOnly = true, + WithNoData = true + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert all SQL components + Assert.Contains("CREATE MATERIALIZED VIEW \"analytics\".\"comprehensive_stats\"", result); + Assert.Contains("timescaledb.continuous", result); + Assert.Contains("timescaledb.create_group_indexes = true", result); + Assert.Contains("timescaledb.materialized_only = true", result); + Assert.Contains("timescaledb.chunk_interval = '1 day'", result); + Assert.Contains("time_bucket('30 minutes', \"recorded_at\") AS time_bucket", result); + Assert.Contains("\"sensor_id\"", result); + Assert.Contains("\"location\"", result); + Assert.Contains("AVG(\"temperature\") AS \"avg_temp\"", result); + Assert.Contains("MAX(\"humidity\") AS \"max_humidity\"", result); + Assert.Contains("MIN(\"pressure\") AS \"min_pressure\"", result); + Assert.Contains("COUNT(\"id\") AS \"total_samples\"", result); + Assert.Contains("first(\"temperature\", \"recorded_at\") AS \"first_reading\"", result); + Assert.Contains("last(\"temperature\", \"recorded_at\") AS \"last_reading\"", result); + Assert.Contains("WHERE \"temperature\" IS NOT NULL", result); + Assert.Contains("GROUP BY time_bucket, \"sensor_id\", \"location\"", result); + Assert.Contains("WITH NO DATA", result); + } + + #endregion + + #region AlterContinuousAggregateOperation Tests + + [Fact] + public void DesignTime_Alter_ChunkInterval_GeneratesCorrectCode() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ChunkInterval = "30 days", + OldChunkInterval = "7 days" + }; + + string expected = @".Sql(@"" + ALTER MATERIALIZED VIEW """"public"""".""""hourly_stats"""" SET (timescaledb.chunk_interval = '30 days'); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Runtime_Alter_ChunkInterval_GeneratesCorrectSQL() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "daily_aggregates", + Schema = "analytics", + ChunkInterval = "90 days", + OldChunkInterval = "30 days" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("ALTER MATERIALIZED VIEW \"analytics\".\"daily_aggregates\"", result); + Assert.Contains("SET (timescaledb.chunk_interval = '90 days')", result); + } + + [Fact] + public void DesignTime_Alter_CreateGroupIndexes_GeneratesCorrectCode() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "metrics_view", + Schema = "public", + CreateGroupIndexes = true, + OldCreateGroupIndexes = false + }; + + string expected = @".Sql(@"" + ALTER MATERIALIZED VIEW """"public"""".""""metrics_view"""" SET (timescaledb.create_group_indexes = true); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_MaterializedOnly_GeneratesCorrectCode() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "stats_view", + Schema = "public", + MaterializedOnly = false, + OldMaterializedOnly = true + }; + + string expected = @".Sql(@"" + ALTER MATERIALIZED VIEW """"public"""".""""stats_view"""" SET (timescaledb.materialized_only = false); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() + { + // Arrange - Test altering multiple properties at once + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "complex_view", + Schema = "analytics", + ChunkInterval = "60 days", + OldChunkInterval = "30 days", + CreateGroupIndexes = true, + OldCreateGroupIndexes = false, + MaterializedOnly = false, + OldMaterializedOnly = true + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should generate three separate ALTER statements + Assert.Contains("timescaledb.chunk_interval = '60 days'", result); + Assert.Contains("timescaledb.create_group_indexes = true", result); + Assert.Contains("timescaledb.materialized_only = false", result); + } + + [Fact] + public void Alter_NoChanges_GeneratesNoSQL() + { + // Arrange - Nothing changed + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "unchanged_view", + Schema = "public", + ChunkInterval = "7 days", + OldChunkInterval = "7 days", + CreateGroupIndexes = false, + OldCreateGroupIndexes = false, + MaterializedOnly = false, + OldMaterializedOnly = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should generate empty result + Assert.Empty(result.Trim()); + } + + #endregion + + #region DropContinuousAggregateOperation Tests + + [Fact] + public void DesignTime_Drop_GeneratesCorrectCode() + { + // Arrange + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = "old_aggregate", + Schema = "public" + }; + + string expected = @".Sql(@"" + DROP MATERIALIZED VIEW IF EXISTS """"public"""".""""old_aggregate""""; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Runtime_Drop_GeneratesCorrectSQL() + { + // Arrange + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = "deprecated_view", + Schema = "analytics" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("DROP MATERIALIZED VIEW IF EXISTS \"analytics\".\"deprecated_view\"", result); + Assert.EndsWith(";", result.Trim()); + } + + [Fact] + public void Runtime_Drop_UsesIfExists_ForSafety() + { + // Arrange - Verify IF EXISTS is always used for safety + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = "maybe_exists", + Schema = "public" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - IF EXISTS prevents errors if view doesn't exist + Assert.Contains("IF EXISTS", result); + } + + #endregion + + #region TimescaleDB Constraint Validation Tests + + [Fact] + public void Create_RequiresTimeBucket_InSelectClause() + { + // Arrange - TimescaleDB requires time_bucket in continuous aggregates + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "test_view", + Schema = "public", + ParentName = "test_table", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["count_all:Count:id"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - time_bucket is required by TimescaleDB + Assert.Contains("time_bucket(", result); + Assert.Contains("AS time_bucket", result); + } + + [Fact] + public void Create_RequiresTimeBucket_InGroupByClause() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "test_view", + Schema = "public", + ParentName = "test_table", + TimeBucketWidth = "1 day", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_val:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - GROUP BY must include time_bucket + Assert.Contains("GROUP BY time_bucket", result); + } + + [Fact] + public void Create_FirstAndLast_RequireTimeParameter() + { + // Arrange - TimescaleDB first() and last() must have time column + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "first_last_test", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "ts", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "first_val:First:value", + "last_val:Last:value" + ], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - first() and last() MUST have (value, time) signature + Assert.Contains("first(\"value\", \"ts\")", result); + Assert.Contains("last(\"value\", \"ts\")", result); + Assert.DoesNotContain("first(\"value\")", result); + Assert.DoesNotContain("last(\"value\")", result); + } + + [Fact] + public void Create_StandardAggregates_DoNotRequireTimeParameter() + { + // Arrange - Standard SQL aggregates (AVG, MAX, MIN, SUM, COUNT) don't need time + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "standard_agg_test", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = + [ + "avg_temp:Avg:temperature", + "max_temp:Max:temperature", + "count_all:Count:id" + ], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Standard aggregates use single parameter + Assert.Contains("AVG(\"temperature\")", result); + Assert.Contains("MAX(\"temperature\")", result); + Assert.Contains("COUNT(\"id\")", result); + Assert.DoesNotContain("AVG(\"temperature\", \"time\")", result); + } + + #endregion + } +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs new file mode 100644 index 0000000..61c9677 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -0,0 +1,736 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + /// + /// Comprehensive tests for HypertableOperationGenerator validating design-time and runtime + /// SQL generation according to TimescaleDB requirements. + /// + /// TimescaleDB Requirements (researched from official docs): + /// - create_hypertable(relation, by_range/by_hash) - modern API (v2.13+) + /// - set_chunk_time_interval() - accepts INTERVAL or bigint (microseconds) + /// - Compression requires ALTER TABLE SET (timescaledb.compress = true/false) + /// - enable_chunk_skipping() requires compression to be enabled first + /// - add_dimension() uses by_hash(column, partitions) or by_range(column, interval) + /// - Dimensions can only be added to empty hypertables (in practice, add during creation) + /// + public class HypertableOperationGeneratorComprehensiveTests + { + /// + /// Helper to run the generator and capture design-time C# code output. + /// + private static string GetDesignTimeCode(dynamic operation) + { + IndentedStringBuilder builder = new(); + HypertableOperationGenerator generator = new(isDesignTime: true); + List statements = generator.Generate(operation); + SqlBuilderHelper.BuildQueryString(statements, builder); + return builder.ToString(); + } + + /// + /// Helper to run the generator and capture runtime SQL output. + /// + private static string GetRuntimeSql(dynamic operation) + { + HypertableOperationGenerator generator = new(isDesignTime: false); + List statements = generator.Generate(operation); + return string.Join("\n", statements); + } + + #region CreateHypertableOperation - Design Time Tests + + [Fact] + public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() + { + // Arrange - Test by_range() dimension syntax + CreateHypertableOperation operation = new() + { + TableName = "events", + Schema = "public", + TimeColumnName = "event_time", + ChunkTimeInterval = "1 day", + AdditionalDimensions = + [ + Dimension.CreateRange("received_time", "7 days") + ] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""events""""', 'event_time'); + SELECT set_chunk_time_interval('public.""""events""""', INTERVAL '1 day'); + SELECT add_dimension('public.""""events""""', by_range('received_time', INTERVAL '7 days')); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithMultipleDimensions_GeneratesCorrectOrder() + { + // Arrange - Test multiple dimensions (hash + range) + CreateHypertableOperation operation = new() + { + TableName = "distributed_events", + Schema = "public", + TimeColumnName = "timestamp", + AdditionalDimensions = + [ + Dimension.CreateHash("device_id", 4), + Dimension.CreateRange("processed_time", "1 month") + ] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""distributed_events""""', 'timestamp'); + SELECT add_dimension('public.""""distributed_events""""', by_hash('device_id', 4)); + SELECT add_dimension('public.""""distributed_events""""', by_range('processed_time', INTERVAL '1 month')); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorrectCode() + { + // Arrange - Test bigint interval (microseconds) + CreateHypertableOperation operation = new() + { + TableName = "high_freq_data", + Schema = "public", + TimeColumnName = "ts", + ChunkTimeInterval = "86400000000" // 1 day in microseconds + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""high_freq_data""""', 'ts'); + SELECT set_chunk_time_interval('public.""""high_freq_data""""', 86400000000::bigint); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCode() + { + // Arrange - Compression enabled but no chunk skipping + CreateHypertableOperation operation = new() + { + TableName = "compressed_data", + Schema = "public", + TimeColumnName = "time", + EnableCompression = true + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""compressed_data""""', 'time'); + ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_ChunkSkippingAutoEnablesCompression_GeneratesCorrectCode() + { + // Arrange - Chunk skipping automatically enables compression (TimescaleDB requirement) + CreateHypertableOperation operation = new() + { + TableName = "skippable_chunks", + Schema = "public", + TimeColumnName = "timestamp", + EnableCompression = false, // Explicitly false + ChunkSkipColumns = ["device_id", "sensor_type"] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Compression should be auto-enabled + Assert.Contains("timescaledb.compress = true", result); + } + + #endregion + + #region CreateHypertableOperation - Runtime Tests + + [Fact] + public void Runtime_Create_Minimal_GeneratesCorrectSQL() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "simple_table", + Schema = "public", + TimeColumnName = "time" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Runtime uses single quotes (not doubled) + Assert.Contains("SELECT create_hypertable('public.\"simple_table\"', 'time')", result); + Assert.EndsWith(";", result.Trim()); + } + + [Fact] + public void Runtime_Create_WithIntervalString_UsesIntervalKeyword() + { + // Arrange - String intervals should use INTERVAL keyword + CreateHypertableOperation operation = new() + { + TableName = "timed_data", + Schema = "public", + TimeColumnName = "timestamp", + ChunkTimeInterval = "7 days" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("INTERVAL '7 days'", result); + Assert.DoesNotContain("::bigint", result); + } + + [Fact] + public void Runtime_Create_WithNumericInterval_UsesBigintCast() + { + // Arrange - Numeric intervals should use ::bigint cast + CreateHypertableOperation operation = new() + { + TableName = "numeric_interval", + Schema = "public", + TimeColumnName = "ts", + ChunkTimeInterval = "604800000000" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("604800000000::bigint", result); + Assert.DoesNotContain("INTERVAL", result); + } + + [Fact] + public void Runtime_Create_WithHashDimension_GeneratesByHashSyntax() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "partitioned", + Schema = "public", + TimeColumnName = "time", + AdditionalDimensions = + [ + Dimension.CreateHash("location_id", 8) + ] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Must use by_hash() with partition count + Assert.Contains("add_dimension('public.\"partitioned\"', by_hash('location_id', 8))", result); + } + + [Fact] + public void Runtime_Create_WithRangeDimension_GeneratesByRangeSyntax() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "ranged", + Schema = "public", + TimeColumnName = "time", + AdditionalDimensions = + [ + Dimension.CreateRange("secondary_time", "30 days") + ] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Must use by_range() with interval + Assert.Contains("add_dimension('public.\"ranged\"', by_range('secondary_time', INTERVAL '30 days'))", result); + } + + #endregion + + #region AlterHypertableOperation - Design Time Tests + + [Fact] + public void DesignTime_Alter_ChangingChunkInterval_FromStringToString_GeneratesCorrectCode() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + ChunkTimeInterval = "1 day", + OldChunkTimeInterval = "7 days" + }; + + string expected = @".Sql(@"" + SELECT set_chunk_time_interval('public.""""metrics""""', INTERVAL '1 day'); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_ChangingChunkInterval_FromStringToNumeric_GeneratesCorrectCode() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + ChunkTimeInterval = "86400000000", // Numeric (microseconds) + OldChunkTimeInterval = "1 day" // String interval + }; + + string expected = @".Sql(@"" + SELECT set_chunk_time_interval('public.""""metrics""""', 86400000000::bigint); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_AddingDimension_GeneratesCorrectCode() + { + // Arrange - Adding a new dimension to existing hypertable + AlterHypertableOperation operation = new() + { + TableName = "expandable", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateHash("user_id", 4) + ], + OldAdditionalDimensions = [] + }; + + string expected = @".Sql(@"" + SELECT add_dimension('public.""""expandable""""', by_hash('user_id', 4)); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_RemovingDimension_GeneratesWarningComment() + { + // Arrange - TimescaleDB does NOT support removing dimensions + AlterHypertableOperation operation = new() + { + TableName = "cannot_remove", + Schema = "public", + AdditionalDimensions = [], + OldAdditionalDimensions = + [ + Dimension.CreateHash("old_column", 4) + ] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should include warning comment + Assert.Contains("WARNING", result); + Assert.Contains("does not support removing dimensions", result); + Assert.Contains("old_column", result); + } + + [Fact] + public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() + { + // Arrange - Changing dimension parameters (adds new, warns about old) + AlterHypertableOperation operation = new() + { + TableName = "modified_dims", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateHash("location", 8) // Changed from 4 to 8 partitions + ], + OldAdditionalDimensions = + [ + Dimension.CreateHash("location", 4) + ] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - New dimension added (old one cannot be removed) + Assert.Contains("by_hash('location', 8)", result); + } + + [Fact] + public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "decompress", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true + }; + + string expected = @".Sql(@"" + ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() + { + // Arrange - Adding new chunk skip columns + AlterHypertableOperation operation = new() + { + TableName = "add_skip", + Schema = "public", + ChunkSkipColumns = ["col1", "col2", "col3"], + OldChunkSkipColumns = ["col1"] + }; + + string expected = @".Sql(@"" + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('public.""""add_skip""""', 'col2'); + SELECT enable_chunk_skipping('public.""""add_skip""""', 'col3'); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "remove_skip", + Schema = "public", + ChunkSkipColumns = ["keep_this"], + OldChunkSkipColumns = ["keep_this", "remove_this"] + }; + + string expected = @".Sql(@"" + SELECT disable_chunk_skipping('public.""""remove_skip""""', 'remove_this'); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region AlterHypertableOperation - Runtime Tests + + [Fact] + public void Runtime_Alter_ChunkInterval_GeneratesCorrectSQL() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "adjust_chunks", + Schema = "analytics", + ChunkTimeInterval = "2 weeks", + OldChunkTimeInterval = "1 week" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("set_chunk_time_interval('analytics.\"adjust_chunks\"', INTERVAL '2 weeks')", result); + } + + [Fact] + public void Runtime_Alter_EnableCompression_GeneratesCorrectSQL() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "enable_compress", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("ALTER TABLE \"public\".\"enable_compress\" SET (timescaledb.compress = true)", result); + } + + [Fact] + public void Runtime_Alter_DisableCompression_GeneratesCorrectSQL() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "disable_compress", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("ALTER TABLE \"public\".\"disable_compress\" SET (timescaledb.compress = false)", result); + } + + [Fact] + public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "skip_test", + Schema = "public", + ChunkSkipColumns = ["new_col"], + OldChunkSkipColumns = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Must SET enable_chunk_skipping = 'ON' before enable_chunk_skipping() + Assert.Contains("SET timescaledb.enable_chunk_skipping = 'ON'", result); + Assert.Contains("enable_chunk_skipping('public.\"skip_test\"', 'new_col')", result); + } + + #endregion + + #region TimescaleDB Constraint Validation Tests + + [Fact] + public void Create_ChunkSkipping_RequiresCompression() + { + // Arrange - TimescaleDB requires compression for chunk skipping + CreateHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + TimeColumnName = "time", + EnableCompression = false, + ChunkSkipColumns = ["col1"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Compression must be automatically enabled + Assert.Contains("timescaledb.compress = true", result); + } + + [Fact] + public void Alter_AddingChunkSkipping_AutoEnablesCompression() + { + // Arrange - Adding chunk skip columns when compression is disabled + AlterHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + EnableCompression = false, + OldEnableCompression = false, + ChunkSkipColumns = ["device_id"], + OldChunkSkipColumns = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Compression should be auto-enabled + Assert.Contains("ALTER TABLE \"public\".\"test\" SET (timescaledb.compress = true)", result); + } + + [Fact] + public void Alter_RemovingAllChunkSkipColumns_CanDisableCompression() + { + // Arrange - Removing all chunk skip columns when compression not explicitly enabled + AlterHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + EnableCompression = false, + OldEnableCompression = false, + ChunkSkipColumns = [], + OldChunkSkipColumns = ["col1", "col2"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Compression can be disabled when no chunk skipping + Assert.Contains("timescaledb.compress = false", result); + Assert.Contains("disable_chunk_skipping", result); + } + + [Fact] + public void Alter_KeepingExplicitCompression_WhenRemovingChunkSkipping() + { + // Arrange - Compression explicitly enabled, removing chunk skip columns + AlterHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + ChunkSkipColumns = [], + OldChunkSkipColumns = ["col1"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Compression should remain enabled + Assert.DoesNotContain("timescaledb.compress = false", result); + Assert.Contains("disable_chunk_skipping", result); + } + + [Fact] + public void Create_EmptyHypertable_OnlyGeneratesCreateStatement() + { + // Arrange - Minimal hypertable + CreateHypertableOperation operation = new() + { + TableName = "minimal", + Schema = "public", + TimeColumnName = "ts" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Single(result.Split('\n', StringSplitOptions.RemoveEmptyEntries)); + Assert.Contains("create_hypertable", result); + } + + [Fact] + public void Create_Dimensions_GeneratedAfterHypertableCreation() + { + // Arrange - Dimensions must be added after create_hypertable + CreateHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + TimeColumnName = "time", + AdditionalDimensions = + [ + Dimension.CreateHash("location", 4) + ] + }; + + // Act + string result = GetRuntimeSql(operation); + string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Assert - create_hypertable must come before add_dimension + int createIndex = Array.FindIndex(lines, l => l.Contains("create_hypertable")); + int dimensionIndex = Array.FindIndex(lines, l => l.Contains("add_dimension")); + Assert.True(createIndex < dimensionIndex, "create_hypertable must execute before add_dimension"); + } + + [Fact] + public void Create_Compression_GeneratedAfterHypertableCreation() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + TimeColumnName = "time", + EnableCompression = true + }; + + // Act + string result = GetRuntimeSql(operation); + string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Assert - create_hypertable must come before ALTER TABLE + int createIndex = Array.FindIndex(lines, l => l.Contains("create_hypertable")); + int compressIndex = Array.FindIndex(lines, l => l.Contains("ALTER TABLE")); + Assert.True(createIndex < compressIndex, "create_hypertable must execute before compression settings"); + } + + [Fact] + public void Create_ChunkSkipping_GeneratedAfterCompression() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "test", + Schema = "public", + TimeColumnName = "time", + ChunkSkipColumns = ["col1"] + }; + + // Act + string result = GetRuntimeSql(operation); + string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Assert - Compression (ALTER TABLE) must come before chunk skipping + int compressIndex = Array.FindIndex(lines, l => l.Contains("ALTER TABLE") && l.Contains("compress")); + int skipIndex = Array.FindIndex(lines, l => l.Contains("enable_chunk_skipping")); + Assert.True(compressIndex < skipIndex, "Compression must be enabled before chunk skipping"); + } + + #endregion + } +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs new file mode 100644 index 0000000..c31733c --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs @@ -0,0 +1,615 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + /// + /// Comprehensive tests for ReorderPolicyOperationGenerator validating design-time and runtime + /// SQL generation according to TimescaleDB requirements. + /// + public class ReorderPolicyOperationGeneratorComprehensiveTests + { + /// + /// Helper to run the generator and capture design-time C# code output. + /// + private static string GetDesignTimeCode(dynamic operation) + { + IndentedStringBuilder builder = new(); + ReorderPolicyOperationGenerator generator = new(isDesignTime: true); + List statements = generator.Generate(operation); + SqlBuilderHelper.BuildQueryString(statements, builder); + return builder.ToString(); + } + + /// + /// Helper to run the generator and capture runtime SQL output. + /// + private static List GetRuntimeSqlStatements(dynamic operation) + { + ReorderPolicyOperationGenerator generator = new(isDesignTime: false); + return generator.Generate(operation); + } + + /// + /// Helper to get combined SQL output as single string. + /// + private static string GetRuntimeSql(dynamic operation) + { + List statements = GetRuntimeSqlStatements(operation); + return string.Join("\n", statements); + } + + #region AddReorderPolicyOperation - Design Time Tests + + [Fact] + public void DesignTime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() + { + // Arrange - Minimal reorder policy with only required fields (no alter_job when using defaults) + AddReorderPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public", + IndexName = "metrics_time_idx" + }; + + string expected = @".Sql(@"" + SELECT add_reorder_policy('public.""""metrics""""', 'metrics_time_idx'); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should only generate add_reorder_policy, not alter_job + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Add_WithAllOptions_GeneratesCorrectCode() + { + // Arrange - Policy with all optional parameters + DateTime initialStart = new(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc); + AddReorderPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "analytics", + IndexName = "sensor_data_time_device_idx", + InitialStart = initialStart, + ScheduleInterval = "6 hours", + MaxRuntime = "30 minutes", + MaxRetries = 5, + RetryPeriod = "2 minutes" + }; + + string expected = $@".Sql(@"" + SELECT add_reorder_policy('analytics.""""sensor_data""""', 'sensor_data_time_device_idx', initial_start => '{initialStart:yyyy-MM-ddTHH:mm:ss.fffffffZ}'); + SELECT alter_job(job_id, schedule_interval => INTERVAL '6 hours', max_runtime => INTERVAL '30 minutes', max_retries => 5, retry_period => INTERVAL '2 minutes') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'analytics' AND hypertable_name = 'sensor_data'; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Add_WithUnlimitedRetries_UsesNegativeOne() + { + // Arrange - MaxRetries = -1 means unlimited (TimescaleDB convention) + AddReorderPolicyOperation operation = new() + { + TableName = "important_data", + Schema = "public", + IndexName = "time_idx", + MaxRetries = -1 + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should use max_retries => -1 for unlimited + Assert.Contains("max_retries => -1", result); + } + + [Fact] + public void DesignTime_Add_WithNoMaxRuntime_UsesZeroInterval() + { + // Arrange - MaxRuntime = "00:00:00" means no limit (TimescaleDB convention) + AddReorderPolicyOperation operation = new() + { + TableName = "data", + Schema = "public", + IndexName = "idx", + MaxRuntime = "00:00:00" + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should use INTERVAL '00:00:00' for no limit + Assert.Contains("INTERVAL '00:00:00'", result); + } + + [Fact] + public void DesignTime_Add_WithInitialStart_FormatsAsISO8601() + { + // Arrange + DateTime testDate = new(2025, 3, 15, 10, 30, 45, 123, DateTimeKind.Utc); + AddReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + IndexName = "idx", + InitialStart = testDate + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Should use ISO 8601 format with microseconds and Z suffix + Assert.Contains("2025-03-15T10:30:45", result); + Assert.Contains("Z", result); + } + + #endregion + + #region AddReorderPolicyOperation - Runtime Tests + + [Fact] + public void Runtime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() + { + // Arrange - Minimal policy without custom scheduling + AddReorderPolicyOperation operation = new() + { + TableName = "simple_table", + Schema = "public", + IndexName = "simple_idx" + }; + + // Act + List statements = GetRuntimeSqlStatements(operation); + + // Assert - Should only generate add_reorder_policy (uses TimescaleDB defaults) + Assert.Single(statements); + Assert.Contains("SELECT add_reorder_policy('public.\"simple_table\"', 'simple_idx')", statements[0]); + Assert.DoesNotContain("alter_job", statements[0]); + } + + [Fact] + public void Runtime_Add_WithoutInitialStart_OmitsParameter() + { + // Arrange - No InitialStart specified + AddReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + IndexName = "idx" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should not include initial_start parameter + Assert.DoesNotContain("initial_start =>", result); + } + + [Fact] + public void Runtime_Add_WithCustomSchedule_QueriesCorrectJobView() + { + // Arrange - Add custom schedule to trigger alter_job generation + AddReorderPolicyOperation operation = new() + { + TableName = "my_table", + Schema = "my_schema", + IndexName = "my_idx", + ScheduleInterval = "6 hours" // Non-default triggers alter_job + }; + + // Act + List statements = GetRuntimeSqlStatements(operation); + string alterJobSql = statements[1]; // alter_job is the second statement + + // Assert - Must query timescaledb_information.jobs to find job_id + Assert.Equal(2, statements.Count); + Assert.Contains("timescaledb_information.jobs", alterJobSql); + Assert.Contains("proc_name = 'policy_reorder'", alterJobSql); + Assert.Contains("hypertable_schema = 'my_schema'", alterJobSql); + Assert.Contains("hypertable_name = 'my_table'", alterJobSql); + } + + #endregion + + #region AlterReorderPolicyOperation - Design Time Tests + + [Fact] + public void DesignTime_Alter_ScheduleInterval_GeneratesCorrectCode() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public", + ScheduleInterval = "12 hours", + OldScheduleInterval = "1 day" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, schedule_interval => INTERVAL '12 hours') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'metrics'; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_MaxRuntime_GeneratesCorrectCode() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + TableName = "data", + Schema = "public", + MaxRuntime = "1 hour", + OldMaxRuntime = "30 minutes" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, max_runtime => INTERVAL '1 hour') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'data'; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_MaxRetries_GeneratesCorrectCode() + { + // Arrange - Changing from unlimited (-1) to limited (3) + AlterReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + MaxRetries = 3, + OldMaxRetries = -1 + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, max_retries => 3) + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'test'; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_RetryPeriod_GeneratesCorrectCode() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + RetryPeriod = "10 minutes", + OldRetryPeriod = "5 minutes" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, retry_period => INTERVAL '10 minutes') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'test'; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_MultipleProperties_GeneratesCombinedStatement() + { + // Arrange - Altering multiple job properties at once + AlterReorderPolicyOperation operation = new() + { + TableName = "complex", + Schema = "public", + ScheduleInterval = "2 days", + OldScheduleInterval = "1 day", + MaxRuntime = "2 hours", + OldMaxRuntime = "1 hour", + MaxRetries = 10, + OldMaxRetries = 5 + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - All changes should be in one alter_job call + Assert.Contains("schedule_interval => INTERVAL '2 days'", result); + Assert.Contains("max_runtime => INTERVAL '2 hours'", result); + Assert.Contains("max_retries => 10", result); + } + + [Fact] + public void Alter_NoChanges_GeneratesNoSQL() + { + // Arrange - Nothing changed + AlterReorderPolicyOperation operation = new() + { + TableName = "unchanged", + Schema = "public", + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day", + MaxRetries = 3, + OldMaxRetries = 3 + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should generate empty result + Assert.Empty(result.Trim()); + } + + #endregion + + #region AlterReorderPolicyOperation - Runtime Tests + + [Fact] + public void Runtime_Alter_SingleProperty_GeneratesCorrectSQL() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "analytics", + ScheduleInterval = "4 hours", + OldScheduleInterval = "6 hours" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("SELECT alter_job(job_id, schedule_interval => INTERVAL '4 hours')", result); + Assert.Contains("FROM timescaledb_information.jobs", result); + } + + [Fact] + public void Runtime_Alter_ChangingToUnlimitedRetries_UsesNegativeOne() + { + // Arrange - Changing to unlimited retries + AlterReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + MaxRetries = -1, + OldMaxRetries = 5 + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("max_retries => -1", result); + } + + #endregion + + #region DropReorderPolicyOperation - Design Time Tests + + [Fact] + public void DesignTime_Drop_GeneratesCorrectCode() + { + // Arrange + DropReorderPolicyOperation operation = new() + { + TableName = "old_table", + Schema = "public" + }; + + string expected = @".Sql(@"" + SELECT remove_reorder_policy('public.""""old_table""""', if_exists => true); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Drop_WithCustomSchema_GeneratesCorrectCode() + { + // Arrange + DropReorderPolicyOperation operation = new() + { + TableName = "analytics_data", + Schema = "analytics" + }; + + string expected = @".Sql(@"" + SELECT remove_reorder_policy('analytics.""""analytics_data""""', if_exists => true); + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region DropReorderPolicyOperation - Runtime Tests + + [Fact] + public void Runtime_Drop_GeneratesCorrectSQL() + { + // Arrange + DropReorderPolicyOperation operation = new() + { + TableName = "remove_this", + Schema = "public" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Runtime uses single quotes + Assert.Contains("SELECT remove_reorder_policy('public.\"remove_this\"', if_exists => true)", result); + Assert.EndsWith(";", result.Trim()); + } + + [Fact] + public void Runtime_Drop_AlwaysUsesIfExists() + { + // Arrange + DropReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - if_exists => true prevents errors + Assert.Contains("if_exists => true", result); + } + + #endregion + + #region TimescaleDB Constraint Validation Tests + + [Fact] + public void Add_DefaultValues_MatchTimescaleDBDefaults() + { + // Arrange - Using default values from DefaultValues.cs + AddReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + IndexName = "idx", + ScheduleInterval = "1 day", // Default + MaxRuntime = "00:00:00", // Default (no limit) + MaxRetries = -1, // Default (unlimited) + RetryPeriod = "00:05:00" // Default (5 minutes) + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should match TimescaleDB defaults + Assert.Contains("schedule_interval => INTERVAL '1 day'", result); + Assert.Contains("max_runtime => INTERVAL '00:00:00'", result); + Assert.Contains("max_retries => -1", result); + Assert.Contains("retry_period => INTERVAL '00:05:00'", result); + } + + [Fact] + public void Add_WithCustomSchedule_RequiresAlterJob_AfterAddReorderPolicy() + { + // Arrange - Provide custom schedule to trigger alter_job + AddReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + IndexName = "idx", + ScheduleInterval = "12 hours" // Triggers alter_job + }; + + // Act + List statements = GetRuntimeSqlStatements(operation); + + // Assert - add_reorder_policy must come before alter_job + Assert.Equal(2, statements.Count); + Assert.Contains("add_reorder_policy", statements[0]); + Assert.Contains("alter_job", statements[1]); + } + + [Fact] + public void Alter_QueriesJobByTableAndSchema() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + TableName = "specific_table", + Schema = "specific_schema", + ScheduleInterval = "2 hours", + OldScheduleInterval = "1 hour" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Must identify correct job by schema and table name + Assert.Contains("hypertable_schema = 'specific_schema'", result); + Assert.Contains("hypertable_name = 'specific_table'", result); + Assert.Contains("proc_name = 'policy_reorder'", result); + } + + [Fact] + public void Add_IntervalFormat_AcceptsVariousFormats() + { + // Arrange - TimescaleDB accepts various interval formats + AddReorderPolicyOperation operation = new() + { + TableName = "test", + Schema = "public", + IndexName = "idx", + ScheduleInterval = "2 days", + MaxRuntime = "30 minutes", + RetryPeriod = "1 hour" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - All interval formats should work + Assert.Contains("INTERVAL '2 days'", result); + Assert.Contains("INTERVAL '30 minutes'", result); + Assert.Contains("INTERVAL '1 hour'", result); + } + + [Fact] + public void Drop_SafeOperation_UsesIfExists() + { + // Arrange - Drop should be safe operation + DropReorderPolicyOperation operation = new() + { + TableName = "maybe_has_policy", + Schema = "public" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - if_exists prevents errors if policy doesn't exist + Assert.Contains("if_exists => true", result); + } + + #endregion + } +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateIntegrationTests.cs index 5e3df5f..54cea23 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -6,14 +6,13 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration { - public class ContinuousAggregateIntegrationTests : IAsyncLifetime + public class ContinuousAggregateIntegrationTests : MigrationTestBase, IAsyncLifetime { private PostgreSqlContainer? _container; private string? _connectionString; public async Task InitializeAsync() { - // Arrange: Start TimescaleDB container _container = new PostgreSqlBuilder() .WithImage("timescale/timescaledb:latest-pg16") .WithDatabase("test_db") @@ -33,376 +32,9 @@ public async Task DisposeAsync() } } - [Fact] - public async Task Should_Create_ContinuousAggregate_With_BasicAggregates() - { - // Arrange: Create context with hypertable and continuous aggregate using basic aggregates - await using var context = new BasicAggregatesTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await InsertTradeDataAsync(context); - - // Act: Refresh the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_basic', NULL, NULL);"); - - var aggregates = await context.TradeAggregates - .OrderBy(a => a.TimeBucket) - .ToListAsync(); - - // Assert: Verify aggregates were calculated correctly - Assert.NotEmpty(aggregates); - var firstAggregate = aggregates.First(); - Assert.True(firstAggregate.AvgPrice > 0); - Assert.True(firstAggregate.MaxPrice >= firstAggregate.MinPrice); - Assert.True(firstAggregate.SumPrice > 0); - Assert.True(firstAggregate.CountPrice > 0); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_FirstAndLast_Functions() - { - // Arrange: Create context with First and Last aggregate functions - await using var context = new FirstLastTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data with specific timestamps - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'), - ('2025-01-06 10:30:00+00', 'AAPL', 105.00, 200, 'NYSE'), - ('2025-01-06 10:45:00+00', 'AAPL', 103.00, 150, 'NYSE'); - "); - - // Act: Refresh the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_first_last', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify first() returns earliest value and last() returns latest value - Assert.Single(aggregates); - Assert.Equal(100.00m, aggregates[0].FirstPrice); // First price at 10:00 - Assert.Equal(103.00m, aggregates[0].LastPrice); // Last price at 10:45 - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_GroupByColumns() - { - // Arrange: Create context with GROUP BY columns - await using var context = new GroupByTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data for different exchanges - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'), - ('2025-01-06 10:00:00+00', 'AAPL', 110.00, 200, 'NASDAQ'), - ('2025-01-06 10:00:00+00', 'AAPL', 105.00, 150, 'LSE'); - "); - - // Act: Refresh the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_grouped', NULL, NULL);"); - - var aggregates = await context.TradeAggregates - .OrderBy(a => a.Exchange) - .ToListAsync(); - - // Assert: Verify we have one aggregate per exchange - Assert.Equal(3, aggregates.Count); - Assert.Equal("LSE", aggregates[0].Exchange); - Assert.Equal(105.00m, aggregates[0].AvgPrice); - Assert.Equal("NASDAQ", aggregates[1].Exchange); - Assert.Equal(110.00m, aggregates[1].AvgPrice); - Assert.Equal("NYSE", aggregates[2].Exchange); - Assert.Equal(100.00m, aggregates[2].AvgPrice); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_WhereClause() - { - // Arrange: Create context with WHERE clause to filter data - await using var context = new WhereClauseTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data with different tickers - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'), - ('2025-01-06 10:00:00+00', 'TSLA', 200.00, 200, 'NYSE'), - ('2025-01-06 10:00:00+00', 'MSFT', 300.00, 150, 'NYSE'); - "); - - // Act: Refresh the continuous aggregate (should only include AAPL) - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_filtered', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify only AAPL data is included - Assert.Single(aggregates); - Assert.Equal(100.00m, aggregates[0].AvgPrice); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_WithNoData_Option() - { - // Arrange: Create context with WITH NO DATA option - await using var context = new WithNoDataTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'); - "); - - // Act: Query the continuous aggregate (should be empty because of WITH NO DATA) - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify no data is materialized initially - Assert.Empty(aggregates); - - // Now refresh and verify data appears - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_no_data', NULL, NULL);"); - - aggregates = await context.TradeAggregates.ToListAsync(); - Assert.Single(aggregates); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_CustomChunkInterval() - { - // Arrange: Create context with custom chunk_interval = "1 day" - await using var context = new CustomChunkIntervalTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await InsertTradeDataAsync(context); - - // Act: Refresh and query the aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_custom_chunk', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify the continuous aggregate works correctly - Assert.NotEmpty(aggregates); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_CreateGroupIndexes() - { - // Arrange: Create context with create_group_indexes = true - await using var context = new CreateGroupIndexesTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await InsertTradeDataAsync(context); - - // Act: Refresh and query the aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_with_indexes', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify aggregates were created (indexes are internal, hard to verify directly) - Assert.NotEmpty(aggregates); - } - - [Fact] - public async Task Should_Create_ContinuousAggregate_With_MaterializedOnly_False() - { - // Arrange: Create context with materialized_only = false (allows real-time aggregation) - await using var context = new MaterializedOnlyFalseTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'); - "); + #region Should_Create_ContinuousAggregate_With_BasicAggregates - // Act: Query without explicit refresh (should include real-time data) - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify we can see data even without manual refresh - Assert.Single(aggregates); - Assert.Equal(100.00m, aggregates[0].AvgPrice); - } - - [Fact] - public async Task Should_Alter_ContinuousAggregate_ChunkInterval() - { - // Arrange: Create context with initial chunk_interval = "7 days" - await using var context1 = new AlterChunkIntervalContext_Before(_connectionString!); - await context1.Database.EnsureCreatedAsync(); - - // Insert test data and refresh - await InsertTradeDataAsync(context1); - await context1.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_alterable', NULL, NULL);"); - - var aggregatesBefore = await context1.TradeAggregates.ToListAsync(); - Assert.NotEmpty(aggregatesBefore); - - // Act: Alter the chunk_interval to "14 days" - await using var context2 = new AlterChunkIntervalContext_After(_connectionString!); - - await context2.Database.ExecuteSqlRawAsync(@" - ALTER MATERIALIZED VIEW trade_aggregate_alterable - SET (timescaledb.chunk_interval = '14 days'); - "); - - // Assert: Verify we can still query the aggregate after altering chunk_interval - var aggregatesAfter = await context2.TradeAggregates.ToListAsync(); - Assert.NotEmpty(aggregatesAfter); - Assert.Equal(aggregatesBefore.Count, aggregatesAfter.Count); - } - - [Fact] - public async Task Should_Alter_ContinuousAggregate_MaterializedOnly() - { - // Arrange: Create context with materialized_only = false - await using var context = new AlterMaterializedOnlyTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data and refresh - await InsertTradeDataAsync(context); - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_materialized_only', NULL, NULL);"); - - // Act: Alter materialized_only to true - await context.Database.ExecuteSqlRawAsync(@" - ALTER MATERIALIZED VIEW trade_aggregate_materialized_only - SET (timescaledb.materialized_only = true); - "); - - // Assert: Verify we can still query the aggregate after alteration - var aggregates = await context.TradeAggregates.ToListAsync(); - Assert.NotEmpty(aggregates); - } - - [Fact] - public async Task Should_Alter_ContinuousAggregate_CreateGroupIndexes() - { - // NOTE: TimescaleDB does not support altering create_group_indexes after creation - // This test verifies that the option is set during creation but cannot be altered - - // Arrange: Create context with create_group_indexes = false - await using var context = new AlterCreateGroupIndexesTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Act & Assert: Attempting to alter create_group_indexes should fail - await Assert.ThrowsAsync(async () => - { - await context.Database.ExecuteSqlRawAsync(@" - ALTER MATERIALIZED VIEW trade_aggregate_group_indexes - SET (timescaledb.create_group_indexes = true); - "); - }); - } - - [Fact] - public async Task Should_Drop_ContinuousAggregate_Successfully() - { - // Arrange: Create context with continuous aggregate - await using var context = new DropTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert data and refresh to ensure aggregate has data - await InsertTradeDataAsync(context); - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_to_drop', NULL, NULL);"); - - // Verify we can query the aggregate before dropping - var aggregatesBefore = await context.TradeAggregates.ToListAsync(); - Assert.NotEmpty(aggregatesBefore); - - // Act: Drop the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "DROP MATERIALIZED VIEW IF EXISTS trade_aggregate_to_drop;"); - - // Assert: Verify we cannot query the aggregate after dropping (should throw) - await Assert.ThrowsAsync(async () => - { - await context.TradeAggregates.ToListAsync(); - }); - } - - [Fact] - public async Task Should_Generate_Correct_SQL_For_ContinuousAggregate() - { - // Arrange: Create context and ensure database is created - await using var context = new SqlGenerationTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await InsertTradeDataAsync(context); - - // Act: Refresh and query the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_sql_gen', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify the continuous aggregate works correctly - Assert.NotEmpty(aggregates); - var firstAggregate = aggregates.First(); - Assert.True(firstAggregate.AvgPrice > 0); - } - - [Fact] - public async Task Should_Handle_SnakeCase_Naming_Convention() - { - // Arrange: Create context with snake_case naming convention - await using var context = new SnakeCaseTestContext(_connectionString!); - await context.Database.EnsureCreatedAsync(); - - // Insert test data - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO trades (timestamp, ticker, price, size, exchange) - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 100.00, 100, 'NYSE'); - "); - - // Act: Refresh and query the continuous aggregate - await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_snake_case', NULL, NULL);"); - - var aggregates = await context.TradeAggregates.ToListAsync(); - - // Assert: Verify snake_case columns work correctly - Assert.Single(aggregates); - Assert.Equal(100.00m, aggregates[0].avg_price); - } - - #region Helper Methods - - private async Task InsertTradeDataAsync(DbContext context) - { - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES - ('2025-01-06 10:00:00+00', 'AAPL', 150.50, 100, 'NYSE'), - ('2025-01-06 10:30:00+00', 'AAPL', 151.00, 200, 'NYSE'), - ('2025-01-06 10:45:00+00', 'AAPL', 149.75, 150, 'NYSE'); - "); - } - - #endregion - - #region Test Models - - private class TestTrade + private class BasicAggregatesTrade { public DateTime Timestamp { get; set; } public string Ticker { get; set; } = string.Empty; @@ -411,7 +43,7 @@ private class TestTrade public string Exchange { get; set; } = string.Empty; } - private class BasicAggregatesTestAggregate + private class BasicAggregatesAggregate { public DateTime TimeBucket { get; set; } public decimal AvgPrice { get; set; } @@ -421,69 +53,27 @@ private class BasicAggregatesTestAggregate public long CountPrice { get; set; } } - private class FirstLastTestAggregate + private class BasicAggregatesContext(string connectionString) : DbContext { - public DateTime TimeBucket { get; set; } - public decimal FirstPrice { get; set; } - public decimal LastPrice { get; set; } - } - - private class GroupByTestAggregate - { - public DateTime TimeBucket { get; set; } - public string Exchange { get; set; } = string.Empty; - public decimal AvgPrice { get; set; } - } - - private class WhereClauseTestAggregate - { - public DateTime TimeBucket { get; set; } - public decimal AvgPrice { get; set; } - } - - private class SnakeCaseTestAggregate - { - public DateTime time_bucket { get; set; } - public decimal avg_price { get; set; } - } - - #endregion - - #region Test Contexts - - private class BasicAggregatesTestContext : DbContext - { - private readonly string _connectionString; - - public BasicAggregatesTestContext(string connectionString) - { - _connectionString = connectionString; - } - - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Configure Trade as a hypertable - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - // Configure continuous aggregate with all basic aggregate functions - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_basic", "1 hour", x => x.Timestamp) @@ -493,7 +83,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .AddAggregateFunction(x => x.SumPrice, x => x.Price, EAggregateFunction.Sum) .AddAggregateFunction(x => x.CountPrice, x => x.Price, EAggregateFunction.Count); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); entity.Property(x => x.MaxPrice).HasColumnName("MaxPrice"); @@ -504,44 +93,81 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - private class FirstLastTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_BasicAggregates() { - private readonly string _connectionString; + await using BasicAggregatesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public FirstLastTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_basic', NULL, NULL);"); - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + List aggregates = await context.TradeAggregates + .OrderBy(a => a.TimeBucket) + .ToListAsync(); + + Assert.NotEmpty(aggregates); + BasicAggregatesAggregate firstAggregate = aggregates.First(); + Assert.True(firstAggregate.AvgPrice > 0); + Assert.True(firstAggregate.MaxPrice >= firstAggregate.MinPrice); + Assert.True(firstAggregate.SumPrice > 0); + Assert.True(firstAggregate.CountPrice > 0); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_FirstAndLast_Functions + + private class FirstLastTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class FirstLastAggregate + { + public DateTime TimeBucket { get; set; } + public decimal FirstPrice { get; set; } + public decimal LastPrice { get; set; } + } + + private class FirstLastContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_first_last", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.FirstPrice, x => x.Price, EAggregateFunction.First) .AddAggregateFunction(x => x.LastPrice, x => x.Price, EAggregateFunction.Last); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.FirstPrice).HasColumnName("FirstPrice"); entity.Property(x => x.LastPrice).HasColumnName("LastPrice"); @@ -549,44 +175,76 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - private class GroupByTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_FirstAndLast_Functions() { - private readonly string _connectionString; + await using FirstLastContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public GroupByTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {105.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {103.00m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_first_last', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.Single(aggregates); + Assert.Equal(100.00m, aggregates[0].FirstPrice); + Assert.Equal(103.00m, aggregates[0].LastPrice); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_GroupByColumns + + private class GroupByTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class GroupByAggregate + { + public DateTime TimeBucket { get; set; } + public string Exchange { get; set; } = string.Empty; + public decimal AvgPrice { get; set; } + } + + private class GroupByContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_grouped", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) .AddGroupByColumn(x => x.Exchange); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.Exchange).HasColumnName("Exchange"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); @@ -594,170 +252,297 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - private class WhereClauseTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_GroupByColumns() { - private readonly string _connectionString; + await using GroupByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public WhereClauseTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {110.00m}, {200}, {"NASDAQ"}), + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {105.00m}, {150}, {"LSE"})"); - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_grouped', NULL, NULL);"); + + List aggregates = await context.TradeAggregates + .OrderBy(a => a.Exchange) + .ToListAsync(); + + Assert.Equal(3, aggregates.Count); + Assert.Equal("LSE", aggregates[0].Exchange); + Assert.Equal(105.00m, aggregates[0].AvgPrice); + Assert.Equal("NASDAQ", aggregates[1].Exchange); + Assert.Equal(110.00m, aggregates[1].AvgPrice); + Assert.Equal("NYSE", aggregates[2].Exchange); + Assert.Equal(100.00m, aggregates[2].AvgPrice); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_WhereClause + + private class WhereClauseTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class WhereClauseAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } + + private class WhereClauseContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_filtered", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) .Where("\"Ticker\" = 'AAPL'"); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class WithNoDataTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_WhereClause() { - private readonly string _connectionString; + await using WhereClauseContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public WithNoDataTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"TSLA"}, {200.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"MSFT"}, {300.00m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_filtered', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.Single(aggregates); + Assert.Equal(100.00m, aggregates[0].AvgPrice); + } + + #endregion - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + #region Should_Create_ContinuousAggregate_WithNoData_Option + + private class WithNoDataTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class WithNoDataAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } + + private class WithNoDataContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_no_data", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) .WithNoData(true) - .MaterializedOnly(true); // Disable real-time aggregation so WITH NO DATA takes effect + .MaterializedOnly(true); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class CustomChunkIntervalTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_WithNoData_Option() { - private readonly string _connectionString; + await using WithNoDataContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public CustomChunkIntervalTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + + List aggregates = await context.TradeAggregates.ToListAsync(); - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + Assert.Empty(aggregates); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_no_data', NULL, NULL);"); + + aggregates = await context.TradeAggregates.ToListAsync(); + Assert.Single(aggregates); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_CustomChunkInterval + + private class CustomChunkIntervalTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class CustomChunkIntervalAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } + + private class CustomChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_custom_chunk", "1 hour", x => x.Timestamp, - chukInterval: "1 day") + chunkInterval: "1 day") .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class CreateGroupIndexesTestContext : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_CustomChunkInterval() { - private readonly string _connectionString; + await using CustomChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public CreateGroupIndexesTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_custom_chunk', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.NotEmpty(aggregates); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_CreateGroupIndexes + + private class CreateGroupIndexesTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class CreateGroupIndexesAggregate + { + public DateTime TimeBucket { get; set; } + public string Exchange { get; set; } = string.Empty; + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class CreateGroupIndexesContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_with_indexes", "1 hour", x => x.Timestamp) @@ -765,221 +550,336 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .AddGroupByColumn(x => x.Exchange) .CreateGroupIndexes(true); - // Map properties to view columns - entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); - entity.Property(x => x.Exchange).HasColumnName("Exchange"); - entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); - }); - } + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.Property(x => x.Exchange).HasColumnName("Exchange"); + entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); + }); + } + } + + [Fact] + public async Task Should_Create_ContinuousAggregate_With_CreateGroupIndexes() + { + await using CreateGroupIndexesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_with_indexes', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.NotEmpty(aggregates); } - private class MaterializedOnlyFalseTestContext : DbContext + #endregion + + #region Should_Create_ContinuousAggregate_With_MaterializedOnly_False + + private class MaterializedOnlyFalseTrade { - private readonly string _connectionString; + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } - public MaterializedOnlyFalseTestContext(string connectionString) - { - _connectionString = connectionString; - } + private class MaterializedOnlyFalseAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class MaterializedOnlyFalseContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_realtime", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) .MaterializedOnly(false); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class AlterChunkIntervalContext_Before : DbContext + [Fact] + public async Task Should_Create_ContinuousAggregate_With_MaterializedOnly_False() { - private readonly string _connectionString; + await using MaterializedOnlyFalseContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public AlterChunkIntervalContext_Before(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.Single(aggregates); + Assert.Equal(100.00m, aggregates[0].AvgPrice); + } + + #endregion + + #region Should_Alter_ContinuousAggregate_ChunkInterval + + private class AlterChunkIntervalTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class AlterChunkIntervalAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class AlterChunkIntervalInitialContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_alterable", "1 hour", x => x.Timestamp, - chukInterval: "7 days") + chunkInterval: "7 days") .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class AlterChunkIntervalContext_After : DbContext + private class AlterChunkIntervalModifiedContext(string connectionString) : DbContext { - private readonly string _connectionString; - - public AlterChunkIntervalContext_After(string connectionString) - { - _connectionString = connectionString; - } - - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_alterable", "1 hour", x => x.Timestamp, - chukInterval: "14 days") + chunkInterval: "14 days") // <-- Changed from "7 days" .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class AlterMaterializedOnlyTestContext : DbContext + [Fact] + public async Task Should_Alter_ContinuousAggregate_ChunkInterval() + { + await using AlterChunkIntervalInitialContext context1 = new(_connectionString!); + await context1.Database.EnsureCreatedAsync(); + + await context1.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context1.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_alterable', NULL, NULL);"); + + List aggregatesBefore = await context1.TradeAggregates.ToListAsync(); + Assert.NotEmpty(aggregatesBefore); + + await using AlterChunkIntervalModifiedContext context2 = new(_connectionString!); + + await context2.Database.ExecuteSqlRawAsync(@" + ALTER MATERIALIZED VIEW trade_aggregate_alterable + SET (timescaledb.chunk_interval = '14 days'); + "); + + List aggregatesAfter = await context2.TradeAggregates.ToListAsync(); + Assert.NotEmpty(aggregatesAfter); + Assert.Equal(aggregatesBefore.Count, aggregatesAfter.Count); + } + + #endregion + + #region Should_Alter_ContinuousAggregate_MaterializedOnly + + private class AlterMaterializedOnlyTrade { - private readonly string _connectionString; + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } - public AlterMaterializedOnlyTestContext(string connectionString) - { - _connectionString = connectionString; - } + private class AlterMaterializedOnlyAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class AlterMaterializedOnlyContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_materialized_only", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) .MaterializedOnly(false); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class AlterCreateGroupIndexesTestContext : DbContext + [Fact] + public async Task Should_Alter_ContinuousAggregate_MaterializedOnly() { - private readonly string _connectionString; + await using AlterMaterializedOnlyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public AlterCreateGroupIndexesTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_materialized_only', NULL, NULL);"); + + await context.Database.ExecuteSqlRawAsync(@" + ALTER MATERIALIZED VIEW trade_aggregate_materialized_only + SET (timescaledb.materialized_only = true); + "); + + List aggregates = await context.TradeAggregates.ToListAsync(); + Assert.NotEmpty(aggregates); + } + + #endregion + + #region Should_Alter_ContinuousAggregate_CreateGroupIndexes + + private class AlterGroupIndexesTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class AlterGroupIndexesAggregate + { + public DateTime TimeBucket { get; set; } + public string Exchange { get; set; } = string.Empty; + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class AlterGroupIndexesContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_group_indexes", "1 hour", x => x.Timestamp) @@ -987,7 +887,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .AddGroupByColumn(x => x.Exchange) .CreateGroupIndexes(false); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.Exchange).HasColumnName("Exchange"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); @@ -995,134 +894,245 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - private class DropTestContext : DbContext + [Fact] + public async Task Should_Alter_ContinuousAggregate_CreateGroupIndexes() { - private readonly string _connectionString; + await using AlterGroupIndexesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public DropTestContext(string connectionString) + await Assert.ThrowsAsync(async () => { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlRawAsync(@" + ALTER MATERIALIZED VIEW trade_aggregate_group_indexes + SET (timescaledb.create_group_indexes = true); + "); + }); + } + + #endregion + + #region Should_Drop_ContinuousAggregate_Successfully + + private class DropTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class DropAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + private class DropContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_to_drop", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class SqlGenerationTestContext : DbContext + [Fact] + public async Task Should_Drop_ContinuousAggregate_Successfully() { - private readonly string _connectionString; + await using DropContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_to_drop', NULL, NULL);"); + + List aggregatesBefore = await context.TradeAggregates.ToListAsync(); + Assert.NotEmpty(aggregatesBefore); + + await context.Database.ExecuteSqlRawAsync( + "DROP MATERIALIZED VIEW IF EXISTS trade_aggregate_to_drop;"); - public SqlGenerationTestContext(string connectionString) + await Assert.ThrowsAsync(async () => { - _connectionString = connectionString; - } + await context.TradeAggregates.ToListAsync(); + }); + } + + #endregion - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + #region Should_Generate_Correct_SQL_For_ContinuousAggregate + + private class SqlGenerationTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class SqlGenerationAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } + + private class SqlGenerationContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString) - .UseTimescaleDb(); - } + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("Trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "trade_aggregate_sql_gen", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); - // Map properties to view columns entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgPrice).HasColumnName("AvgPrice"); }); } } - private class SnakeCaseTestContext : DbContext + [Fact] + public async Task Should_Generate_Correct_SQL_For_ContinuousAggregate() { - private readonly string _connectionString; + await using SqlGenerationContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); - public SnakeCaseTestContext(string connectionString) - { - _connectionString = connectionString; - } + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.trade_aggregate_sql_gen', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.NotEmpty(aggregates); + SqlGenerationAggregate firstAggregate = aggregates.First(); + Assert.True(firstAggregate.AvgPrice > 0); + } - public DbSet Trades => Set(); - public DbSet TradeAggregates => Set(); + #endregion + + #region Should_Handle_SnakeCase_Naming_Convention + + private class SnakeCaseTrade + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Size { get; set; } + public string Exchange { get; set; } = string.Empty; + } + + private class SnakeCaseAggregate + { + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } + } + + private class SnakeCaseContext(string connectionString) : DbContext + { + public DbSet Trades => Set(); + public DbSet TradeAggregates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseNpgsql(_connectionString) + optionsBuilder.UseNpgsql(connectionString) .UseSnakeCaseNamingConvention() .UseTimescaleDb(); } protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.ToTable("trades"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( - "trade_aggregate_snake_case", + entity.IsContinuousAggregate( + "snake_case_test_aggregate", "1 hour", x => x.Timestamp) - .AddAggregateFunction(x => x.avg_price, x => x.Price, EAggregateFunction.Avg); - - // Note: snake_case convention is applied automatically, so time_bucket and avg_price are already correct + .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); }); } } + [Fact] + public async Task Should_Handle_SnakeCase_Naming_Convention() + { + await using SnakeCaseContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO trades (timestamp, ticker, price, size, exchange) + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.snake_case_test_aggregate', NULL, NULL);"); + + List aggregates = await context.TradeAggregates.ToListAsync(); + + Assert.Single(aggregates); + Assert.Equal(100.00m, aggregates[0].AvgPrice); + } + #endregion } } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs new file mode 100644 index 0000000..a3af55d --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -0,0 +1,565 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ContinuousAggregateScaffoldingExtractorTests : 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}".Replace("-", ""); + + 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; + } + + #region Should_Extract_Minimal_ContinuousAggregate + + private class MinimalSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class MinimalHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MinimalAggregateContext(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_Extract_Minimal_ContinuousAggregate() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MinimalAggregateContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "hourly_metrics"))); + + object infoObj = result[("public", "hourly_metrics")]; + Assert.IsType(infoObj); + + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)infoObj; + + Assert.Equal("hourly_metrics", info.MaterializedViewName); + Assert.Equal("public", info.Schema); + Assert.Equal("Metrics", info.SourceHypertableName); + Assert.Equal("public", info.SourceSchema); + Assert.NotNull(info.ViewDefinition); + } + + #endregion + + #region Should_Return_Empty_When_No_ContinuousAggregates + + private class NoAggregateSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class NoAggregateContext(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_Return_Empty_When_No_ContinuousAggregates() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using NoAggregateContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Empty(result); + } + + #endregion + + #region Should_Extract_MaterializedOnly_True + + private class MatTrueSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class MatTrueHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyTrueContext(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_mat_true", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ).MaterializedOnly(true); + }); + } + } + + [Fact] + public async Task Should_Extract_MaterializedOnly_True() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MaterializedOnlyTrueContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics_mat_true")]; + + Assert.True(info.MaterializedOnly); + } + + #endregion + + #region Should_Extract_MaterializedOnly_False + + private class MatFalseSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class MatFalseHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyFalseContext(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_mat_false", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ).MaterializedOnly(false); + }); + } + } + + [Fact] + public async Task Should_Extract_MaterializedOnly_False() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MaterializedOnlyFalseContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics_mat_false")]; + + Assert.False(info.MaterializedOnly); + } + + #endregion + + #region Should_Extract_ChunkInterval + + private class ChunkIntervalSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class ChunkIntervalHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ChunkIntervalContext(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_chunk", + "1 hour", + x => x.Timestamp, + chunkInterval: "1 day" + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public async Task Should_Extract_ChunkInterval() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using ChunkIntervalContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics_chunk")]; + + Assert.NotNull(info.ChunkInterval); + Assert.Contains("24:00:00", info.ChunkInterval); + } + + #endregion + + #region Should_Extract_ViewDefinition_With_AggregateFunctions + + private class AggregatesSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class AggregatesHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + public double MaxValue { get; set; } + } + + private class AggregatesContext(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_agg", + "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 async Task Should_Extract_ViewDefinition_With_AggregateFunctions() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using AggregatesContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics_agg")]; + + Assert.NotNull(info.ViewDefinition); + Assert.Contains("time_bucket", info.ViewDefinition); + Assert.Contains("avg", info.ViewDefinition.ToLower()); + Assert.Contains("max", info.ViewDefinition.ToLower()); + } + + #endregion + + #region Should_Extract_Multiple_ContinuousAggregates + + private class MultipleSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class MultipleHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MultipleDailyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MultipleAggregatesContext(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_Extract_Multiple_ContinuousAggregates() + { + string testConnectionString = await GetTestConnectionStringAsync(); + await using MultipleAggregatesContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey(("public", "hourly_metrics"))); + Assert.True(result.ContainsKey(("public", "daily_metrics"))); + + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo hourlyInfo = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics")]; + Assert.Equal("hourly_metrics", hourlyInfo.MaterializedViewName); + Assert.Contains("01:00:00", hourlyInfo.ViewDefinition); + + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo dailyInfo = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "daily_metrics")]; + Assert.Equal("daily_metrics", dailyInfo.MaterializedViewName); + Assert.Contains("1 day", dailyInfo.ViewDefinition); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableIntegrationTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableIntegrationTests.cs new file mode 100644 index 0000000..01f0c5d --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableIntegrationTests.cs @@ -0,0 +1,790 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class HypertableIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task IsHypertableAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task GetChunkIntervalAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT time_interval::text + FROM timescaledb_information.dimensions + WHERE hypertable_name = @tableName + AND dimension_type = 'Time' + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result?.ToString() ?? string.Empty; + } + + private static async Task IsCompressionEnabledAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task> GetChunkSkipColumnsAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT column_name + FROM _timescaledb_catalog.chunk_column_stats ccs + JOIN _timescaledb_catalog.hypertable h ON ccs.hypertable_id = h.id + WHERE h.table_name = @tableName + GROUP BY column_name; + "; + command.Parameters.AddWithValue("tableName", tableName); + + List columns = []; + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + columns.Add(reader.GetString(0)); + } + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return columns; + } + + private static async Task> GetDimensionsAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT column_name, num_partitions + FROM timescaledb_information.dimensions + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + List dimensions = []; + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + dimensions.Add(new DimensionInfo + { + ColumnName = reader.GetString(0), + NumberPartitions = reader.IsDBNull(1) ? null : reader.GetInt32(1) + }); + } + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return dimensions; + } + + private static async Task GetChunkCountAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) + FROM timescaledb_information.chunks + WHERE hypertable_schema = 'public' AND hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is long longResult ? (int)longResult : + result is int intResult ? intResult : 0; + } + + private class DimensionInfo + { + public string ColumnName { get; set; } = string.Empty; + public int? NumberPartitions { get; set; } + } + + #endregion + + #region Should_Create_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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Create_Minimal_Hypertable() + { + await using MinimalHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime timestamp = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + double value = 100.5; + await context.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO \"Metrics\" (\"Timestamp\", \"Value\") VALUES ({timestamp}, {value})"); + + bool isHypertable = await IsHypertableAsync(context, "Metrics"); + Assert.True(isHypertable); + + List metrics = await context.Metrics.ToListAsync(); + Assert.Single(metrics); + Assert.Equal(100.5, metrics[0].Value); + } + + #endregion + + #region Should_Create_Hypertable_With_CustomChunkInterval + + private class CustomChunkIntervalData + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Temperature { get; set; } + } + + private class CustomChunkIntervalContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("sensor_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_CustomChunkInterval() + { + await using CustomChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + string chunkInterval = await GetChunkIntervalAsync(context, "sensor_data"); + + Assert.Contains("1 day", chunkInterval); + } + + #endregion + + #region Should_Create_Hypertable_With_Compression_Enabled + + private class CompressionEnabledMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionEnabledContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compressed_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(true); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_Compression_Enabled() + { + await using CompressionEnabledContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool compressionEnabled = await IsCompressionEnabledAsync(context, "compressed_metrics"); + + Assert.True(compressionEnabled); + } + + #endregion + + #region Should_Create_Hypertable_With_ChunkSkipping + + private class ChunkSkippingData + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Temperature { get; set; } + } + + private class ChunkSkippingContext(string connectionString) : DbContext + { + public DbSet SkippableData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("skippable_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_ChunkSkipping() + { + await using ChunkSkippingContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List skipColumns = await GetChunkSkipColumnsAsync(context, "skippable_data"); + + bool compressionEnabled = await IsCompressionEnabledAsync(context, "skippable_data"); + Assert.True(compressionEnabled); + Assert.Contains("DeviceId", skipColumns); + } + + #endregion + + #region Should_Create_Hypertable_With_HashDimension + + private class HashDimensionData + { + public DateTime Timestamp { get; set; } + public int LocationId { get; set; } + public double Value { get; set; } + } + + private class HashDimensionContext(string connectionString) : DbContext + { + public DbSet PartitionedData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("partitioned_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("LocationId", 4)); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_HashDimension() + { + await using HashDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "partitioned_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? hashDimension = dimensions.FirstOrDefault(d => d.ColumnName == "LocationId"); + Assert.NotNull(hashDimension); + Assert.Equal(4, hashDimension.NumberPartitions); + } + + #endregion + + #region Should_Create_Hypertable_With_RangeDimension + + private class RangeDimensionData + { + public DateTime Timestamp { get; set; } + public DateTime ProcessedTime { get; set; } + public double Value { get; set; } + } + + private class RangeDimensionContext(string connectionString) : DbContext + { + public DbSet MultiTimeData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("multi_time_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("ProcessedTime", "7 days")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension() + { + await using RangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "multi_time_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessedTime"); + Assert.NotNull(rangeDimension); + } + + #endregion + + #region Should_Create_Hypertable_With_MultipleDimensions + + private class MultipleDimensionsData + { + public DateTime EventTime { get; set; } + public int DeviceId { get; set; } + public string Region { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + } + + private class MultipleDimensionsContext(string connectionString) : DbContext + { + public DbSet EventData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("distributed_events"); + entity.HasNoKey(); + entity.IsHypertable(x => x.EventTime) + .HasDimension(Dimension.CreateHash("DeviceId", 4)) + .HasDimension(Dimension.CreateHash("Region", 2)); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_MultipleDimensions() + { + await using MultipleDimensionsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "distributed_events"); + + Assert.Equal(3, dimensions.Count); + Assert.Contains(dimensions, d => d.ColumnName == "EventTime"); + Assert.Contains(dimensions, d => d.ColumnName == "DeviceId"); + Assert.Contains(dimensions, d => d.ColumnName == "Region"); + } + + #endregion + + #region Should_Create_Hypertable_With_AllOptions + + private class AllOptionsData + { + public DateTime Timestamp { get; set; } + public int SensorId { get; set; } + public string Location { get; set; } = string.Empty; + public double Temperature { get; set; } + } + + private class AllOptionsContext(string connectionString) : DbContext + { + public DbSet ComprehensiveData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("comprehensive_table"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("12 hours") + .EnableCompression(true) + .WithChunkSkipping(x => x.SensorId) + .HasDimension(Dimension.CreateHash("Location", 8)); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_AllOptions() + { + await using AllOptionsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool isHypertable = await IsHypertableAsync(context, "comprehensive_table"); + string chunkInterval = await GetChunkIntervalAsync(context, "comprehensive_table"); + bool compressionEnabled = await IsCompressionEnabledAsync(context, "comprehensive_table"); + List skipColumns = await GetChunkSkipColumnsAsync(context, "comprehensive_table"); + List dimensions = await GetDimensionsAsync(context, "comprehensive_table"); + + Assert.True(isHypertable); + Assert.Contains("12:00:00", chunkInterval); + Assert.True(compressionEnabled); + Assert.Contains("SensorId", skipColumns); + Assert.Equal(2, dimensions.Count); + } + + #endregion + + #region Should_Insert_And_Query_Data_From_Hypertable + + private class IoTDataRecord + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } + public double Humidity { get; set; } + } + + private class DataOperationsContext(string connectionString) : DbContext + { + public DbSet IoTData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("IoTData"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Should_Insert_And_Query_Data_From_Hypertable() + { + await using DataOperationsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""IoTData"" (""Timestamp"", ""DeviceId"", ""Temperature"", ""Humidity"") + VALUES + ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}, {45.0}), + ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {21.0}, {46.0}), + ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {19.5}, {50.0})"); + + List data = await context.IoTData.ToListAsync(); + Assert.Equal(3, data.Count); + + List device1Data = await context.IoTData.Where(d => d.DeviceId == "device_1").ToListAsync(); + Assert.Equal(2, device1Data.Count); + + int chunkCount = await GetChunkCountAsync(context, "IoTData"); + Assert.True(chunkCount >= 1); + } + + #endregion + + #region Should_Handle_LargeDataset + + private class PerformanceTestData + { + public DateTime Timestamp { get; set; } + public int SensorId { get; set; } + public double Value { get; set; } + } + + private class PerformanceTestContext(string connectionString) : DbContext + { + public DbSet PerformanceTest => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("PerformanceTest"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour"); + }); + } + } + + [Fact] + public async Task Should_Handle_LargeDataset() + { + await using PerformanceTestContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime baseTime = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + List valueRows = []; + + for (int i = 0; i < 100; i++) + { + DateTime timestamp = baseTime.AddMinutes(i); + valueRows.Add($"('{timestamp:yyyy-MM-dd HH:mm:ss}+00', {i % 10}, {15.0 + i * 0.1})"); + } + + string sql = $@"INSERT INTO ""PerformanceTest"" (""Timestamp"", ""SensorId"", ""Value"") + VALUES {string.Join(", ", valueRows)}"; + await context.Database.ExecuteSqlRawAsync(sql); + + int count = await context.PerformanceTest.CountAsync(); + Assert.Equal(100, count); + + List sensor0Data = await context.PerformanceTest + .Where(d => d.SensorId == 0) + .ToListAsync(); + Assert.Equal(10, sensor0Data.Count); + + int chunkCount = await GetChunkCountAsync(context, "PerformanceTest"); + Assert.True(chunkCount >= 1); + } + + #endregion + + #region Should_Create_Hypertable_Before_Compression + + private class OperationOrderingMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OperationOrderingContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ordered_ops"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(true); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_Before_Compression() + { + await using OperationOrderingContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool isHypertable = await IsHypertableAsync(context, "ordered_ops"); + bool compressionEnabled = await IsCompressionEnabledAsync(context, "ordered_ops"); + + Assert.True(isHypertable); + Assert.True(compressionEnabled); + } + + #endregion + + #region Should_Enable_Compression_Before_ChunkSkipping + + private class CompressionChunkSkippingData + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Temperature { get; set; } + } + + private class CompressionChunkSkippingContext(string connectionString) : DbContext + { + public DbSet SkippableData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_chunk_skip"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.DeviceId); + }); + } + } + + [Fact] + public async Task Should_Enable_Compression_Before_ChunkSkipping() + { + await using CompressionChunkSkippingContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool compressionEnabled = await IsCompressionEnabledAsync(context, "compression_chunk_skip"); + List skipColumns = await GetChunkSkipColumnsAsync(context, "compression_chunk_skip"); + + Assert.True(compressionEnabled); + Assert.NotEmpty(skipColumns); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs new file mode 100644 index 0000000..560fd88 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -0,0 +1,397 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class HypertableScaffoldingExtractorTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Extract_Minimal_Hypertable + + private class MinimalMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { 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_Extract_Minimal_Hypertable() + { + await using MinimalHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "Metrics"))); + + object infoObj = result[("public", "Metrics")]; + Assert.IsType(infoObj); + + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)infoObj; + Assert.Equal("Timestamp", info.TimeColumnName); + Assert.NotNull(info.ChunkTimeInterval); + Assert.False(info.CompressionEnabled); + Assert.Empty(info.ChunkSkipColumns); + Assert.Empty(info.AdditionalDimensions); + } + + #endregion + + #region Should_Return_Empty_When_No_Hypertables + + private class PlainEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class EmptyDatabaseContext(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("Plain"); + }); + } + } + + [Fact] + public async Task Should_Return_Empty_When_No_Hypertables() + { + await using EmptyDatabaseContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Empty(result); + } + + #endregion + + #region Should_Extract_Hypertable_With_Compression_Enabled + + private class CompressionMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { get; set; } + } + + private class CompressionEnabledContext(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_Extract_Hypertable_With_Compression_Enabled() + { + await using CompressionEnabledContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.True(info.CompressionEnabled); + } + + #endregion + + #region Should_Extract_ChunkSkipColumns + + private class ChunkSkippingMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { get; set; } + } + + private class ChunkSkippingContext(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() + .WithChunkSkipping(x => x.SensorId); + }); + } + } + + [Fact] + public async Task Should_Extract_ChunkSkipColumns() + { + await using ChunkSkippingContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlRawAsync( + "INSERT INTO \"Metrics\" (\"Timestamp\", \"DeviceId\", \"Location\", \"Value\", \"SensorId\") VALUES (NOW(), 'device1', 'location1', 100.0, 1)"); + await context.Database.ExecuteSqlRawAsync( + "SELECT compress_chunk(i) FROM show_chunks('\"Metrics\"') AS i;"); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.NotEmpty(info.ChunkSkipColumns); + Assert.Contains("SensorId", info.ChunkSkipColumns); + } + + #endregion + + #region Should_Extract_Hash_Dimension + + private class HashDimensionMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { 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_Extract_Hash_Dimension() + { + await using HashDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Dimension dimension = Assert.Single(info.AdditionalDimensions); + Assert.Equal("DeviceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + Assert.Equal(4, dimension.NumberOfPartitions); + } + + #endregion + + #region Should_Extract_Multiple_Dimensions + + private class MultipleDimensionsMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { get; set; } + } + + private class MultipleDimensionsContext(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)) + .HasDimension(Dimension.CreateRange("SensorId", "1000")); + }); + } + } + + [Fact] + public async Task Should_Extract_Multiple_Dimensions() + { + await using MultipleDimensionsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.Equal(2, info.AdditionalDimensions.Count); + + Dimension hashDim = info.AdditionalDimensions[0]; + Assert.Equal("DeviceId", hashDim.ColumnName); + Assert.Equal(EDimensionType.Hash, hashDim.Type); + + Dimension rangeDim = info.AdditionalDimensions[1]; + Assert.Equal("SensorId", rangeDim.ColumnName); + Assert.Equal(EDimensionType.Range, rangeDim.Type); + } + + #endregion + + #region Should_Extract_Multiple_Hypertables + + private class MultipleHypertablesMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public double Value { get; set; } + public int SensorId { get; set; } + } + + private class MultipleHypertablesEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultipleHypertablesContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => 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.ToTable("Events"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Extract_Multiple_Hypertables() + { + await using MultipleHypertablesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey(("public", "Metrics"))); + Assert.True(result.ContainsKey(("public", "Events"))); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationLifecycleTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationLifecycleTests.cs new file mode 100644 index 0000000..8ce9986 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationLifecycleTests.cs @@ -0,0 +1,823 @@ +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.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class MigrationLifecycleTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Migration_Should_Generate_CreateHypertable_Operation + + private class GenerateHypertableMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class GenerateHypertableContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_CreateHypertable_Operation() + { + await using GenerateHypertableContext context = new(_connectionString!); + + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + CreateHypertableOperation? createHypertable = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(createHypertable); + Assert.Equal("Metrics", createHypertable.TableName); + Assert.Equal("Timestamp", createHypertable.TimeColumnName); + Assert.Equal("1 day", createHypertable.ChunkTimeInterval); + } + + #endregion + + #region Migration_Should_Create_Hypertable_In_Database + + private class CreateHypertableDbMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateHypertableDbContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Migration_Should_Create_Hypertable_In_Database() + { + await using CreateHypertableDbContext context = new(_connectionString!); + + await CreateDatabaseViaMigrationAsync(context); + + bool isHypertable = await IsHypertableAsync(context, "Metrics"); + Assert.True(isHypertable); + } + + #endregion + + #region Migration_Should_Generate_AlterHypertable_When_ChunkInterval_Changes + + private class AlterChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlterChunkIntervalInitialContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + private class AlterChunkIntervalModifiedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("12 hours"); // <-- Changed from "1 day" + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_AlterHypertable_When_ChunkInterval_Changes() + { + await using AlterChunkIntervalInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + await using AlterChunkIntervalModifiedContext modifiedContext = new(_connectionString!); + IReadOnlyList operations = GenerateMigrationOperations(initialContext, modifiedContext); + + AlterHypertableOperation? alterHypertable = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(alterHypertable); + Assert.Equal("1 day", alterHypertable.OldChunkTimeInterval); + Assert.Equal("12 hours", alterHypertable.ChunkTimeInterval); + } + + #endregion + + #region Migration_Should_Apply_ChunkInterval_Change_To_Database + + private class ApplyChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ApplyChunkIntervalInitialContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + private class ApplyChunkIntervalModifiedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("12 hours"); // <-- Changed from "1 day" + }); + } + } + + [Fact] + public async Task Migration_Should_Apply_ChunkInterval_Change_To_Database() + { + await using ApplyChunkIntervalInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + await using ApplyChunkIntervalModifiedContext modifiedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + string chunkInterval = await GetChunkIntervalAsync(modifiedContext, "Metrics"); + Assert.Contains("12:00:00", chunkInterval); + } + + #endregion + + #region Migration_Should_Generate_CreateContinuousAggregate_Operation + + private class GenerateCAMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class GenerateCAAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class GenerateCAContext(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.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); + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_CreateContinuousAggregate_Operation() + { + await using GenerateCAContext context = new(_connectionString!); + + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + CreateContinuousAggregateOperation? createCA = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(createCA); + Assert.Equal("hourly_metrics", createCA.MaterializedViewName); + Assert.Equal("1 hour", createCA.TimeBucketWidth); + Assert.Contains("AvgValue:Avg:Value", createCA.AggregateFunctions); + } + + #endregion + + #region Migration_Should_Create_ContinuousAggregate_In_Database + + private class CreateCADbMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateCADbAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CreateCADbContext(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.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); + }); + } + } + + [Fact] + public async Task Migration_Should_Create_ContinuousAggregate_In_Database() + { + await using CreateCADbContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool exists = await ContinuousAggregateExistsAsync(context, "hourly_metrics"); + Assert.True(exists); + } + + #endregion + + #region Migration_Should_Generate_AlterContinuousAggregate_When_ChunkInterval_Changes + + private class AlterCAMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlterCAAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AlterCAInitialContext(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.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 AlterCAModifiedContext(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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp, + chunkInterval: "30 days") // <-- Changed from default "7 days" + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_AlterContinuousAggregate_When_ChunkInterval_Changes() + { + await using AlterCAInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + await using AlterCAModifiedContext modifiedContext = new(_connectionString!); + IReadOnlyList operations = GenerateMigrationOperations(initialContext, modifiedContext); + + AlterContinuousAggregateOperation? alterCA = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(alterCA); + Assert.Equal("30 days", alterCA.ChunkInterval); + } + + #endregion + + #region Migration_Should_Drop_And_Recreate_ContinuousAggregate_When_Structure_Changes + + private class StructuralChangeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StructuralChangeAggregateInitial + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StructuralChangeAggregateModified + { + public DateTime TimeBucket { get; set; } + public double MaxValue { get; set; } + } + + private class StructuralChangeInitialContext(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.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 StructuralChangeModifiedContext(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.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.MaxValue, x => x.Value, EAggregateFunction.Max); // <-- Changed from Avg + }); + } + } + + [Fact] + public async Task Migration_Should_Drop_And_Recreate_ContinuousAggregate_When_Structure_Changes() + { + await using StructuralChangeInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + await using StructuralChangeModifiedContext modifiedContext = new(_connectionString!); + IReadOnlyList operations = GenerateMigrationOperations(initialContext, modifiedContext); + + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Migration_Should_Generate_AddReorderPolicy_Operation + + private class GenerateReorderPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class GenerateReorderPolicyContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_AddReorderPolicy_Operation() + { + await using GenerateReorderPolicyContext context = new(_connectionString!); + + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + AddReorderPolicyOperation? addPolicy = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(addPolicy); + Assert.Equal("Metrics", addPolicy.TableName); + Assert.Equal("metrics_time_idx", addPolicy.IndexName); + } + + #endregion + + #region Migration_Should_Create_ReorderPolicy_In_Database + + private class CreateReorderPolicyDbMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateReorderPolicyDbContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public async Task Migration_Should_Create_ReorderPolicy_In_Database() + { + await using CreateReorderPolicyDbContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasReorderPolicyAsync(context, "Metrics"); + Assert.True(hasPolicy); + } + + #endregion + + #region Migration_Should_Generate_AlterReorderPolicy_When_Schedule_Changes + + private class AlterReorderPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlterReorderPolicyInitialContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + private class AlterReorderPolicyModifiedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx", scheduleInterval: "12:00:00"); // <-- Changed from "1 day" + entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); + }); + } + } + + [Fact] + public async Task Migration_Should_Generate_AlterReorderPolicy_When_Schedule_Changes() + { + await using AlterReorderPolicyInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + await using AlterReorderPolicyModifiedContext modifiedContext = new(_connectionString!); + IReadOnlyList operations = GenerateMigrationOperations(initialContext, modifiedContext); + + AlterReorderPolicyOperation? alterPolicy = operations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(alterPolicy); + Assert.Equal("1 day", alterPolicy.OldScheduleInterval); + Assert.Equal("12:00:00", alterPolicy.ScheduleInterval); + } + + #endregion + + #region Helper Methods + + private static async Task IsHypertableAsync(DbContext context, string tableName) + { + await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName"; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool b && b; + } + + private static async Task GetChunkIntervalAsync(DbContext context, string tableName) + { + await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT time_interval::text + FROM timescaledb_information.dimensions + WHERE hypertable_name = @tableName + AND dimension_type = 'Time' + LIMIT 1"; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result?.ToString() ?? string.Empty; + } + + private static async Task ContinuousAggregateExistsAsync(DbContext context, string viewName) + { + await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.continuous_aggregates + WHERE view_name = @viewName"; + command.Parameters.AddWithValue("viewName", viewName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool b && b; + } + + private static async Task HasReorderPolicyAsync(DbContext context, string tableName) + { + await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.jobs j + JOIN timescaledb_information.hypertables h ON j.hypertable_name = h.hypertable_name + WHERE h.hypertable_name = @tableName + AND j.proc_name = 'policy_reorder'"; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool b && b; + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationTestBase.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationTestBase.cs new file mode 100644 index 0000000..6aebb57 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/MigrationTestBase.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Base class for migration lifecycle tests. +/// Provides helper methods to simulate EF Core migration workflows. +/// +public abstract class MigrationTestBase +{ + /// + /// Simulates "dotnet ef migrations add" by comparing two models and generating operations. + /// + protected static IReadOnlyList GenerateMigrationOperations( + DbContext? sourceContext, + DbContext targetContext) + { + IMigrationsModelDiffer differ = targetContext.GetService(); + + IRelationalModel? sourceModel = sourceContext?.GetService().Model.GetRelationalModel(); + IRelationalModel targetModel = targetContext.GetService().Model.GetRelationalModel(); + + return differ.GetDifferences(sourceModel, targetModel); + } + + /// + /// Simulates "dotnet ef database update" by generating SQL from operations and executing them. + /// Groups commands with SET statements to maintain PostgreSQL session state. + /// + protected static async Task ApplyMigrationAsync( + DbContext context, + IReadOnlyList operations) + { + IMigrationsSqlGenerator sqlGenerator = context.GetService(); + IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); + + // Group commands: when we encounter a SET command, batch it with the next command + // to preserve session state (PostgreSQL SET commands are session-scoped) + List currentBatch = []; + + foreach (MigrationCommand command in commands) + { + string sql = command.CommandText.Trim(); + + // Check if this is a SET command + if (sql.StartsWith("SET ", StringComparison.OrdinalIgnoreCase)) + { + // Add SET to current batch + currentBatch.Add(sql.TrimEnd(';')); + } + else + { + // Add command to batch + currentBatch.Add(sql.TrimEnd(';')); + + // Execute the batch (SET + command, or just command if no SET) + string batchSql = string.Join(";\n", currentBatch); + await context.Database.ExecuteSqlRawAsync(batchSql); + + // Clear batch for next iteration + currentBatch.Clear(); + } + } + + // Execute any remaining SET commands (shouldn't happen, but handle edge case) + if (currentBatch.Count > 0) + { + string batchSql = string.Join(";\n", currentBatch); + await context.Database.ExecuteSqlRawAsync(batchSql); + } + } + + /// + /// Complete migration workflow: generate operations and apply them. + /// + protected static async Task ExecuteMigrationAsync( + DbContext? sourceContext, + DbContext targetContext) + { + IReadOnlyList operations = GenerateMigrationOperations(sourceContext, targetContext); + await ApplyMigrationAsync(targetContext, operations); + } + + /// + /// Creates the database schema without using EnsureCreated (uses migration pipeline instead). + /// + protected static async Task CreateDatabaseViaMigrationAsync(DbContext context) + { + // Generate operations from null (empty database) to current model + await ExecuteMigrationAsync(null, context); + } + + /// + /// Simulates altering the database by comparing two different contexts. + /// + protected static async Task AlterDatabaseViaMigrationAsync( + DbContext oldContext, + DbContext newContext) + { + await ExecuteMigrationAsync(oldContext, newContext); + } +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyIntegrationTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyIntegrationTests.cs new file mode 100644 index 0000000..2cd3d8c --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyIntegrationTests.cs @@ -0,0 +1,1014 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ReorderPolicyIntegrationTests : IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task HasReorderPolicyAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.jobs + WHERE application_name LIKE 'Reorder Policy%' + AND hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task GetReorderPolicyJobIdAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT job_id + FROM timescaledb_information.jobs + WHERE application_name LIKE 'Reorder Policy%' + AND hypertable_name = @tableName + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is int jobId ? jobId : 0; + } + + private static async Task GetScheduleIntervalAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT schedule_interval + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is TimeSpan interval ? interval : TimeSpan.Zero; + } + + private static async Task GetMaxRuntimeAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT max_runtime + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is TimeSpan maxRuntime ? maxRuntime : TimeSpan.Zero; + } + + private static async Task GetMaxRetriesAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT max_retries + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is int maxRetries ? maxRetries : 0; + } + + private static async Task GetRetryPeriodAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT retry_period + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is TimeSpan retryPeriod ? retryPeriod : TimeSpan.Zero; + } + + #endregion + + #region Should_Create_ReorderPolicy_WithMinimalConfig + + private class MinimalConfigMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class MinimalConfigContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_minimal_config"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy("metrics_minimal_config_time_idx"); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_minimal_config_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Create_ReorderPolicy_WithMinimalConfig() + { + await using MinimalConfigContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_minimal_config"); + Assert.True(hasPolicy); + + int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_minimal_config"); + Assert.True(jobId > 0); + } + + #endregion + + #region Should_Create_ReorderPolicy_WithAllOptions + + private class AllOptionsMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + public string? SensorId { get; set; } + } + + private class AllOptionsContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_all_options"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_all_options_time_idx", + initialStart: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "6 hours", + maxRuntime: "00:30:00", + maxRetries: 3, + retryPeriod: "00:10:00" + ); + entity.HasIndex(e => new { e.Time, e.Id, e.SensorId }) + .HasDatabaseName("metrics_all_options_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Create_ReorderPolicy_WithAllOptions() + { + await using AllOptionsContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_all_options"); + Assert.True(hasPolicy); + + int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_all_options"); + Assert.True(jobId > 0); + + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); + TimeSpan maxRuntime = await GetMaxRuntimeAsync(context, jobId); + int maxRetries = await GetMaxRetriesAsync(context, jobId); + TimeSpan retryPeriod = await GetRetryPeriodAsync(context, jobId); + + Assert.Equal(TimeSpan.FromHours(6), scheduleInterval); + Assert.Equal(TimeSpan.FromMinutes(30), maxRuntime); + Assert.Equal(3, maxRetries); + Assert.Equal(TimeSpan.FromMinutes(10), retryPeriod); + } + + #endregion + + #region Should_Create_ReorderPolicy_WithCustomScheduleInterval + + private class CustomScheduleMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CustomScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_custom_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_custom_schedule_time_idx", + scheduleInterval: "1 day" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_custom_schedule_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Create_ReorderPolicy_WithCustomScheduleInterval() + { + await using CustomScheduleContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_custom_schedule"); + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); + + Assert.Equal(TimeSpan.FromDays(1), scheduleInterval); + } + + #endregion + + #region Should_Alter_ReorderPolicy_ScheduleInterval + + private class ScheduleIntervalMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialScheduleIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_schedule_interval"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_schedule_interval_time_idx", + scheduleInterval: "1 day" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_schedule_interval_time_idx"); + }); + } + } + + private class ModifiedScheduleIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_schedule_interval"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_schedule_interval_time_idx", + scheduleInterval: "12 hours" // <-- Changed from "1 day" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_schedule_interval_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Alter_ReorderPolicy_ScheduleInterval() + { + await using InitialScheduleIntervalContext initialContext = new(_connectionString!); + await initialContext.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_schedule_interval"); + TimeSpan initialSchedule = await GetScheduleIntervalAsync(initialContext, jobId); + Assert.Equal(TimeSpan.FromDays(1), initialSchedule); + + await using ModifiedScheduleIntervalContext modifiedContext = new(_connectionString!); + + IMigrationsModelDiffer modelDiffer = modifiedContext.GetService(); + IMigrationsSqlGenerator sqlGenerator = modifiedContext.GetService(); + + IReadOnlyList operations = modelDiffer.GetDifferences( + initialContext.GetService().Model.GetRelationalModel(), + modifiedContext.GetService().Model.GetRelationalModel()); + + IReadOnlyList commands = sqlGenerator.Generate(operations, modifiedContext.Model); + + foreach (MigrationCommand command in commands) + { + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + } + + TimeSpan newSchedule = await GetScheduleIntervalAsync(modifiedContext, jobId); + Assert.Equal(TimeSpan.FromHours(12), newSchedule); + } + + #endregion + + #region Should_Alter_ReorderPolicy_MaxRuntime + + private class MaxRuntimeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialMaxRuntimeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_max_runtime"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_max_runtime_time_idx", + maxRuntime: "00:30:00" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_max_runtime_time_idx"); + }); + } + } + + private class ModifiedMaxRuntimeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_max_runtime"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_max_runtime_time_idx", + maxRuntime: "00:15:00" // <-- Changed from "00:30:00" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_max_runtime_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Alter_ReorderPolicy_MaxRuntime() + { + await using InitialMaxRuntimeContext initialContext = new(_connectionString!); + await initialContext.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_max_runtime"); + + await using ModifiedMaxRuntimeContext modifiedContext = new(_connectionString!); + + IMigrationsModelDiffer modelDiffer = modifiedContext.GetService(); + IMigrationsSqlGenerator sqlGenerator = modifiedContext.GetService(); + + IReadOnlyList operations = modelDiffer.GetDifferences( + initialContext.GetService().Model.GetRelationalModel(), + modifiedContext.GetService().Model.GetRelationalModel()); + + IReadOnlyList commands = sqlGenerator.Generate(operations, modifiedContext.Model); + + foreach (MigrationCommand command in commands) + { + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + } + + TimeSpan maxRuntime = await GetMaxRuntimeAsync(modifiedContext, jobId); + Assert.Equal(TimeSpan.FromMinutes(15), maxRuntime); + } + + #endregion + + #region Should_Alter_ReorderPolicy_MaxRetries + + private class MaxRetriesMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialMaxRetriesContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_max_retries"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_max_retries_time_idx", + maxRetries: 3 + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_max_retries_time_idx"); + }); + } + } + + private class ModifiedMaxRetriesContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_max_retries"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_max_retries_time_idx", + maxRetries: 5 // <-- Changed from 3 + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_max_retries_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Alter_ReorderPolicy_MaxRetries() + { + await using InitialMaxRetriesContext initialContext = new(_connectionString!); + await initialContext.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_max_retries"); + + await using ModifiedMaxRetriesContext modifiedContext = new(_connectionString!); + + IMigrationsModelDiffer modelDiffer = modifiedContext.GetService(); + IMigrationsSqlGenerator sqlGenerator = modifiedContext.GetService(); + + IReadOnlyList operations = modelDiffer.GetDifferences( + initialContext.GetService().Model.GetRelationalModel(), + modifiedContext.GetService().Model.GetRelationalModel()); + + IReadOnlyList commands = sqlGenerator.Generate(operations, modifiedContext.Model); + + foreach (MigrationCommand command in commands) + { + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + } + + int maxRetries = await GetMaxRetriesAsync(modifiedContext, jobId); + Assert.Equal(5, maxRetries); + } + + #endregion + + #region Should_Alter_ReorderPolicy_MultipleParameters + + private class MultipleParamsMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialMultipleParamsContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_multiple_params"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_multiple_params_time_idx", + scheduleInterval: "1 day", + maxRuntime: "00:30:00", + maxRetries: 3 + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_multiple_params_time_idx"); + }); + } + } + + private class ModifiedMultipleParamsContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_multiple_params"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_multiple_params_time_idx", + scheduleInterval: "12 hours", // <-- Changed from "1 day" + maxRuntime: "00:15:00", // <-- Changed from "00:30:00" + maxRetries: 5 // <-- Changed from 3 + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_multiple_params_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Alter_ReorderPolicy_MultipleParameters() + { + await using InitialMultipleParamsContext initialContext = new(_connectionString!); + await initialContext.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_multiple_params"); + + await using ModifiedMultipleParamsContext modifiedContext = new(_connectionString!); + + IMigrationsModelDiffer modelDiffer = modifiedContext.GetService(); + IMigrationsSqlGenerator sqlGenerator = modifiedContext.GetService(); + + IReadOnlyList operations = modelDiffer.GetDifferences( + initialContext.GetService().Model.GetRelationalModel(), + modifiedContext.GetService().Model.GetRelationalModel()); + + IReadOnlyList commands = sqlGenerator.Generate(operations, modifiedContext.Model); + + foreach (MigrationCommand command in commands) + { + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + } + + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(modifiedContext, jobId); + TimeSpan maxRuntime = await GetMaxRuntimeAsync(modifiedContext, jobId); + int maxRetries = await GetMaxRetriesAsync(modifiedContext, jobId); + + Assert.Equal(TimeSpan.FromHours(12), scheduleInterval); + Assert.Equal(TimeSpan.FromMinutes(15), maxRuntime); + Assert.Equal(5, maxRetries); + } + + #endregion + + #region Should_Drop_ReorderPolicy + + private class DropPolicyMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DropPolicyInitialContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy("metrics_drop_policy_time_idx"); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_drop_policy_time_idx"); + }); + } + } + + private class DropPolicyRemovedContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + // <-- Reorder policy removed + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_drop_policy_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Drop_ReorderPolicy() + { + await using DropPolicyInitialContext initialContext = new(_connectionString!); + await initialContext.Database.EnsureCreatedAsync(); + + IRelationalModel sourceRelationalModel = initialContext.GetService().Model.GetRelationalModel(); + + bool hasPolicy = await HasReorderPolicyAsync(initialContext, "metrics_drop_policy"); + Assert.True(hasPolicy); + + await using DropPolicyRemovedContext removedContext = new(_connectionString!); + + IMigrationsModelDiffer modelDiffer = removedContext.GetService(); + IMigrationsSqlGenerator sqlGenerator = removedContext.GetService(); + + IRelationalModel targetModel = removedContext.GetService().Model.GetRelationalModel(); + IReadOnlyList operations = modelDiffer.GetDifferences(sourceRelationalModel, targetModel); + + IReadOnlyList commands = sqlGenerator.Generate(operations, removedContext.Model); + + foreach (MigrationCommand command in commands) + { + await removedContext.Database.ExecuteSqlRawAsync(command.CommandText); + } + + hasPolicy = await HasReorderPolicyAsync(removedContext, "metrics_drop_policy"); + Assert.False(hasPolicy); + } + + #endregion + + #region Should_Create_ReorderPolicy_OnExistingHypertable + + private class ExistingHypertableMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ExistingHypertableContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_existing_hypertable"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy("metrics_existing_hypertable_time_idx"); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_existing_hypertable_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Create_ReorderPolicy_OnExistingHypertable() + { + await using ExistingHypertableContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_existing_hypertable"); + Assert.True(hasPolicy); + } + + #endregion + + #region Should_Query_TimescaleDB_Jobs_View + + private class JobsViewMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + public string? SensorId { get; set; } + } + + private class JobsViewContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_jobs_view"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_jobs_view_time_idx", + initialStart: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "6 hours", + maxRuntime: "00:30:00", + maxRetries: 3, + retryPeriod: "00:10:00" + ); + entity.HasIndex(e => new { e.Time, e.Id, e.SensorId }) + .HasDatabaseName("metrics_jobs_view_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Query_TimescaleDB_Jobs_View() + { + await using JobsViewContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + await connection.OpenAsync(); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT application_name, hypertable_name, schedule_interval, max_runtime, max_retries, retry_period + FROM timescaledb_information.jobs + WHERE application_name LIKE 'Reorder Policy%' + AND hypertable_name = 'metrics_jobs_view'; + "; + + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + + string applicationName = reader.GetString(0); + string hypertableName = reader.GetString(1); + TimeSpan scheduleInterval = reader.GetTimeSpan(2); + TimeSpan maxRuntime = reader.GetTimeSpan(3); + int maxRetries = reader.GetInt32(4); + TimeSpan retryPeriod = reader.GetTimeSpan(5); + + Assert.Contains("Reorder Policy", applicationName); + Assert.Equal("metrics_jobs_view", hypertableName); + Assert.Equal(TimeSpan.FromHours(6), scheduleInterval); + Assert.Equal(TimeSpan.FromMinutes(30), maxRuntime); + Assert.Equal(3, maxRetries); + Assert.Equal(TimeSpan.FromMinutes(10), retryPeriod); + } + + #endregion + + #region Should_Handle_UnlimitedRetries + + private class UnlimitedRetriesMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class UnlimitedRetriesContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_unlimited_retries"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_unlimited_retries_time_idx", + maxRetries: -1 + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_unlimited_retries_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Handle_UnlimitedRetries() + { + await using UnlimitedRetriesContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_unlimited_retries"); + int maxRetries = await GetMaxRetriesAsync(context, jobId); + + Assert.Equal(-1, maxRetries); + } + + #endregion + + #region Should_Handle_ZeroMaxRuntime + + private class ZeroMaxRuntimeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ZeroMaxRuntimeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("metrics_zero_max_runtime"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithReorderPolicy( + indexName: "metrics_zero_max_runtime_time_idx", + maxRuntime: "00:00:00" + ); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("metrics_zero_max_runtime_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Handle_ZeroMaxRuntime() + { + await using ZeroMaxRuntimeContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_zero_max_runtime"); + TimeSpan maxRuntime = await GetMaxRuntimeAsync(context, jobId); + + Assert.Equal(TimeSpan.Zero, maxRuntime); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs new file mode 100644 index 0000000..8ce4458 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs @@ -0,0 +1,501 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ReorderPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Extract_Minimal_ReorderPolicy + + private class MinimalPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalReorderPolicyContext(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_Extract_Minimal_ReorderPolicy() + { + await using MinimalReorderPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "Metrics"))); + + object infoObj = result[("public", "Metrics")]; + Assert.IsType(infoObj); + + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)infoObj; + Assert.Equal("metrics_time_idx", info.IndexName); + Assert.Null(info.InitialStart); + Assert.NotNull(info.ScheduleInterval); + Assert.NotNull(info.MaxRuntime); + Assert.NotNull(info.MaxRetries); + Assert.NotNull(info.RetryPeriod); + } + + #endregion + + #region Should_Return_Empty_When_No_ReorderPolicies + + private class NoPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoReorderPolicyContext(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_Return_Empty_When_No_ReorderPolicies() + { + await using NoReorderPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Empty(result); + } + + #endregion + + #region Should_Extract_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext(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", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public async Task Should_Extract_InitialStart() + { + await using InitialStartContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.NotNull(info.InitialStart); + + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, info.InitialStart.Value); + } + + #endregion + + #region Should_Extract_ScheduleInterval + + private class ScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext(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" + ); + }); + } + } + + [Fact] + public async Task Should_Extract_ScheduleInterval() + { + await using ScheduleIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.Equal("12:00:00", info.ScheduleInterval); + } + + #endregion + + #region Should_Extract_MaxRuntime + + private class MaxRuntimeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeContext(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", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public async Task Should_Extract_MaxRuntime() + { + await using MaxRuntimeContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.Equal("01:00:00", info.MaxRuntime); + } + + #endregion + + #region Should_Extract_MaxRetries + + private class MaxRetriesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesContext(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", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public async Task Should_Extract_MaxRetries() + { + await using MaxRetriesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.Equal(5, info.MaxRetries); + } + + #endregion + + #region Should_Extract_RetryPeriod + + private class RetryPeriodMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodContext(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", + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public async Task Should_Extract_RetryPeriod() + { + await using RetryPeriodContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.Equal("00:10:00", info.RetryPeriod); + } + + #endregion + + #region Should_Extract_Multiple_ReorderPolicies + + private class MultiplePoliciesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiplePoliciesEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultiplePoliciesContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => 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"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Events"); + entity.HasIndex(x => x.Timestamp, "events_time_idx"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("events_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Extract_Multiple_ReorderPolicies() + { + await using MultiplePoliciesContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey(("public", "Metrics"))); + Assert.True(result.ContainsKey(("public", "Events"))); + + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo metricsInfo = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + Assert.Equal("metrics_time_idx", metricsInfo.IndexName); + + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo eventsInfo = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Events")]; + Assert.Equal("events_time_idx", eventsInfo.IndexName); + } + + #endregion + + #region Should_Extract_Fully_Configured_ReorderPolicy + + private class FullyConfiguredMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext(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", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + }); + } + } + + [Fact] + public async Task Should_Extract_Fully_Configured_ReorderPolicy() + { + await using FullyConfiguredContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; + + Assert.Equal("metrics_time_idx", info.IndexName); + Assert.NotNull(info.InitialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, info.InitialStart.Value); + Assert.Equal("06:00:00", info.ScheduleInterval); + Assert.Equal("02:00:00", info.MaxRuntime); + Assert.Equal(3, info.MaxRetries); + Assert.Equal("00:15:00", info.RetryPeriod); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/STRYKER_README.md b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/STRYKER_README.md new file mode 100644 index 0000000..964f0a7 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/STRYKER_README.md @@ -0,0 +1,216 @@ +# Stryker.NET Mutation Testing + +This directory contains the configuration for **Stryker.NET** mutation testing, which validates the quality and effectiveness of your test suite. + +## What is Mutation Testing? + +Mutation testing introduces small changes (mutations) to your source code and checks if your tests catch these changes. It answers the question: **"Do your tests actually catch bugs?"** + +## Quick Start + +### 1. Install Stryker.NET (if not already installed) + +```bash +dotnet tool install -g dotnet-stryker +``` + +### 2. Run Mutation Testing + +From the `CmdScale.EntityFrameworkCore.TimescaleDB.Tests` directory: + +```bash +# Full mutation run (can take 30-60 minutes) +dotnet stryker + +# Quick run for development (tests only changed files since last commit) +dotnet stryker --since + +# Run on specific files only +dotnet stryker --mutate "**/HypertableDiffer.cs" +``` + +### 3. View Results + +After completion, the HTML report will be generated in: +``` +StrykerOutput/reports/mutation-report.html +``` + +Open this file in your browser to see detailed results. + +## Configuration Explained + +The `stryker-config.json` file is optimized for this project: + +### Key Settings + +- **Projects Mutated:** + - `CmdScale.EntityFrameworkCore.TimescaleDB` (main library) + - `CmdScale.EntityFrameworkCore.TimescaleDB.Design` (design-time library) + +- **Test Project:** + - `CmdScale.EntityFrameworkCore.TimescaleDB.Tests` ✅ + - **Excludes:** `CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests` ❌ + +- **Concurrency:** 8 parallel processes (adjust based on CPU cores) + +- **Thresholds:** + - **High:** 80% (green) - Excellent mutation score + - **Low:** 60% (orange) - Needs improvement + - **Break:** 50% (red) - Build fails if below this + +- **Coverage Analysis:** `perTest` (most accurate, slower) + +- **Test Filter:** `Category!=Integration` + - Skips slow integration tests for faster feedback + - Remove this to include integration tests + +### Files Excluded from Mutation + +The following are automatically excluded: +- `*.Designer.cs` files +- `obj/` and `bin/` directories +- `Migrations/` directories +- `Properties/` directories + +### Mutations Ignored + +- **String mutations** - Often produce false positives +- **ToString/GetHashCode/Equals** - Usually not critical business logic + +## Understanding Results + +### Mutation Score + +``` +Mutation Score = (Killed Mutations / Total Mutations) × 100% +``` + +- **Killed** ✅ - Test caught the mutation (good!) +- **Survived** ❌ - Mutation not caught (test gap!) +- **Timeout** ⏱️ - Test took too long (possible infinite loop) +- **No Coverage** 🚫 - No test executed the mutated code + +### Example Output + +``` +All files | 87.5% | 350/400 | 350 | 40 | 10 | 0 +├── Differs | 92.3% | 120/130 | 120 | 8 | 2 | 0 +├── Generators | 85.7% | 180/210 | 180 | 25 | 5 | 0 +└── Extractors | 83.3% | 50/60 | 50 | 7 | 3 | 0 +``` + +## Performance Tips + +### Speed Up Mutation Testing + +1. **Use `--since` flag:** + ```bash + dotnet stryker --since + ``` + Only mutates files changed since last commit. + +2. **Increase concurrency:** + ```bash + dotnet stryker --concurrency 16 + ``` + +3. **Use baseline (after first run):** + ```json + "baseline": { + "enabled": true + } + ``` + +4. **Target specific files:** + ```bash + dotnet stryker --mutate "**/Differs/**" + ``` + +5. **Skip integration tests:** + Already configured via `test-case-filter: "Category!=Integration"` + +## Interpreting Low Scores + +If a file has a low mutation score (<60%), it usually means: + +1. **Missing test coverage** - Add more tests +2. **Weak assertions** - Use specific assertions (not just `Assert.NotNull()`) +3. **Dead code** - Remove unused code +4. **Complex logic** - Simplify or add targeted tests + +## Troubleshooting + +### Issue: Tests are too slow + +**Solution:** Increase `additional-timeout` in config: +```json +"additional-timeout": 20000 +``` + +### Issue: Too many mutations to process + +**Solution:** Use `--since` or target specific files: +```bash +dotnet stryker --since --mutate "**/Differs/**" +``` + +### Issue: False positives on string mutations + +**Solution:** Already configured to ignore string mutations: +```json +"ignore-mutations": ["string"] +``` + +### Issue: Integration tests timing out + +**Solution:** Already excluded via: +```json +"test-case-filter": "Category!=Integration" +``` + +## Best Practices + +1. **Run regularly:** After major feature additions +2. **Use `--since`:** For day-to-day development +3. **Full run weekly:** As part of CI/CD pipeline +4. **Target 80%+:** Aim for high mutation score +5. **Review survivors:** Manually check survived mutations +6. **Keep baseline updated:** Re-run full analysis monthly + +## Advanced Configuration + +### Enable Dashboard Reporting + +To upload results to Stryker Dashboard: + +```json +"dashboard": { + "api-key": "your-api-key", + "project": "CmdScale.EntityFrameworkCore.TimescaleDB", + "version": "main" +} +``` + +### Enable Baseline Mode + +After first successful run: + +```json +"baseline": { + "enabled": true, + "provider": "disk" +} +``` + +This speeds up subsequent runs by comparing against baseline. + +## Resources + +- [Stryker.NET Documentation](https://stryker-mutator.io/docs/stryker-net/introduction) +- [Configuration Options](https://stryker-mutator.io/docs/stryker-net/configuration) +- [Mutation Types](https://stryker-mutator.io/docs/mutation-testing-elements/supported-mutators) + +## Questions? + +Check the [Stryker.NET GitHub Issues](https://github.com/stryker-mutator/stryker-net/issues) or documentation. diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs new file mode 100644 index 0000000..34c27d1 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs @@ -0,0 +1,1583 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests that verify ContinuousAggregateBuilder Fluent API methods correctly apply annotations. +/// +public class ContinuousAggregateBuilderTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region IsContinuousAggregate_Should_Set_MaterializedViewName + + private class IsContinuousAggregate_Should_Set_MaterializedViewName_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_MaterializedViewName_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_MaterializedViewName_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_MaterializedViewName() + { + using IsContinuousAggregate_Should_Set_MaterializedViewName_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_MaterializedViewName_HourlyMetricAggregate))!; + + Assert.Equal("hourly_metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type + + private class IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type() + { + using IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_HourlyMetricAggregate))!; + + Assert.Equal("IsContinuousAggregate_Should_Set_ParentName_From_SourceEntity_Type_MetricEntity", entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Configure_Entity_As_View + + private class IsContinuousAggregate_Should_Configure_Entity_As_View_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Configure_Entity_As_View_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Configure_Entity_As_View_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Configure_Entity_As_View() + { + using IsContinuousAggregate_Should_Configure_Entity_As_View_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Configure_Entity_As_View_HourlyMetricAggregate))!; + + Assert.Equal("hourly_metrics", entityType.GetViewName()); + Assert.Null(entityType.GetTableName()); + } + + #endregion + + #region IsContinuousAggregate_Should_Set_TimeBucketWidth + + private class IsContinuousAggregate_Should_Set_TimeBucketWidth_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketWidth_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketWidth_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_TimeBucketWidth() + { + using IsContinuousAggregate_Should_Set_TimeBucketWidth_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_TimeBucketWidth_HourlyMetricAggregate))!; + + Assert.Equal("1 hour", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression + + private class IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression() + { + using IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_TimeBucketSourceColumn_From_Expression_HourlyMetricAggregate))!; + + Assert.Equal("Timestamp", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True + + private class IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True() + { + using IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_TimeBucketGroupBy_Default_True_HourlyMetricAggregate))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False + + private class IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False_Context : 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, + timeBucketGroupBy: false) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False() + { + using IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Support_TimeBucketGroupBy_False_HourlyMetricAggregate))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided + + private class IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided_Context : 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, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided() + { + using IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Set_ChunkInterval_When_Provided_HourlyMetricAggregate))!; + + Assert.Equal("30 days", entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value); + } + + #endregion + + #region IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null + + private class IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null() + { + using IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsContinuousAggregate_Should_Not_Set_ChunkInterval_When_Null_HourlyMetricAggregate))!; + + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)); + } + + #endregion + + #region AddAggregateFunction_Should_Add_Single_Aggregate + + private class AddAggregateFunction_Should_Add_Single_Aggregate_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddAggregateFunction_Should_Add_Single_Aggregate_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AddAggregateFunction_Should_Add_Single_Aggregate_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void AddAggregateFunction_Should_Add_Single_Aggregate() + { + using AddAggregateFunction_Should_Add_Single_Aggregate_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddAggregateFunction_Should_Add_Single_Aggregate_HourlyMetricAggregate))!; + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Single(aggregateFunctions); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + } + + #endregion + + #region AddAggregateFunction_Should_Support_Multiple_Aggregates + + private class AddAggregateFunction_Should_Support_Multiple_Aggregates_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddAggregateFunction_Should_Support_Multiple_Aggregates_MultipleAggregatesEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public double MinValue { get; set; } + public double MaxValue { get; set; } + public double SumValue { get; set; } + } + + private class AddAggregateFunction_Should_Support_Multiple_Aggregates_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MinValue, x => x.Value, EAggregateFunction.Min) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max) + .AddAggregateFunction(x => x.SumValue, x => x.Value, EAggregateFunction.Sum); + }); + } + } + + [Fact] + public void AddAggregateFunction_Should_Support_Multiple_Aggregates() + { + using AddAggregateFunction_Should_Support_Multiple_Aggregates_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddAggregateFunction_Should_Support_Multiple_Aggregates_MultipleAggregatesEntity))!; + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Equal(4, aggregateFunctions.Count); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + Assert.Contains("MinValue:Min:Value", aggregateFunctions); + Assert.Contains("MaxValue:Max:Value", aggregateFunctions); + Assert.Contains("SumValue:Sum:Value", aggregateFunctions); + } + + #endregion + + #region AddAggregateFunction_Should_Prevent_Duplicate_Property + + private class AddAggregateFunction_Should_Prevent_Duplicate_Property_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddAggregateFunction_Should_Prevent_Duplicate_Property_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AddAggregateFunction_Should_Prevent_Duplicate_Property_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void AddAggregateFunction_Should_Prevent_Duplicate_Property() + { + using AddAggregateFunction_Should_Prevent_Duplicate_Property_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddAggregateFunction_Should_Prevent_Duplicate_Property_HourlyMetricAggregate))!; + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Single(aggregateFunctions); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + } + + #endregion + + #region AddAggregateFunction_Should_Support_All_Aggregate_Types + + private class AddAggregateFunction_Should_Support_All_Aggregate_Types_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddAggregateFunction_Should_Support_All_Aggregate_Types_AllAggregatesEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public double MinValue { get; set; } + public double MaxValue { get; set; } + public double SumValue { get; set; } + public int CountValue { get; set; } + public double FirstValue { get; set; } + public double LastValue { get; set; } + } + + private class AddAggregateFunction_Should_Support_All_Aggregate_Types_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MinValue, x => x.Value, EAggregateFunction.Min) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max) + .AddAggregateFunction(x => x.SumValue, x => x.Value, EAggregateFunction.Sum) + .AddAggregateFunction(x => x.CountValue, x => x.Value, EAggregateFunction.Count) + .AddAggregateFunction(x => x.FirstValue, x => x.Value, EAggregateFunction.First) + .AddAggregateFunction(x => x.LastValue, x => x.Value, EAggregateFunction.Last); + }); + } + } + + [Fact] + public void AddAggregateFunction_Should_Support_All_Aggregate_Types() + { + using AddAggregateFunction_Should_Support_All_Aggregate_Types_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddAggregateFunction_Should_Support_All_Aggregate_Types_AllAggregatesEntity))!; + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Equal(7, aggregateFunctions.Count); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + Assert.Contains("MinValue:Min:Value", aggregateFunctions); + Assert.Contains("MaxValue:Max:Value", aggregateFunctions); + Assert.Contains("SumValue:Sum:Value", aggregateFunctions); + Assert.Contains("CountValue:Count:Value", aggregateFunctions); + Assert.Contains("FirstValue:First:Value", aggregateFunctions); + Assert.Contains("LastValue:Last:Value", aggregateFunctions); + } + + #endregion + + #region AddGroupByColumn_Should_Add_Single_Column_From_Expression + + private class AddGroupByColumn_Should_Add_Single_Column_From_Expression_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class AddGroupByColumn_Should_Add_Single_Column_From_Expression_GroupedMetricAggregate + { + public DateTime TimeBucket { get; set; } + public int DeviceId { get; set; } + public double AvgValue { get; set; } + } + + private class AddGroupByColumn_Should_Add_Single_Column_From_Expression_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void AddGroupByColumn_Should_Add_Single_Column_From_Expression() + { + using AddGroupByColumn_Should_Add_Single_Column_From_Expression_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddGroupByColumn_Should_Add_Single_Column_From_Expression_GroupedMetricAggregate))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + Assert.Contains("DeviceId", groupByColumns); + } + + #endregion + + #region AddGroupByColumn_Should_Add_Single_Column_From_RawSQL + + private class AddGroupByColumn_Should_Add_Single_Column_From_RawSQL_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddGroupByColumn_Should_Add_Single_Column_From_RawSQL_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AddGroupByColumn_Should_Add_Single_Column_From_RawSQL_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn("device_id % 10"); + }); + } + } + + [Fact] + public void AddGroupByColumn_Should_Add_Single_Column_From_RawSQL() + { + using AddGroupByColumn_Should_Add_Single_Column_From_RawSQL_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddGroupByColumn_Should_Add_Single_Column_From_RawSQL_HourlyMetricAggregate))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + Assert.Contains("device_id % 10", groupByColumns); + } + + #endregion + + #region AddGroupByColumn_Should_Support_Multiple_Columns + + private class AddGroupByColumn_Should_Support_Multiple_Columns_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + public string? Location { get; set; } + } + + private class AddGroupByColumn_Should_Support_Multiple_Columns_MultiGroupByEntity + { + public DateTime TimeBucket { get; set; } + public int DeviceId { get; set; } + public string? Location { get; set; } + public double AvgValue { get; set; } + } + + private class AddGroupByColumn_Should_Support_Multiple_Columns_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId) + .AddGroupByColumn(x => x.Location); + }); + } + } + + [Fact] + public void AddGroupByColumn_Should_Support_Multiple_Columns() + { + using AddGroupByColumn_Should_Support_Multiple_Columns_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddGroupByColumn_Should_Support_Multiple_Columns_MultiGroupByEntity))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Equal(2, groupByColumns.Count); + Assert.Contains("DeviceId", groupByColumns); + Assert.Contains("Location", groupByColumns); + } + + #endregion + + #region AddGroupByColumn_Should_Prevent_Duplicate_Columns + + private class AddGroupByColumn_Should_Prevent_Duplicate_Columns_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class AddGroupByColumn_Should_Prevent_Duplicate_Columns_GroupedMetricAggregate + { + public DateTime TimeBucket { get; set; } + public int DeviceId { get; set; } + public double AvgValue { get; set; } + } + + private class AddGroupByColumn_Should_Prevent_Duplicate_Columns_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId) + .AddGroupByColumn(x => x.DeviceId); + }); + } + } + + [Fact] + public void AddGroupByColumn_Should_Prevent_Duplicate_Columns() + { + using AddGroupByColumn_Should_Prevent_Duplicate_Columns_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddGroupByColumn_Should_Prevent_Duplicate_Columns_GroupedMetricAggregate))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + Assert.Contains("DeviceId", groupByColumns); + } + + #endregion + + #region AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns + + private class AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn("device_id % 10") + .AddGroupByColumn("device_id % 10"); + }); + } + } + + [Fact] + public void AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns() + { + using AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AddGroupByColumn_Should_Prevent_Duplicate_RawSQL_Columns_HourlyMetricAggregate))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + Assert.Contains("device_id % 10", groupByColumns); + } + + #endregion + + #region WithNoData_Should_Set_WithNoData_True_By_Default + + private class WithNoData_Should_Set_WithNoData_True_By_Default_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class WithNoData_Should_Set_WithNoData_True_By_Default_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class WithNoData_Should_Set_WithNoData_True_By_Default_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(); + }); + } + } + + [Fact] + public void WithNoData_Should_Set_WithNoData_True_By_Default() + { + using WithNoData_Should_Set_WithNoData_True_By_Default_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(WithNoData_Should_Set_WithNoData_True_By_Default_HourlyMetricAggregate))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value); + } + + #endregion + + #region WithNoData_Should_Support_Explicit_False + + private class WithNoData_Should_Support_Explicit_False_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class WithNoData_Should_Support_Explicit_False_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class WithNoData_Should_Support_Explicit_False_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(false); + }); + } + } + + [Fact] + public void WithNoData_Should_Support_Explicit_False() + { + using WithNoData_Should_Support_Explicit_False_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(WithNoData_Should_Support_Explicit_False_HourlyMetricAggregate))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value); + } + + #endregion + + #region CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default + + private class CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(); + }); + } + } + + [Fact] + public void CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default() + { + using CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CreateGroupIndexes_Should_Set_CreateGroupIndexes_True_By_Default_HourlyMetricAggregate))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value); + } + + #endregion + + #region CreateGroupIndexes_Should_Support_Explicit_False + + private class CreateGroupIndexes_Should_Support_Explicit_False_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateGroupIndexes_Should_Support_Explicit_False_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CreateGroupIndexes_Should_Support_Explicit_False_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(false); + }); + } + } + + [Fact] + public void CreateGroupIndexes_Should_Support_Explicit_False() + { + using CreateGroupIndexes_Should_Support_Explicit_False_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CreateGroupIndexes_Should_Support_Explicit_False_HourlyMetricAggregate))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value); + } + + #endregion + + #region MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default + + private class MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .MaterializedOnly(); + }); + } + } + + [Fact] + public void MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default() + { + using MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaterializedOnly_Should_Set_MaterializedOnly_True_By_Default_HourlyMetricAggregate))!; + + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value); + } + + #endregion + + #region MaterializedOnly_Should_Support_Explicit_False + + private class MaterializedOnly_Should_Support_Explicit_False_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnly_Should_Support_Explicit_False_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnly_Should_Support_Explicit_False_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .MaterializedOnly(false); + }); + } + } + + [Fact] + public void MaterializedOnly_Should_Support_Explicit_False() + { + using MaterializedOnly_Should_Support_Explicit_False_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaterializedOnly_Should_Support_Explicit_False_HourlyMetricAggregate))!; + + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value); + } + + #endregion + + #region Where_Should_Set_WhereClause + + private class Where_Should_Set_WhereClause_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class Where_Should_Set_WhereClause_HourlyMetricAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class Where_Should_Set_WhereClause_Context : 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.AvgValue, x => x.Value, EAggregateFunction.Avg) + .Where("device_id > 100"); + }); + } + } + + [Fact] + public void Where_Should_Set_WhereClause() + { + using Where_Should_Set_WhereClause_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(Where_Should_Set_WhereClause_HourlyMetricAggregate))!; + + Assert.Equal("device_id > 100", entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value); + } + + #endregion + + #region FluentAPI_Should_Support_Full_Method_Chaining + + private class FluentAPI_Should_Support_Full_Method_Chaining_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class FluentAPI_Should_Support_Full_Method_Chaining_FullyConfiguredAggregate + { + public DateTime TimeBucket { get; set; } + public int DeviceId { get; set; } + public double AvgValue { get; set; } + public double MaxValue { get; set; } + } + + private class FluentAPI_Should_Support_Full_Method_Chaining_Context : 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, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max) + .AddGroupByColumn(x => x.DeviceId) + .WithNoData() + .CreateGroupIndexes(false) + .MaterializedOnly() + .Where("device_id > 100"); + }); + } + } + + [Fact] + public void FluentAPI_Should_Support_Full_Method_Chaining() + { + using FluentAPI_Should_Support_Full_Method_Chaining_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FluentAPI_Should_Support_Full_Method_Chaining_FullyConfiguredAggregate))!; + + Assert.Equal("hourly_metrics", entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value); + Assert.Equal("FluentAPI_Should_Support_Full_Method_Chaining_MetricEntity", entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value); + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value); + Assert.Equal("device_id > 100", entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value); + + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + Assert.NotNull(aggregateFunctions); + Assert.Equal(2, aggregateFunctions.Count); + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/HypertableTypeBuilderTests.cs new file mode 100644 index 0000000..309b5da --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -0,0 +1,664 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Text.Json; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests that verify HypertableTypeBuilder Fluent API methods correctly apply annotations. +/// +public class HypertableTypeBuilderTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region IsHypertable_Should_Set_IsHypertable_Annotation + + private class IsHypertableAnnotationEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IsHypertableAnnotationContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void IsHypertable_Should_Set_IsHypertable_Annotation() + { + using IsHypertableAnnotationContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IsHypertableAnnotationEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region IsHypertable_Should_Set_TimeColumn_From_Expression + + private class TimeColumnExpressionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimeColumnExpressionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void IsHypertable_Should_Set_TimeColumn_From_Expression() + { + using TimeColumnExpressionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(TimeColumnExpressionEntity))!; + + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + } + + #endregion + + #region IsHypertable_Should_Handle_ValueType_Property_Expression + + private class ValueTypePropertyEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ValueTypePropertyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void IsHypertable_Should_Handle_ValueType_Property_Expression() + { + using ValueTypePropertyContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ValueTypePropertyEntity))!; + + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + } + + #endregion + + #region WithChunkTimeInterval_Should_Set_ChunkTimeInterval_Annotation + + private class ChunkTimeIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChunkTimeIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public void WithChunkTimeInterval_Should_Set_ChunkTimeInterval_Annotation() + { + using ChunkTimeIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ChunkTimeIntervalEntity))!; + + Assert.Equal("1 day", entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + } + + #endregion + + #region WithChunkTimeInterval_Should_Support_Various_Interval_Formats + + private class HourlyIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MonthlyIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MicrosecondIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleIntervalFormatsContext : DbContext + { + public DbSet Hourly => Set(); + public DbSet Monthly => Set(); + public DbSet Microsecond => 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("Hourly"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Monthly"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 month"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Microsecond"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("86400000000"); + }); + } + } + + [Fact] + public void WithChunkTimeInterval_Should_Support_Various_Interval_Formats() + { + using MultipleIntervalFormatsContext context = new(); + IModel model = GetModel(context); + + IEntityType entity1 = model.FindEntityType(typeof(HourlyIntervalEntity))!; + Assert.Equal("1 hour", entity1.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + + IEntityType entity2 = model.FindEntityType(typeof(MonthlyIntervalEntity))!; + Assert.Equal("1 month", entity2.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + + IEntityType entity3 = model.FindEntityType(typeof(MicrosecondIntervalEntity))!; + Assert.Equal("86400000000", entity3.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + } + + #endregion + + #region EnableCompression_Should_Set_EnableCompression_Annotation_True_By_Default + + private class CompressionEnabledEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionEnabledContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(); + }); + } + } + + [Fact] + public void EnableCompression_Should_Set_EnableCompression_Annotation_True_By_Default() + { + using CompressionEnabledContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressionEnabledEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region EnableCompression_Should_Support_Explicit_False + + private class CompressionDisabledEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionDisabledContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .EnableCompression(false); + }); + } + } + + [Fact] + public void EnableCompression_Should_Support_Explicit_False() + { + using CompressionDisabledContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressionDisabledEntity))!; + + Assert.Equal(false, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region WithChunkSkipping_Should_Set_ChunkSkipColumns_Annotation + + private class ChunkSkippingEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class ChunkSkippingContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.Value); + }); + } + } + + [Fact] + public void WithChunkSkipping_Should_Set_ChunkSkipColumns_Annotation() + { + using ChunkSkippingContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ChunkSkippingEntity))!; + + Assert.Equal("Value", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + } + + #endregion + + #region WithChunkSkipping_Should_Support_Multiple_Columns + + private class MultipleChunkSkipEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class MultipleChunkSkipColumnsContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.Value, x => x.DeviceId); + }); + } + } + + [Fact] + public void WithChunkSkipping_Should_Support_Multiple_Columns() + { + using MultipleChunkSkipColumnsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MultipleChunkSkipEntity))!; + + string? chunkSkipColumns = entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value as string; + Assert.NotNull(chunkSkipColumns); + Assert.Contains("Value", chunkSkipColumns); + Assert.Contains("DeviceId", chunkSkipColumns); + } + + #endregion + + #region WithChunkSkipping_Should_Join_Multiple_Columns_With_Comma + + private class CommaJoinedChunkSkipEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class CommaJoinedChunkSkipContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.Value, x => x.DeviceId); + }); + } + } + + [Fact] + public void WithChunkSkipping_Should_Join_Multiple_Columns_With_Comma() + { + using CommaJoinedChunkSkipContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CommaJoinedChunkSkipEntity))!; + + Assert.Equal("Value,DeviceId", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + } + + #endregion + + #region HasDimension_Should_Add_Hash_Dimension + + private class HashDimensionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class HashDimensionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void HasDimension_Should_Add_Hash_Dimension() + { + using HashDimensionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(HashDimensionEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("DeviceId", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[0].Type); + Assert.Equal(4, dimensions[0].NumberOfPartitions); + } + + #endregion + + #region HasDimension_Should_Add_Range_Dimension + + private class RangeDimensionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Location { get; set; } + } + + private class RangeDimensionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("Location", "1000")); + }); + } + } + + [Fact] + public void HasDimension_Should_Add_Range_Dimension() + { + using RangeDimensionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RangeDimensionEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("Location", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[0].Type); + Assert.Equal("1000", dimensions[0].Interval); + } + + #endregion + + #region HasDimension_Should_Support_Multiple_Dimensions + + private class MultipleDimensionsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + public string? Location { get; set; } + } + + private class MultipleDimensionsContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)) + .HasDimension(Dimension.CreateRange("Location", "1000")); + }); + } + } + + [Fact] + public void HasDimension_Should_Support_Multiple_Dimensions() + { + using MultipleDimensionsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MultipleDimensionsEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Equal(2, dimensions.Count); + + Assert.Equal("DeviceId", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[0].Type); + + Assert.Equal("Location", dimensions[1].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[1].Type); + } + + #endregion + + #region FluentAPI_Should_Support_Method_Chaining + + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class FullyConfiguredHypertableContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day") + .EnableCompression() + .WithChunkSkipping(x => x.Value) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + [Fact] + public void FluentAPI_Should_Support_Method_Chaining() + { + using FullyConfiguredHypertableContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + Assert.Equal("Value", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions!); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs new file mode 100644 index 0000000..47f768c --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs @@ -0,0 +1,619 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests that verify ReorderPolicyTypeBuilder Fluent API methods correctly apply annotations. +/// +public class ReorderPolicyTypeBuilderTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region WithReorderPolicy_Should_Set_HasReorderPolicy_Annotation + + private class MinimalEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalContext1 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_HasReorderPolicy_Annotation() + { + using MinimalContext1 context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalEntity1))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Set_IndexName + + private class MinimalEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalContext2 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_IndexName() + { + using MinimalContext2 context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalEntity2))!; + + Assert.Equal("metrics_time_idx", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Set_InitialStart_When_Provided + + private class InitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_InitialStart_When_Provided() + { + using InitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(InitialStartEntity))!; + + object? initialStartValue = entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + + DateTime initialStart = (DateTime)initialStartValue; + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), initialStart); + } + + #endregion + + #region WithReorderPolicy_Should_Not_Set_InitialStart_When_Null + + private class NoInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoInitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Not_Set_InitialStart_When_Null() + { + using NoInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoInitialStartEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)); + } + + #endregion + + #region WithReorderPolicy_Should_Set_ScheduleInterval_When_Provided + + private class ScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + scheduleInterval: "12:00:00" + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_ScheduleInterval_When_Provided() + { + using ScheduleIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScheduleIntervalEntity))!; + + Assert.Equal("12:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Not_Set_ScheduleInterval_When_Null + + private class NoScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Not_Set_ScheduleInterval_When_Null() + { + using NoScheduleIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoScheduleIntervalEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region WithReorderPolicy_Should_Set_MaxRuntime_When_Provided + + private class MaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_MaxRuntime_When_Provided() + { + using MaxRuntimeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRuntimeEntity))!; + + Assert.Equal("01:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Not_Set_MaxRuntime_When_Null + + private class NoMaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoMaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Not_Set_MaxRuntime_When_Null() + { + using NoMaxRuntimeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoMaxRuntimeEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)); + } + + #endregion + + #region WithReorderPolicy_Should_Set_MaxRetries_When_Provided + + private class MaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_MaxRetries_When_Provided() + { + using MaxRetriesContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRetriesEntity))!; + + Assert.Equal(5, entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Not_Set_MaxRetries_When_Null + + private class NoMaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoMaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Not_Set_MaxRetries_When_Null() + { + using NoMaxRetriesContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoMaxRetriesEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)); + } + + #endregion + + #region WithReorderPolicy_Should_Set_RetryPeriod_When_Provided + + private class RetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Set_RetryPeriod_When_Provided() + { + using RetryPeriodContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RetryPeriodEntity))!; + + Assert.Equal("00:10:00", entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Not_Set_RetryPeriod_When_Null + + private class NoRetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoRetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Not_Set_RetryPeriod_When_Null() + { + using NoRetryPeriodContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoRetryPeriodEntity))!; + + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)); + } + + #endregion + + #region WithReorderPolicy_Should_Support_All_Parameters + + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + indexName: "metrics_time_idx", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Support_All_Parameters() + { + using FullyConfiguredContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("metrics_time_idx", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + + object? initialStartValue = entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + DateTime initialStart = (DateTime)initialStartValue; + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), initialStart); + + Assert.Equal("06:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("02:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:15:00", entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region WithReorderPolicy_Should_Return_EntityTypeBuilder_For_Chaining + + private class MethodChainingEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MethodChainingContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("metrics_time_idx"); + }); + } + } + + [Fact] + public void WithReorderPolicy_Should_Return_EntityTypeBuilder_For_Chaining() + { + using MethodChainingContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MethodChainingEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("metrics_time_idx", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings new file mode 100644 index 0000000..eb412c1 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings @@ -0,0 +1,30 @@ + + + + + + + cobertura,lcov + + [CmdScale.EntityFrameworkCore.TimescaleDB]CmdScale.EntityFrameworkCore.TimescaleDB.Internals.WhereClauseExpressionVisitor, + [CmdScale.EntityFrameworkCore.TimescaleDB.Design]* + + + Obsolete, + GeneratedCodeAttribute, + CompilerGeneratedAttribute, + ExcludeFromCodeCoverageAttribute + + + **/Migrations/*.cs + + false + true + false + true + false + + S + + + diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/stryker-config.json b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/stryker-config.json new file mode 100644 index 0000000..ff3d907 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/stryker-config.json @@ -0,0 +1,66 @@ +{ + "stryker-config": { + "project-info": { + "name": "CmdScale.EntityFrameworkCore.TimescaleDB", + "module": "CmdScale.EntityFrameworkCore.TimescaleDB.Tests", + "version": "0.3.0" + }, + "solution": "../CmdScale.EntityFrameworkCore.TimescaleDB.sln", + "project": "CmdScale.EntityFrameworkCore.TimescaleDB.csproj", + "test-projects": [ + "CmdScale.EntityFrameworkCore.TimescaleDB.Tests.csproj" + ], + "target-framework": "net8.0", + "concurrency": 8, + "mutation-level": "Standard", + "language-version": "latest", + "additional-timeout": 10000, + "mutate": [ + "**/*.cs", + "!**/*.Designer.cs", + "!**/obj/**/*.cs", + "!**/bin/**/*.cs", + "!**/Migrations/**/*.cs", + "!**/Properties/**/*.cs" + ], + "coverage-analysis": "perTest", + "disable-bail": false, + "disable-mix-mutants": false, + "thresholds": { + "high": 80, + "low": 60, + "break": 50 + }, + "verbosity": "info", + "reporters": [ + "html", + "progress", + "cleartext" + ], + "report-file-name": "mutation-report", + "since": { + "enabled": true, + "ignore-changes-in": [ + "**/Example/**", + "**/Benchmarks/**", + "**/FunctionalTests/**" + ], + "target": "main" + }, + "baseline": { + "enabled": false, + "provider": "disk", + "fallback-version": "main" + }, + "ignore-mutations": [ + "string" + ], + "ignore-methods": [ + "*ToString", + "*GetHashCode", + "*Equals" + ], + "test-case-filter": "Category!=Integration", + "break-on-initial-test-failure": true + } +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.sln b/CmdScale.EntityFrameworkCore.TimescaleDB.sln index 46d72e0..44ca4bd 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.sln +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36310.24 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11217.181 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CmdScale.EntityFrameworkCore.TimescaleDB", "CmdScale.EntityFrameworkCore.TimescaleDB\CmdScale.EntityFrameworkCore.TimescaleDB.csproj", "{8B5F3AE1-FBB7-42E0-B2A2-BB7CC556AC01}" EndProject diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs index 64c31f6..ad892e5 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs @@ -14,7 +14,7 @@ public static ContinuousAggregateBuilder IsContinuousAgg string timeBucketWidth, Expression> propertyExpression, bool timeBucketGroupBy = true, - string? chukInterval = null) + string? chunkInterval = null) where TEntity : class where TSourceEntity : class { @@ -32,9 +32,9 @@ public static ContinuousAggregateBuilder IsContinuousAgg entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, timeBucketWidth); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy, timeBucketGroupBy); - if (!string.IsNullOrEmpty(chukInterval)) + if (!string.IsNullOrEmpty(chunkInterval)) { - entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ChunkInterval, chukInterval); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ChunkInterval, chunkInterval); } return new ContinuousAggregateBuilder(entityTypeBuilder); diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableConvention.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableConvention.cs index b3305a6..6628a91 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableConvention.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableConvention.cs @@ -39,16 +39,10 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde if (attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0) { + /// Chunk skipping requires compression to be enabled entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); entityTypeBuilder.HasAnnotation(HypertableAnnotations.ChunkSkipColumns, string.Join(",", attribute.ChunkSkipColumns)); } - - bool hasChunkSkipping = attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0; - if (hasChunkSkipping) - { - entityTypeBuilder.HasAnnotation(HypertableAnnotations.ChunkSkipColumns, string.Join(",", attribute.ChunkSkipColumns ?? [])); - entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); - } } } } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ReorderPolicy/ReorderPolicyConvention.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ReorderPolicy/ReorderPolicyConvention.cs index 377e3da..bd1ae86 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ReorderPolicy/ReorderPolicyConvention.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ReorderPolicy/ReorderPolicyConvention.cs @@ -31,7 +31,7 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde { if (DateTime.TryParse(attribute.InitialStart, out DateTime parsedDateTimeOffset)) { - entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, parsedDateTimeOffset); + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, parsedDateTimeOffset); } else { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/ContinuousAggregateOperationGenerator.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/ContinuousAggregateOperationGenerator.cs index 833419c..0cd1aa6 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/ContinuousAggregateOperationGenerator.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/ContinuousAggregateOperationGenerator.cs @@ -124,9 +124,9 @@ public List Generate(CreateContinuousAggregateOperation operation) sqlBuilder.Append($"FROM {parentQualifiedIdentifier}"); // Add WHERE clause if specified - if (!string.IsNullOrWhiteSpace(operation.WhereClaus)) + if (!string.IsNullOrWhiteSpace(operation.WhereClause)) { - string whereClause = operation.WhereClaus.Replace("\"", quoteString); + string whereClause = operation.WhereClause.Replace("\"", quoteString); sqlBuilder.AppendLine(); sqlBuilder.Append($"WHERE {whereClause}"); } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs index 4a0d242..f4b765e 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs @@ -70,7 +70,13 @@ public List Generate(CreateHypertableOperation operation) { if (dimension.Type == EDimensionType.Range) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{dimension.ColumnName}', INTERVAL '{dimension.Interval}'));"); + // Detect if interval is numeric (integer range) or time-based (timestamp range) + bool isIntegerRange = long.TryParse(dimension.Interval, out _); + string intervalExpression = isIntegerRange + ? dimension.Interval! + : $"INTERVAL '{dimension.Interval}'"; + + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{dimension.ColumnName}', {intervalExpression}));"); } else if (dimension.Type == EDimensionType.Hash) { @@ -159,7 +165,13 @@ public List Generate(AlterHypertableOperation operation) { if (newDim.Type == EDimensionType.Range) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{newDim.ColumnName}', INTERVAL '{newDim.Interval}'));"); + // Detect if interval is numeric (integer range) or time-based (timestamp range) + bool isIntegerRange = long.TryParse(newDim.Interval, out _); + string intervalExpression = isIntegerRange + ? newDim.Interval! + : $"INTERVAL '{newDim.Interval}'"; + + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{newDim.ColumnName}', {intervalExpression}));"); } else if (newDim.Type == EDimensionType.Hash) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index 3a11c51..70352bc 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -4,7 +4,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates { - internal class ContinuousAggregateDiffer : IFeatureDiffer + public class ContinuousAggregateDiffer : IFeatureDiffer { public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) { @@ -65,7 +65,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source x.Target.WithNoData != x.Source.WithNoData || !AreAggregateFunctionsEqual(x.Target.AggregateFunctions, x.Source.AggregateFunctions) || !AreGroupByColumnsEqual(x.Target.GroupByColumns, x.Source.GroupByColumns) || - x.Target.WhereClaus != x.Source.WhereClaus + x.Target.WhereClause != x.Source.WhereClause ); foreach (var aggregate in structurallyChangedAggregates) diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index a7ee4e4..a3ecaf8 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -6,7 +6,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates { - internal class ContinuousAggregateModelExtractor + public class ContinuousAggregateModelExtractor { public static IEnumerable GetContinuousAggregates(IRelationalModel? relationalModel) { @@ -61,6 +61,9 @@ public static IEnumerable GetContinuousAggre // Get convention-aware store identifier for the parent table StoreObjectIdentifier parentStoreIdentifier = StoreObjectIdentifier.Table(parentTableName, parentEntityType.GetSchema()); + string? viewName = entityType.GetViewName() ?? materializedViewName; + StoreObjectIdentifier aggregateStoreIdentifier = StoreObjectIdentifier.View(viewName, entityType.GetSchema()); + // Resolve time bucket source column to database column name string? timeBucketSourceColumn = parentEntityType.FindProperty(timeBucketSourceColumnModelName)?.GetColumnName(parentStoreIdentifier); if (string.IsNullOrWhiteSpace(timeBucketSourceColumn)) @@ -102,8 +105,15 @@ public static IEnumerable GetContinuousAggre continue; } - // Alias stays as-is since it's the target column name in the aggregate view - aggregateFunctions.Add($"{aliasModelName}:{functionEnumString}:{sourceColumnDbName}"); + // Resolve alias column name from aggregate entity to respect naming conventions + string? aliasDbName = entityType.FindProperty(aliasModelName)?.GetColumnName(aggregateStoreIdentifier); + if (string.IsNullOrWhiteSpace(aliasDbName)) + { + // Fallback to model name if property not found in aggregate entity + aliasDbName = aliasModelName; + } + + aggregateFunctions.Add($"{aliasDbName}:{functionEnumString}:{sourceColumnDbName}"); } } @@ -148,7 +158,7 @@ public static IEnumerable GetContinuousAggre TimeBucketGroupBy = timeBucketGroupBy, AggregateFunctions = aggregateFunctions, GroupByColumns = groupByColumns, - WhereClaus = whereClause + WhereClause = whereClause }; } } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs index 10f15dd..e95c073 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs @@ -5,7 +5,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables { - internal class HypertableDiffer : IFeatureDiffer + public class HypertableDiffer : IFeatureDiffer { public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableModelExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableModelExtractor.cs index bd35795..782cf34 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableModelExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableModelExtractor.cs @@ -8,7 +8,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables { - internal static class HypertableModelExtractor + public static class HypertableModelExtractor { public static IEnumerable GetHypertables(IRelationalModel? relationalModel) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs index 18ecc29..dd8729a 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs @@ -4,7 +4,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies { - internal class ReorderPolicyDiffer : IFeatureDiffer + public class ReorderPolicyDiffer : IFeatureDiffer { public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs index c461ff5..588cefe 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs @@ -5,7 +5,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies { - internal static class ReorderPolicyModelExtractor + public static class ReorderPolicyModelExtractor { public static IEnumerable GetReorderPolicies(IRelationalModel? relationalModel) { diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/CreateContinuousAggregateOperation.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/CreateContinuousAggregateOperation.cs index 296d788..037b9b4 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/CreateContinuousAggregateOperation.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/CreateContinuousAggregateOperation.cs @@ -15,10 +15,10 @@ public class CreateContinuousAggregateOperation : MigrationOperation public string TimeBucketWidth { get; set; } = string.Empty; public string TimeBucketSourceColumn { get; set; } = string.Empty; - public bool TimeBucketGroupBy { get; set; } = true; + public bool TimeBucketGroupBy { get; set; } public List AggregateFunctions { get; set; } = []; public List GroupByColumns { get; set; } = []; - public string? WhereClaus { get; set; } + public string? WhereClause { get; set; } } } diff --git a/README.md b/README.md index 79d26a5..ba7c608 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ Seamlessly define and manage **TimescaleDB hypertables** using standard EF Core Take full control over how your hypertable data is organized on disk with **TimescaleDB's** reorder policies. By defining a reorder policy, you can automatically re-sort chunks of data by a specified index, significantly improving the performance of queries that scan large time ranges or specific index values. +### Continuous Aggregates + +Create and manage **TimescaleDB continuous aggregates** — automatically refreshed materialized views that pre-compute aggregate data for faster queries. Define time-bucketed aggregations using a type-safe Fluent API or Data Annotations. + +- **Time Bucketing**: Automatically group data into time intervals (e.g., `1 hour`, `1 day`). +- **Aggregate Functions**: Support for `Avg`, `Sum`, `Min`, `Max`, `Count`, `First`, and `Last`. +- **Group By Columns**: Add additional grouping dimensions beyond time. +- **Filtering**: Apply WHERE clauses to filter source data. + --- ## 📦 NuGet Packages @@ -130,11 +139,65 @@ If you need to start with a completely fresh, empty database, you can stop the r docker-compose down -v ``` +--- + +## 🧪 Testing + +This project uses a two-tier testing strategy to ensure code quality and correctness. + +### Test Projects + +| Project | Purpose | +|---------|---------| +| `CmdScale.EntityFrameworkCore.TimescaleDB.Tests` | Unit tests using xUnit and Moq. Fast, isolated tests for differs, extractors, generators, and conventions. Also includes integration tests using Testcontainers. | +| `CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests` | EF Core specification tests validating end-to-end behavior against a real TimescaleDB instance. | + +### Running Tests + +```bash +# Run all tests +dotnet test + +# Run a specific test by name +dotnet test --filter "FullyQualifiedName~HypertableDifferTests" +``` + +### Test Coverage + +Generate an HTML coverage report using [ReportGenerator](https://github.com/danielpalme/ReportGenerator): + +```bash +# Install ReportGenerator (once) +dotnet tool install -g dotnet-reportgenerator-globaltool + +# Run tests with coverage collection +dotnet test --collect:"XPlat Code Coverage" + +# Generate HTML report +reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage/report" -reporttypes:Html +``` + +### Mutation Testing + +Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction) to validate test effectiveness by introducing mutations and checking if tests catch them: + +```bash +# Install Stryker (once) +dotnet tool install -g dotnet-stryker + +# Run from the test directory +cd CmdScale.EntityFrameworkCore.TimescaleDB.Tests +dotnet stryker + +# Quick run (test only changed files) +dotnet stryker --since +``` +Results are generated in `StrykerOutput/reports/mutation-report.html`. See `STRYKER_README.md` in the `CmdScale.EntityFrameworkCore.TimescaleDB.Tests` project for detailed configuration. --- -## 🧪 Scripts +## 🛠️ Scripts This repository includes PowerShell scripts to streamline the development workflow, particularly for switching between local project development and package-based testing. ### Allow PowerShell Scripts to Run From 42463a8bf9f0b08b3cae39ed8415428846a09a5e Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Tue, 25 Nov 2025 14:28:01 +0100 Subject: [PATCH 2/3] test: add unit tests for scaffolding AnnotationApplier classes --- ...ntinuousAggregateAnnotationApplierTests.cs | 516 +++++++++++++ .../HypertableAnnotationApplierTests.cs | 540 +++++++++++++ .../ReorderPolicyAnnotationApplierTests.cs | 712 ++++++++++++++++++ .../coverlet.runsettings | 3 +- 4 files changed, 1769 insertions(+), 2 deletions(-) create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/HypertableAnnotationApplierTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs new file mode 100644 index 0000000..b5c5d6e --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs @@ -0,0 +1,516 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.ContinuousAggregateScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class ContinuousAggregateAnnotationApplierTests +{ + private readonly ContinuousAggregateAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestView", string schema = "public") + { + return new DatabaseTable { Name = name, Schema = schema }; + } + + #region Should_Apply_Minimal_ContinuousAggregate_Annotations + + [Fact] + public void Should_Apply_Minimal_ContinuousAggregate_Annotations() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT time_bucket('1 hour', timestamp) AS bucket, COUNT(*) FROM metrics GROUP BY 1", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify all mandatory annotations are set + Assert.Equal("hourly_metrics", table[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("metrics", table[ContinuousAggregateAnnotations.ParentName]); + Assert.Equal(false, table[ContinuousAggregateAnnotations.MaterializedOnly]); + Assert.Equal("SELECT time_bucket('1 hour', timestamp) AS bucket, COUNT(*) FROM metrics GROUP BY 1", table["TimescaleDB:ViewDefinition"]); + + // ChunkInterval should NOT be set when null + Assert.Null(table[ContinuousAggregateAnnotations.ChunkInterval]); + } + + #endregion + + #region Should_Apply_MaterializedViewName_Annotation + + [Fact] + public void Should_Apply_MaterializedViewName_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "daily_aggregates_v2", + Schema: "analytics", + ViewDefinition: "SELECT ...", + SourceHypertableName: "raw_data", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("daily_aggregates_v2", table[ContinuousAggregateAnnotations.MaterializedViewName]); + } + + #endregion + + #region Should_Apply_ParentName_Annotation + + [Fact] + public void Should_Apply_ParentName_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_view", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "sensor_readings", + SourceSchema: "sensors", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("sensor_readings", table[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion + + #region Should_Apply_MaterializedOnly_True + + [Fact] + public void Should_Apply_MaterializedOnly_True() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: true, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[ContinuousAggregateAnnotations.MaterializedOnly]); + } + + #endregion + + #region Should_Apply_MaterializedOnly_False + + [Fact] + public void Should_Apply_MaterializedOnly_False() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(false, table[ContinuousAggregateAnnotations.MaterializedOnly]); + } + + #endregion + + #region Should_Apply_ChunkInterval_Annotation + + [Fact] + public void Should_Apply_ChunkInterval_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: "1 day" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("1 day", table[ContinuousAggregateAnnotations.ChunkInterval]); + } + + #endregion + + #region Should_Apply_ChunkInterval_With_Various_Formats + + [Theory] + [InlineData("7 days")] + [InlineData("1 hour")] + [InlineData("30 minutes")] + [InlineData("1 month")] + [InlineData("00:30:00")] + public void Should_Apply_ChunkInterval_With_Various_Formats(string chunkInterval) + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "test_view", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: chunkInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(chunkInterval, table[ContinuousAggregateAnnotations.ChunkInterval]); + } + + #endregion + + #region Should_Not_Apply_ChunkInterval_When_Null + + [Fact] + public void Should_Not_Apply_ChunkInterval_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ContinuousAggregateAnnotations.ChunkInterval]); + } + + #endregion + + #region Should_Not_Apply_ChunkInterval_When_Empty + + [Fact] + public void Should_Not_Apply_ChunkInterval_When_Empty() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: "" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ContinuousAggregateAnnotations.ChunkInterval]); + } + + #endregion + + #region Should_Apply_ViewDefinition_Annotation + + [Fact] + public void Should_Apply_ViewDefinition_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + string viewDefinition = @"SELECT + time_bucket('1 hour', timestamp) AS bucket, + device_id, + AVG(temperature) AS avg_temp, + MAX(temperature) AS max_temp, + MIN(temperature) AS min_temp + FROM sensor_readings + WHERE device_id IS NOT NULL + GROUP BY 1, 2"; + + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_temps", + Schema: "public", + ViewDefinition: viewDefinition, + SourceHypertableName: "sensor_readings", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(viewDefinition, table["TimescaleDB:ViewDefinition"]); + } + + #endregion + + #region Should_Apply_All_Annotations_For_Fully_Configured_ContinuousAggregate + + [Fact] + public void Should_Apply_All_Annotations_For_Fully_Configured_ContinuousAggregate() + { + // Arrange + DatabaseTable table = CreateTable("daily_summary", "analytics"); + string viewDefinition = @"SELECT + time_bucket('1 day', recorded_at) AS day, + region_id, + SUM(sales) AS total_sales, + COUNT(*) AS transaction_count + FROM transactions + GROUP BY 1, 2"; + + ContinuousAggregateInfo info = new( + MaterializedViewName: "daily_summary", + Schema: "analytics", + ViewDefinition: viewDefinition, + SourceHypertableName: "transactions", + SourceSchema: "sales", + MaterializedOnly: true, + ChunkInterval: "7 days" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify ALL annotations are applied + Assert.Equal("daily_summary", table[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("transactions", table[ContinuousAggregateAnnotations.ParentName]); + Assert.Equal(true, table[ContinuousAggregateAnnotations.MaterializedOnly]); + Assert.Equal("7 days", table[ContinuousAggregateAnnotations.ChunkInterval]); + Assert.Equal(viewDefinition, table["TimescaleDB:ViewDefinition"]); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Invalid_Info_Type + + [Fact] + public void Should_Throw_ArgumentException_For_Invalid_Info_Type() + { + // Arrange + DatabaseTable table = CreateTable(); + object invalidInfo = new { SomeProperty = "invalid" }; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, invalidInfo) + ); + + Assert.Equal("featureInfo", exception.ParamName); + Assert.Contains("Expected ContinuousAggregateInfo", exception.Message); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message + + [Fact] + public void Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message() + { + // Arrange + DatabaseTable table = CreateTable(); + int wrongInfo = 42; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, wrongInfo) + ); + + Assert.Contains("Expected ContinuousAggregateInfo", exception.Message); + Assert.Contains("Int32", exception.Message); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Null_Info + + [Fact] + public void Should_Throw_ArgumentException_For_Null_Info() + { + // Arrange + DatabaseTable table = CreateTable(); + + // Act & Assert + Assert.Throws( + () => _applier.ApplyAnnotations(table, null!) + ); + } + + #endregion + + #region Should_Preserve_Existing_Table_Properties + + [Fact] + public void Should_Preserve_Existing_Table_Properties() + { + // Arrange + DatabaseTable table = CreateTable("existing_view", "custom_schema"); + table.Comment = "Pre-existing comment"; + ContinuousAggregateInfo info = new( + MaterializedViewName: "hourly_metrics", + Schema: "public", + ViewDefinition: "SELECT ...", + SourceHypertableName: "metrics", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - table properties should be preserved + Assert.Equal("existing_view", table.Name); + Assert.Equal("custom_schema", table.Schema); + Assert.Equal("Pre-existing comment", table.Comment); + + // And annotations should still be applied + Assert.Equal("hourly_metrics", table[ContinuousAggregateAnnotations.MaterializedViewName]); + } + + #endregion + + #region Should_Handle_Complex_ViewDefinition_With_Special_Characters + + [Fact] + public void Should_Handle_Complex_ViewDefinition_With_Special_Characters() + { + // Arrange + DatabaseTable table = CreateTable(); + string viewDefinition = @"SELECT + time_bucket('1 hour'::interval, ""timestamp"") AS bucket, + device_id, + AVG(CASE WHEN value > 0 THEN value ELSE NULL END) AS avg_positive, + string_agg(DISTINCT tag, ',' ORDER BY tag) AS tags + FROM ""Metrics"" + WHERE status != 'deleted' AND value IS NOT NULL + GROUP BY 1, 2 + HAVING COUNT(*) > 0"; + + ContinuousAggregateInfo info = new( + MaterializedViewName: "complex_view", + Schema: "public", + ViewDefinition: viewDefinition, + SourceHypertableName: "Metrics", + SourceSchema: "public", + MaterializedOnly: true, + ChunkInterval: "1 day" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(viewDefinition, table["TimescaleDB:ViewDefinition"]); + } + + #endregion + + #region Should_Handle_SourceHypertableName_From_Different_Schema + + [Fact] + public void Should_Handle_SourceHypertableName_From_Different_Schema() + { + // Arrange + DatabaseTable table = CreateTable(); + ContinuousAggregateInfo info = new( + MaterializedViewName: "aggregated_data", + Schema: "analytics", + ViewDefinition: "SELECT ...", + SourceHypertableName: "raw_events", + SourceSchema: "ingestion", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - ParentName should be the table name, not including schema + Assert.Equal("raw_events", table[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion + + #region Should_Apply_Annotations_To_Different_Table_Schemas + + [Theory] + [InlineData("public")] + [InlineData("analytics")] + [InlineData("custom_schema")] + [InlineData("_timescaledb_internal")] + public void Should_Apply_Annotations_To_Different_Table_Schemas(string schema) + { + // Arrange + DatabaseTable table = CreateTable("test_view", schema); + ContinuousAggregateInfo info = new( + MaterializedViewName: "test_view", + Schema: schema, + ViewDefinition: "SELECT ...", + SourceHypertableName: "source_table", + SourceSchema: "public", + MaterializedOnly: false, + ChunkInterval: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("test_view", table[ContinuousAggregateAnnotations.MaterializedViewName]); + Assert.Equal("source_table", table[ContinuousAggregateAnnotations.ParentName]); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/HypertableAnnotationApplierTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/HypertableAnnotationApplierTests.cs new file mode 100644 index 0000000..54d734a --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/HypertableAnnotationApplierTests.cs @@ -0,0 +1,540 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using System.Text.Json; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.HypertableScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class HypertableAnnotationApplierTests +{ + private readonly HypertableAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestTable", string schema = "public") + { + return new DatabaseTable { Name = name, Schema = schema }; + } + + #region Should_Apply_Minimal_Hypertable_Annotations + + [Fact] + public void Should_Apply_Minimal_Hypertable_Annotations() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify all annotations are set correctly + Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); + Assert.Equal("Timestamp", table[HypertableAnnotations.HypertableTimeColumn]); + Assert.Equal("604800000000", table[HypertableAnnotations.ChunkTimeInterval]); + Assert.Equal(false, table[HypertableAnnotations.EnableCompression]); + + // ChunkSkipColumns and AdditionalDimensions should NOT be set when empty + Assert.Null(table[HypertableAnnotations.ChunkSkipColumns]); + Assert.Null(table[HypertableAnnotations.AdditionalDimensions]); + } + + #endregion + + #region Should_Apply_TimeColumn_Annotation + + [Fact] + public void Should_Apply_TimeColumn_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "created_at", + ChunkTimeInterval: "86400000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("created_at", table[HypertableAnnotations.HypertableTimeColumn]); + } + + #endregion + + #region Should_Apply_ChunkTimeInterval_Annotation + + [Fact] + public void Should_Apply_ChunkTimeInterval_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "3600000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("3600000000", table[HypertableAnnotations.ChunkTimeInterval]); + } + + #endregion + + #region Should_Apply_Compression_Enabled_True + + [Fact] + public void Should_Apply_Compression_Enabled_True() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Apply_Compression_Enabled_False + + [Fact] + public void Should_Apply_Compression_Enabled_False() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(false, table[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Apply_Single_ChunkSkipColumn + + [Fact] + public void Should_Apply_Single_ChunkSkipColumn() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: ["DeviceId"], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.ChunkSkipColumns]); + Assert.Equal("DeviceId", table[HypertableAnnotations.ChunkSkipColumns]); + } + + #endregion + + #region Should_Apply_Multiple_ChunkSkipColumns + + [Fact] + public void Should_Apply_Multiple_ChunkSkipColumns() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: ["DeviceId", "Location", "SensorType"], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.ChunkSkipColumns]); + Assert.Equal("DeviceId,Location,SensorType", table[HypertableAnnotations.ChunkSkipColumns]); + } + + #endregion + + #region Should_Not_Apply_ChunkSkipColumns_When_Empty + + [Fact] + public void Should_Not_Apply_ChunkSkipColumns_When_Empty() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[HypertableAnnotations.ChunkSkipColumns]); + } + + #endregion + + #region Should_Apply_Single_Hash_Dimension + + [Fact] + public void Should_Apply_Single_Hash_Dimension() + { + // Arrange + DatabaseTable table = CreateTable(); + Dimension hashDimension = Dimension.CreateHash("DeviceId", 4); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [hashDimension] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.AdditionalDimensions]); + string? json = table[HypertableAnnotations.AdditionalDimensions] as string; + Assert.NotNull(json); + + List? dimensions = JsonSerializer.Deserialize>(json); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + + Dimension dimension = dimensions[0]; + Assert.Equal("DeviceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + Assert.Equal(4, dimension.NumberOfPartitions); + Assert.Null(dimension.Interval); + } + + #endregion + + #region Should_Apply_Single_Range_Dimension + + [Fact] + public void Should_Apply_Single_Range_Dimension() + { + // Arrange + DatabaseTable table = CreateTable(); + Dimension rangeDimension = Dimension.CreateRange("Location", "1000"); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [rangeDimension] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.AdditionalDimensions]); + string? json = table[HypertableAnnotations.AdditionalDimensions] as string; + Assert.NotNull(json); + + List? dimensions = JsonSerializer.Deserialize>(json); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + + Dimension dimension = dimensions[0]; + Assert.Equal("Location", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("1000", dimension.Interval); + Assert.Null(dimension.NumberOfPartitions); + } + + #endregion + + #region Should_Apply_Multiple_Dimensions + + [Fact] + public void Should_Apply_Multiple_Dimensions() + { + // Arrange + DatabaseTable table = CreateTable(); + Dimension hashDimension = Dimension.CreateHash("DeviceId", 8); + Dimension rangeDimension = Dimension.CreateRange("Region", "86400000000"); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [hashDimension, rangeDimension] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.AdditionalDimensions]); + string? json = table[HypertableAnnotations.AdditionalDimensions] as string; + Assert.NotNull(json); + + List? dimensions = JsonSerializer.Deserialize>(json); + Assert.NotNull(dimensions); + Assert.Equal(2, dimensions.Count); + + // First dimension (hash) + Assert.Equal("DeviceId", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[0].Type); + Assert.Equal(8, dimensions[0].NumberOfPartitions); + + // Second dimension (range) + Assert.Equal("Region", dimensions[1].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[1].Type); + Assert.Equal("86400000000", dimensions[1].Interval); + } + + #endregion + + #region Should_Not_Apply_AdditionalDimensions_When_Empty + + [Fact] + public void Should_Not_Apply_AdditionalDimensions_When_Empty() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[HypertableAnnotations.AdditionalDimensions]); + } + + #endregion + + #region Should_Apply_All_Annotations_For_Fully_Configured_Hypertable + + [Fact] + public void Should_Apply_All_Annotations_For_Fully_Configured_Hypertable() + { + // Arrange + DatabaseTable table = CreateTable("SensorData", "sensors"); + Dimension hashDimension = Dimension.CreateHash("device_id", 16); + Dimension rangeDimension = Dimension.CreateRange("region_code", "2592000000000"); + HypertableInfo info = new( + TimeColumnName: "recorded_at", + ChunkTimeInterval: "86400000000", + CompressionEnabled: true, + ChunkSkipColumns: ["device_id", "sensor_type", "region_code"], + AdditionalDimensions: [hashDimension, rangeDimension] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify ALL annotations are applied + Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); + Assert.Equal("recorded_at", table[HypertableAnnotations.HypertableTimeColumn]); + Assert.Equal("86400000000", table[HypertableAnnotations.ChunkTimeInterval]); + Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); + Assert.Equal("device_id,sensor_type,region_code", table[HypertableAnnotations.ChunkSkipColumns]); + + // Verify dimensions JSON + string? dimensionsJson = table[HypertableAnnotations.AdditionalDimensions] as string; + Assert.NotNull(dimensionsJson); + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Equal(2, dimensions.Count); + Assert.Equal("device_id", dimensions[0].ColumnName); + Assert.Equal(16, dimensions[0].NumberOfPartitions); + Assert.Equal("region_code", dimensions[1].ColumnName); + Assert.Equal("2592000000000", dimensions[1].Interval); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Invalid_Info_Type + + [Fact] + public void Should_Throw_ArgumentException_For_Invalid_Info_Type() + { + // Arrange + DatabaseTable table = CreateTable(); + object invalidInfo = new { SomeProperty = "invalid" }; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, invalidInfo) + ); + + Assert.Equal("featureInfo", exception.ParamName); + Assert.Contains("Expected HypertableInfo", exception.Message); + Assert.Contains("<>f__AnonymousType", exception.Message); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Null_Info + + [Fact] + public void Should_Throw_ArgumentException_For_Null_Info() + { + // Arrange + DatabaseTable table = CreateTable(); + + // Act & Assert + Assert.Throws( + () => _applier.ApplyAnnotations(table, null!) + ); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message + + [Fact] + public void Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message() + { + // Arrange + DatabaseTable table = CreateTable(); + string wrongInfo = "wrong type"; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, wrongInfo) + ); + + Assert.Contains("Expected HypertableInfo", exception.Message); + Assert.Contains("String", exception.Message); + } + + #endregion + + #region Should_Apply_IsHypertable_Always_True + + [Fact] + public void Should_Apply_IsHypertable_Always_True() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - IsHypertable should always be set to true + object? value = table[HypertableAnnotations.IsHypertable]; + Assert.NotNull(value); + Assert.IsType(value); + Assert.True((bool)value); + } + + #endregion + + #region Should_Preserve_Existing_Table_Properties + + [Fact] + public void Should_Preserve_Existing_Table_Properties() + { + // Arrange + DatabaseTable table = CreateTable("PreservedTable", "custom_schema"); + table.Comment = "This is a test table"; + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - table properties should be preserved + Assert.Equal("PreservedTable", table.Name); + Assert.Equal("custom_schema", table.Schema); + Assert.Equal("This is a test table", table.Comment); + + // And annotations should still be applied + Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); + Assert.Equal("Timestamp", table[HypertableAnnotations.HypertableTimeColumn]); + } + + #endregion + + #region Should_Handle_Special_Characters_In_Column_Names + + [Fact] + public void Should_Handle_Special_Characters_In_Column_Names() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "time_stamp_utc", + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + ChunkSkipColumns: ["device_id", "sensor_type_v2"], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("time_stamp_utc", table[HypertableAnnotations.HypertableTimeColumn]); + Assert.Equal("device_id,sensor_type_v2", table[HypertableAnnotations.ChunkSkipColumns]); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs new file mode 100644 index 0000000..c86ba75 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs @@ -0,0 +1,712 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.ReorderPolicyScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class ReorderPolicyAnnotationApplierTests +{ + private readonly ReorderPolicyAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestTable", string schema = "public") + { + return new DatabaseTable { Name = name, Schema = schema }; + } + + #region Should_Apply_Minimal_ReorderPolicy_Annotations + + [Fact] + public void Should_Apply_Minimal_ReorderPolicy_Annotations() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "metrics_device_timestamp_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify mandatory annotations are set + Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("metrics_device_timestamp_idx", table[ReorderPolicyAnnotations.IndexName]); + + // Optional annotations should NOT be set when using defaults + Assert.Null(table[ReorderPolicyAnnotations.InitialStart]); + Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); + Assert.Null(table[ReorderPolicyAnnotations.MaxRuntime]); + Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); + Assert.Null(table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_HasReorderPolicy_Always_True + + [Fact] + public void Should_Apply_HasReorderPolicy_Always_True() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + object? value = table[ReorderPolicyAnnotations.HasReorderPolicy]; + Assert.NotNull(value); + Assert.IsType(value); + Assert.True((bool)value); + } + + #endregion + + #region Should_Apply_IndexName_Annotation + + [Fact] + public void Should_Apply_IndexName_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "custom_clustering_index", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("custom_clustering_index", table[ReorderPolicyAnnotations.IndexName]); + } + + #endregion + + #region Should_Apply_InitialStart_Annotation + + [Fact] + public void Should_Apply_InitialStart_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + DateTime initialStart = new(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: initialStart, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); + } + + #endregion + + #region Should_Not_Apply_InitialStart_When_Null + + [Fact] + public void Should_Not_Apply_InitialStart_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ReorderPolicyAnnotations.InitialStart]); + } + + #endregion + + #region Should_Apply_ScheduleInterval_When_Different_From_Default + + [Fact] + public void Should_Apply_ScheduleInterval_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: "7 days", + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("7 days", table[ReorderPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Not_Apply_ScheduleInterval_When_Default + + [Fact] + public void Should_Not_Apply_ScheduleInterval_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, // "1 day" + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Apply_Various_ScheduleInterval_Formats + + [Theory] + [InlineData("2 days")] + [InlineData("12 hours")] + [InlineData("30 minutes")] + [InlineData("1 week")] + [InlineData("00:30:00")] + public void Should_Apply_Various_ScheduleInterval_Formats(string scheduleInterval) + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: scheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(scheduleInterval, table[ReorderPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Apply_MaxRuntime_When_Different_From_Default + + [Fact] + public void Should_Apply_MaxRuntime_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: "01:00:00", + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("01:00:00", table[ReorderPolicyAnnotations.MaxRuntime]); + } + + #endregion + + #region Should_Not_Apply_MaxRuntime_When_Default + + [Fact] + public void Should_Not_Apply_MaxRuntime_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, // "00:00:00" + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ReorderPolicyAnnotations.MaxRuntime]); + } + + #endregion + + #region Should_Apply_MaxRetries_When_Different_From_Default + + [Fact] + public void Should_Apply_MaxRetries_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: 5, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(5, table[ReorderPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Not_Apply_MaxRetries_When_Default + + [Fact] + public void Should_Not_Apply_MaxRetries_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, // -1 + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Apply_MaxRetries_Zero + + [Fact] + public void Should_Apply_MaxRetries_Zero() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: 0, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - 0 is different from default (-1), so it should be applied + Assert.Equal(0, table[ReorderPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Apply_MaxRetries_Positive_Values + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(10)] + [InlineData(100)] + public void Should_Apply_MaxRetries_Positive_Values(int maxRetries) + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: maxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(maxRetries, table[ReorderPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Apply_RetryPeriod_When_Different_From_Default + + [Fact] + public void Should_Apply_RetryPeriod_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: "00:15:00" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("00:15:00", table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Not_Apply_RetryPeriod_When_Default + + [Fact] + public void Should_Not_Apply_RetryPeriod_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod // "00:05:00" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_Various_RetryPeriod_Formats + + [Theory] + [InlineData("00:01:00")] + [InlineData("00:10:00")] + [InlineData("00:30:00")] + [InlineData("01:00:00")] + public void Should_Apply_Various_RetryPeriod_Formats(string retryPeriod) + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: retryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(retryPeriod, table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_All_Annotations_For_Fully_Configured_ReorderPolicy + + [Fact] + public void Should_Apply_All_Annotations_For_Fully_Configured_ReorderPolicy() + { + // Arrange + DatabaseTable table = CreateTable("sensor_readings", "telemetry"); + DateTime initialStart = new(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc); + ReorderPolicyInfo info = new( + IndexName: "sensor_readings_device_timestamp_idx", + InitialStart: initialStart, + ScheduleInterval: "12 hours", + MaxRuntime: "02:00:00", + MaxRetries: 3, + RetryPeriod: "00:10:00" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify ALL annotations are applied + Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("sensor_readings_device_timestamp_idx", table[ReorderPolicyAnnotations.IndexName]); + Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); + Assert.Equal("12 hours", table[ReorderPolicyAnnotations.ScheduleInterval]); + Assert.Equal("02:00:00", table[ReorderPolicyAnnotations.MaxRuntime]); + Assert.Equal(3, table[ReorderPolicyAnnotations.MaxRetries]); + Assert.Equal("00:10:00", table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_Only_Non_Default_Annotations + + [Fact] + public void Should_Apply_Only_Non_Default_Annotations() + { + // Arrange + DatabaseTable table = CreateTable(); + DateTime initialStart = new(2024, 3, 15, 8, 0, 0, DateTimeKind.Utc); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: initialStart, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, // default - should NOT be applied + MaxRuntime: "01:30:00", // non-default - should be applied + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, // default - should NOT be applied + RetryPeriod: "00:15:00" // non-default - should be applied + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); + Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); + Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); // default + Assert.Equal("01:30:00", table[ReorderPolicyAnnotations.MaxRuntime]); + Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); // default + Assert.Equal("00:15:00", table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Invalid_Info_Type + + [Fact] + public void Should_Throw_ArgumentException_For_Invalid_Info_Type() + { + // Arrange + DatabaseTable table = CreateTable(); + object invalidInfo = new { IndexName = "test" }; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, invalidInfo) + ); + + Assert.Equal("featureInfo", exception.ParamName); + Assert.Contains("Expected ReorderPolicyInfo", exception.Message); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message + + [Fact] + public void Should_Throw_ArgumentException_For_Wrong_Info_Type_With_Message() + { + // Arrange + DatabaseTable table = CreateTable(); + double wrongInfo = 3.14; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, wrongInfo) + ); + + Assert.Contains("Expected ReorderPolicyInfo", exception.Message); + Assert.Contains("Double", exception.Message); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Null_Info + + [Fact] + public void Should_Throw_ArgumentException_For_Null_Info() + { + // Arrange + DatabaseTable table = CreateTable(); + + // Act & Assert + Assert.Throws( + () => _applier.ApplyAnnotations(table, null!) + ); + } + + #endregion + + #region Should_Preserve_Existing_Table_Properties + + [Fact] + public void Should_Preserve_Existing_Table_Properties() + { + // Arrange + DatabaseTable table = CreateTable("existing_table", "custom_schema"); + table.Comment = "Pre-existing table comment"; + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - table properties should be preserved + Assert.Equal("existing_table", table.Name); + Assert.Equal("custom_schema", table.Schema); + Assert.Equal("Pre-existing table comment", table.Comment); + + // And annotations should still be applied + Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); + } + + #endregion + + #region Should_Handle_IndexName_With_Schema_Prefix + + [Fact] + public void Should_Handle_IndexName_With_Schema_Prefix() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "public.metrics_timestamp_device_idx", + InitialStart: null, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("public.metrics_timestamp_device_idx", table[ReorderPolicyAnnotations.IndexName]); + } + + #endregion + + #region Should_Handle_Various_InitialStart_DateTimes + + [Theory] + [MemberData(nameof(InitialStartTestData))] + public void Should_Handle_Various_InitialStart_DateTimes(DateTime initialStart) + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: initialStart, + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: DefaultValues.ReorderPolicyRetryPeriod + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); + } + + public static IEnumerable InitialStartTestData() + { + yield return new object[] { new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc) }; + yield return new object[] { new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc) }; + yield return new object[] { new DateTime(2024, 6, 15, 12, 30, 45, DateTimeKind.Utc) }; + yield return new object[] { DateTime.MinValue }; + yield return new object[] { DateTime.MaxValue }; + } + + #endregion + + #region Should_Apply_Annotations_With_Null_Optional_Values + + [Fact] + public void Should_Apply_Annotations_With_Null_Optional_Values() + { + // Arrange + DatabaseTable table = CreateTable(); + ReorderPolicyInfo info = new( + IndexName: "test_idx", + InitialStart: null, + ScheduleInterval: null, + MaxRuntime: null, + MaxRetries: null, + RetryPeriod: null + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - mandatory annotations should be applied + Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); + Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); + + // Optional annotations with null should not be applied (comparison with default fails) + // Note: null != DefaultValues.X, so annotations won't be set + Assert.Null(table[ReorderPolicyAnnotations.InitialStart]); + Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); + Assert.Null(table[ReorderPolicyAnnotations.MaxRuntime]); + Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); + Assert.Null(table[ReorderPolicyAnnotations.RetryPeriod]); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings index eb412c1..75f22fb 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings @@ -6,8 +6,7 @@ cobertura,lcov - [CmdScale.EntityFrameworkCore.TimescaleDB]CmdScale.EntityFrameworkCore.TimescaleDB.Internals.WhereClauseExpressionVisitor, - [CmdScale.EntityFrameworkCore.TimescaleDB.Design]* + [CmdScale.EntityFrameworkCore.TimescaleDB]CmdScale.EntityFrameworkCore.TimescaleDB.Internals.WhereClauseExpressionVisitor Obsolete, From 67692a129f71777d7e4afba44dc6d6a3262b54e9 Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Tue, 25 Nov 2025 16:04:11 +0100 Subject: [PATCH 3/3] test: expand test coverage for error handling and edge cases Add comprehensive unit tests for Configuration attributes (HypertableAttribute, Dimension, ReorderPolicyAttribute) with validation and edge case coverage. Enhance existing tests with error handling scenarios: - HypertableConvention: null entity types, whitespace time columns - ContinuousAggregateOperationGenerator: invalid parameters, edge cases - HypertableScaffoldingExtractor: connection state handling, malformed data Fix coverlet.runsettings by removing stray 'S' character that was causing XML parsing issues. Update README.md with explicit test coverage output directories for clarity. --- .../Configuration/DimensionTests.cs | 282 +++++++++ .../Configuration/HypertableAttributeTests.cs | 173 ++++++ .../ReorderPolicyAttributeTests.cs | 222 +++++++ .../Conventions/HypertableConventionTests.cs | 209 +++++++ ...tinuousAggregateOperationGeneratorTests.cs | 564 ++++++++++++++++++ .../HypertableScaffoldingExtractorTests.cs | 277 +++++++++ .../coverlet.runsettings | 2 +- README.md | 10 +- 8 files changed, 1734 insertions(+), 5 deletions(-) create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/DimensionTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/HypertableAttributeTests.cs create mode 100644 CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/ReorderPolicyAttributeTests.cs diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/DimensionTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/DimensionTests.cs new file mode 100644 index 0000000..0f8de5f --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/DimensionTests.cs @@ -0,0 +1,282 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify Dimension factory methods and validation. +/// +public class DimensionTests +{ + #region CreateHash Validation Tests + + [Fact] + public void CreateHash_With_Null_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash(null!, 4)); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateHash_With_Empty_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash("", 4)); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateHash_With_Whitespace_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash(" ", 4)); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateHash_With_Zero_Partitions_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash("DeviceId", 0)); + Assert.Contains("Number of partitions must be greater than zero", ex.Message); + Assert.Equal("numberOfPartitions", ex.ParamName); + } + + [Fact] + public void CreateHash_With_Negative_Partitions_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash("DeviceId", -5)); + Assert.Contains("Number of partitions must be greater than zero", ex.Message); + Assert.Equal("numberOfPartitions", ex.ParamName); + } + + [Fact] + public void CreateHash_With_NegativeOne_Partitions_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash("DeviceId", -1)); + Assert.Contains("Number of partitions must be greater than zero", ex.Message); + } + + [Fact] + public void CreateHash_With_Tabs_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateHash("\t\t", 4)); + Assert.Contains("Dimension column name must be provided", ex.Message); + } + + #endregion + + #region CreateHash Valid Tests + + [Fact] + public void CreateHash_With_Valid_Parameters_CreatesHashDimension() + { + // Arrange & Act + Dimension dimension = Dimension.CreateHash("DeviceId", 4); + + // Assert + Assert.Equal("DeviceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + Assert.Equal(4, dimension.NumberOfPartitions); + Assert.Null(dimension.Interval); + } + + [Fact] + public void CreateHash_With_Single_Partition_CreatesCorrectly() + { + // Arrange & Act + Dimension dimension = Dimension.CreateHash("Id", 1); + + // Assert + Assert.Equal(1, dimension.NumberOfPartitions); + Assert.Equal(EDimensionType.Hash, dimension.Type); + } + + [Fact] + public void CreateHash_With_Large_Partition_Count_CreatesCorrectly() + { + // Arrange & Act + Dimension dimension = Dimension.CreateHash("TenantId", 256); + + // Assert + Assert.Equal(256, dimension.NumberOfPartitions); + Assert.Equal(EDimensionType.Hash, dimension.Type); + } + + [Fact] + public void CreateHash_With_Underscore_ColumnName_AcceptsIt() + { + // Arrange & Act + Dimension dimension = Dimension.CreateHash("device_id", 8); + + // Assert + Assert.Equal("device_id", dimension.ColumnName); + } + + #endregion + + #region CreateRange Validation Tests + + [Fact] + public void CreateRange_With_Null_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange(null!, "1 day")); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Empty_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("", "1 day")); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Whitespace_ColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange(" ", "1 day")); + Assert.Contains("Dimension column name must be provided", ex.Message); + Assert.Equal("columnName", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Null_Interval_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("SensorId", null!)); + Assert.Contains("Interval must be provided for a range dimension", ex.Message); + Assert.Equal("interval", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Empty_Interval_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("SensorId", "")); + Assert.Contains("Interval must be provided for a range dimension", ex.Message); + Assert.Equal("interval", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Whitespace_Interval_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("SensorId", " ")); + Assert.Contains("Interval must be provided for a range dimension", ex.Message); + Assert.Equal("interval", ex.ParamName); + } + + [Fact] + public void CreateRange_With_Tabs_Interval_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("SensorId", "\t\t")); + Assert.Contains("Interval must be provided for a range dimension", ex.Message); + } + + [Fact] + public void CreateRange_With_Newlines_Interval_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => Dimension.CreateRange("SensorId", "\n\n")); + Assert.Contains("Interval must be provided for a range dimension", ex.Message); + } + + #endregion + + #region CreateRange Valid Tests + + [Fact] + public void CreateRange_With_Valid_Parameters_CreatesRangeDimension() + { + // Arrange & Act + Dimension dimension = Dimension.CreateRange("SensorId", "1000"); + + // Assert + Assert.Equal("SensorId", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("1000", dimension.Interval); + Assert.Null(dimension.NumberOfPartitions); + } + + [Fact] + public void CreateRange_With_TimeInterval_CreatesCorrectly() + { + // Arrange & Act + Dimension dimension = Dimension.CreateRange("Timestamp", "1 day"); + + // Assert + Assert.Equal("1 day", dimension.Interval); + Assert.Equal(EDimensionType.Range, dimension.Type); + } + + [Fact] + public void CreateRange_With_HourInterval_CreatesCorrectly() + { + // Arrange & Act + Dimension dimension = Dimension.CreateRange("EventTime", "6 hours"); + + // Assert + Assert.Equal("6 hours", dimension.Interval); + } + + [Fact] + public void CreateRange_With_NumericInterval_CreatesCorrectly() + { + // Arrange & Act + Dimension dimension = Dimension.CreateRange("SequenceId", "10000"); + + // Assert + Assert.Equal("10000", dimension.Interval); + } + + #endregion + + #region Parameterless Constructor Tests + + [Fact] + public void ParameterlessConstructor_InitializesDefaultsCorrectly() + { + // Arrange & Act + Dimension dimension = new(); + + // Assert + Assert.Equal(string.Empty, dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Null(dimension.NumberOfPartitions); + Assert.Null(dimension.Interval); + } + + [Fact] + public void ParameterlessConstructor_AllowsPropertyMutation() + { + // Arrange + Dimension dimension = new() + { + // Act + ColumnName = "TestColumn", + Type = EDimensionType.Hash, + NumberOfPartitions = 16, + Interval = "1 week" + }; + + // Assert + Assert.Equal("TestColumn", dimension.ColumnName); + Assert.Equal(EDimensionType.Hash, dimension.Type); + Assert.Equal(16, dimension.NumberOfPartitions); + Assert.Equal("1 week", dimension.Interval); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/HypertableAttributeTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/HypertableAttributeTests.cs new file mode 100644 index 0000000..23c8a97 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/HypertableAttributeTests.cs @@ -0,0 +1,173 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify HypertableAttribute constructor validation and default values. +/// +public class HypertableAttributeTests +{ + #region Constructor Validation Tests + + [Fact] + public void Constructor_With_Null_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute(null!)); + Assert.Contains("Time column name must be provided", ex.Message); + Assert.Equal("timeColumnName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Empty_String_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute("")); + Assert.Contains("Time column name must be provided", ex.Message); + Assert.Equal("timeColumnName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Whitespace_Only_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute(" ")); + Assert.Contains("Time column name must be provided", ex.Message); + Assert.Equal("timeColumnName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Tabs_Only_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute("\t\t")); + Assert.Contains("Time column name must be provided", ex.Message); + } + + [Fact] + public void Constructor_With_Newlines_Only_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute("\n\n")); + Assert.Contains("Time column name must be provided", ex.Message); + } + + [Fact] + public void Constructor_With_MixedWhitespace_TimeColumnName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new HypertableAttribute(" \t\n ")); + Assert.Contains("Time column name must be provided", ex.Message); + } + + #endregion + + #region Valid Constructor Tests + + [Fact] + public void Constructor_With_Valid_TimeColumnName_InitializesCorrectly() + { + // Arrange & Act + HypertableAttribute attr = new("Timestamp"); + + // Assert + Assert.Equal("Timestamp", attr.TimeColumnName); + } + + [Fact] + public void Constructor_With_Valid_TimeColumnName_SetsDefaultValues() + { + // Arrange & Act + HypertableAttribute attr = new("Timestamp"); + + // Assert + Assert.False(attr.EnableCompression); + Assert.Equal(DefaultValues.ChunkTimeInterval, attr.ChunkTimeInterval); + Assert.Null(attr.ChunkSkipColumns); + } + + [Fact] + public void Constructor_With_Underscore_TimeColumnName_AcceptsIt() + { + // Arrange & Act + HypertableAttribute attr = new("created_at"); + + // Assert + Assert.Equal("created_at", attr.TimeColumnName); + } + + [Fact] + public void Constructor_With_PascalCase_TimeColumnName_AcceptsIt() + { + // Arrange & Act + HypertableAttribute attr = new("CreatedAt"); + + // Assert + Assert.Equal("CreatedAt", attr.TimeColumnName); + } + + #endregion + + #region Property Assignment Tests + + [Fact] + public void EnableCompression_CanBeSetToTrue() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + EnableCompression = true + }; + + // Assert + Assert.True(attr.EnableCompression); + } + + [Fact] + public void ChunkTimeInterval_CanBeSetToCustomValue() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + ChunkTimeInterval = "1 hour" + }; + + // Assert + Assert.Equal("1 hour", attr.ChunkTimeInterval); + } + + [Fact] + public void ChunkSkipColumns_CanBeSetToArray() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + ChunkSkipColumns = ["Value", "DeviceId"] + }; + + // Assert + Assert.Equal(2, attr.ChunkSkipColumns.Length); + Assert.Contains("Value", attr.ChunkSkipColumns); + Assert.Contains("DeviceId", attr.ChunkSkipColumns); + } + + [Fact] + public void ChunkSkipColumns_CanBeSetToEmptyArray() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + ChunkSkipColumns = [] + }; + + // Assert + Assert.NotNull(attr.ChunkSkipColumns); + Assert.Empty(attr.ChunkSkipColumns); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/ReorderPolicyAttributeTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/ReorderPolicyAttributeTests.cs new file mode 100644 index 0000000..f179abd --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Configuration/ReorderPolicyAttributeTests.cs @@ -0,0 +1,222 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify ReorderPolicyAttribute constructor validation and default values. +/// +public class ReorderPolicyAttributeTests +{ + #region Constructor Validation Tests + + [Fact] + public void Constructor_With_Null_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute(null!)); + Assert.Contains("IndexName must be provided", ex.Message); + Assert.Equal("indexName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Empty_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute("")); + Assert.Contains("IndexName must be provided", ex.Message); + Assert.Equal("indexName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Whitespace_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute(" ")); + Assert.Contains("IndexName must be provided", ex.Message); + Assert.Equal("indexName", ex.ParamName); + } + + [Fact] + public void Constructor_With_Tabs_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute("\t\t")); + Assert.Contains("IndexName must be provided", ex.Message); + } + + [Fact] + public void Constructor_With_Newlines_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute("\n\n")); + Assert.Contains("IndexName must be provided", ex.Message); + } + + [Fact] + public void Constructor_With_MixedWhitespace_IndexName_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new ReorderPolicyAttribute(" \t\n ")); + Assert.Contains("IndexName must be provided", ex.Message); + } + + #endregion + + #region Valid Constructor Tests + + [Fact] + public void Constructor_With_Valid_IndexName_InitializesCorrectly() + { + // Arrange & Act + ReorderPolicyAttribute attr = new("IX_Metrics_DeviceId_Time"); + + // Assert + Assert.Equal("IX_Metrics_DeviceId_Time", attr.IndexName); + } + + [Fact] + public void Constructor_With_Valid_IndexName_SetsDefaultValues() + { + // Arrange & Act + ReorderPolicyAttribute attr = new("IX_Test"); + + // Assert + Assert.Null(attr.InitialStart); + Assert.Null(attr.ScheduleInterval); + Assert.Null(attr.MaxRuntime); + Assert.Equal(-1, attr.MaxRetries); + Assert.Null(attr.RetryPeriod); + } + + [Fact] + public void Constructor_With_SimpleIndexName_AcceptsIt() + { + // Arrange & Act + ReorderPolicyAttribute attr = new("simple_index"); + + // Assert + Assert.Equal("simple_index", attr.IndexName); + } + + [Fact] + public void Constructor_With_PascalCaseIndexName_AcceptsIt() + { + // Arrange & Act + ReorderPolicyAttribute attr = new("IX_SensorReadings_SensorId_Timestamp"); + + // Assert + Assert.Equal("IX_SensorReadings_SensorId_Timestamp", attr.IndexName); + } + + #endregion + + #region Property Assignment Tests + + [Fact] + public void InitialStart_CanBeSet() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + InitialStart = "2025-01-01T00:00:00Z" + }; + + // Assert + Assert.Equal("2025-01-01T00:00:00Z", attr.InitialStart); + } + + [Fact] + public void ScheduleInterval_CanBeSet() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + ScheduleInterval = "2 days" + }; + + // Assert + Assert.Equal("2 days", attr.ScheduleInterval); + } + + [Fact] + public void MaxRuntime_CanBeSet() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + MaxRuntime = "1 hour" + }; + + // Assert + Assert.Equal("1 hour", attr.MaxRuntime); + } + + [Fact] + public void MaxRetries_CanBeSetToPositiveValue() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + MaxRetries = 5 + }; + + // Assert + Assert.Equal(5, attr.MaxRetries); + } + + [Fact] + public void MaxRetries_CanBeSetToZero() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + MaxRetries = 0 + }; + + // Assert + Assert.Equal(0, attr.MaxRetries); + } + + [Fact] + public void RetryPeriod_CanBeSet() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + RetryPeriod = "30 minutes" + }; + + // Assert + Assert.Equal("30 minutes", attr.RetryPeriod); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + // Arrange + ReorderPolicyAttribute attr = new("IX_Test") + { + // Act + InitialStart = "2025-01-01T00:00:00Z", + ScheduleInterval = "2 days", + MaxRuntime = "1 hour", + MaxRetries = 3, + RetryPeriod = "30 minutes" + }; + + // Assert + Assert.Equal("2025-01-01T00:00:00Z", attr.InitialStart); + Assert.Equal("2 days", attr.ScheduleInterval); + Assert.Equal("1 hour", attr.MaxRuntime); + Assert.Equal(3, attr.MaxRetries); + Assert.Equal("30 minutes", attr.RetryPeriod); + } + + #endregion +} diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs index 57616ac..c0348ef 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs @@ -301,6 +301,215 @@ public void Should_Not_Process_Entity_Without_Attribute() #endregion + #region Should_Not_Apply_ChunkTimeInterval_When_Empty + + [Hypertable("Timestamp", ChunkTimeInterval = "")] + private class EmptyChunkIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyChunkIntervalContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("EmptyChunkInterval"); + }); + } + } + + [Fact] + public void Should_Not_Apply_ChunkTimeInterval_When_Empty() + { + using EmptyChunkIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EmptyChunkIntervalEntity))!; + + // ChunkTimeInterval annotation should be null when the attribute property is empty + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)); + // But IsHypertable should still be set + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + } + + #endregion + + #region Should_Not_Enable_Compression_For_Empty_ChunkSkipColumns_Array + + [Hypertable("Timestamp", ChunkSkipColumns = new string[0])] + private class EmptyChunkSkipColumnsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyChunkSkipColumnsContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("EmptyChunkSkipColumns"); + }); + } + } + + [Fact] + public void Should_Not_Enable_Compression_For_Empty_ChunkSkipColumns_Array() + { + using EmptyChunkSkipColumnsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EmptyChunkSkipColumnsEntity))!; + + // Empty ChunkSkipColumns should NOT enable compression or set ChunkSkipColumns annotation + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)); + // But IsHypertable should still be set + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region Should_Verify_EnableCompression_False_Explicitly + + [Hypertable("Timestamp", EnableCompression = false)] + private class NoCompressionEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoCompressionContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("NoCompression"); + }); + } + } + + [Fact] + public void Should_Verify_EnableCompression_False_Explicitly() + { + using NoCompressionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoCompressionEntity))!; + + // EnableCompression should be null (not set) when false + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region Should_Apply_Default_ChunkTimeInterval_When_Not_Set + + [Hypertable("Timestamp")] + private class DefaultChunkIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultChunkIntervalContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("DefaultChunkInterval"); + }); + } + } + + [Fact] + public void Should_Apply_Default_ChunkTimeInterval_When_Not_Set() + { + using DefaultChunkIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DefaultChunkIntervalEntity))!; + + // When ChunkTimeInterval is not explicitly set, it uses the DefaultValues.ChunkTimeInterval + // and should still be applied as an annotation + Assert.Equal(DefaultValues.ChunkTimeInterval, entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); + } + + #endregion + + #region Should_Handle_Single_ChunkSkipColumn + + [Hypertable("Timestamp", ChunkSkipColumns = ["Value"])] + private class SingleChunkSkipColumnEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SingleChunkSkipColumnContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("SingleChunkSkipColumn"); + }); + } + } + + [Fact] + public void Should_Handle_Single_ChunkSkipColumn() + { + using SingleChunkSkipColumnContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SingleChunkSkipColumnEntity))!; + + // Single column should be stored without extra commas + Assert.Equal("Value", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI [Hypertable("Timestamp", ChunkTimeInterval = "1 hour", EnableCompression = true)] diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index 8b8316f..439f1f9 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -576,6 +576,570 @@ public void Runtime_Drop_UsesIfExists_ForSafety() #endregion + #region Edge Cases and Error Handling Tests + + [Fact] + public void Create_With_Malformed_AggregateFunction_SkipsInvalidFunction() + { + // Arrange - Aggregate function with wrong number of parts (only 2 instead of 3) + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "bad_agg", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + // Malformed: missing source column (only 2 parts) + AggregateFunctions = ["alias_only:Avg", "valid_agg:Sum:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should skip malformed and only include valid + Assert.Contains("SUM(\"value\") AS \"valid_agg\"", result); + // Malformed function should be skipped + Assert.DoesNotContain("alias_only", result); + } + + [Fact] + public void Create_With_ExtraColon_In_AggregateFunction_SkipsFunction() + { + // Arrange - Too many parts (4 instead of 3) + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "malformed_view", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + // Too many colons - will be skipped + AggregateFunctions = ["alias:Avg:value:extra", "valid:Sum:amount"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Malformed should be skipped + Assert.DoesNotContain("extra", result); + Assert.DoesNotContain("alias", result); + // Valid one should be present + Assert.Contains("SUM(\"amount\") AS \"valid\"", result); + } + + [Fact] + public void Create_With_SinglePartAggregateFunction_SkipsFunction() + { + // Arrange - Only 1 part (just alias, no function or column) + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "single_part", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["just_alias", "valid:Count:id"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.DoesNotContain("just_alias", result); + Assert.Contains("COUNT(\"id\") AS \"valid\"", result); + } + + [Fact] + public void Create_WithoutTimeBucketInGroupBy_GeneratesCorrectSQL() + { + // Arrange - TimeBucketGroupBy = false + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "no_time_bucket_gb", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = false, + AggregateFunctions = ["total:Sum:amount"], + GroupByColumns = ["region"], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + // time_bucket should still be in SELECT + Assert.Contains("time_bucket('1 hour', \"time\") AS time_bucket", result); + // GROUP BY should only have region, not time_bucket + Assert.Contains("GROUP BY \"region\"", result); + Assert.DoesNotContain("GROUP BY time_bucket", result); + } + + [Fact] + public void Create_WithRawSQLGroupByExpression_IncludesAsIs() + { + // Arrange - GROUP BY with raw SQL expression (contains parentheses) + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "raw_sql_groupby", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_val:Avg:value"], + GroupByColumns = ["EXTRACT(HOUR FROM time)", "region"], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Raw SQL should be included as-is, quoted column should be quoted + Assert.Contains("EXTRACT(HOUR FROM time)", result); + Assert.Contains("\"region\"", result); + } + + [Fact] + public void Create_WithRawSQLGroupByExpression_ContainingComma_IncludesAsIs() + { + // Arrange - GROUP BY with raw SQL expression containing comma + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "comma_sql", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_val:Avg:value"], + GroupByColumns = ["COALESCE(region, 'unknown')"], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Raw SQL should be included as-is + Assert.Contains("COALESCE(region, 'unknown')", result); + } + + [Fact] + public void Create_WithEmptyGroupByColumns_OnlyIncludesTimeBucket() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "empty_groupby", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["total:Count:id"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("GROUP BY time_bucket", result); + // Ensure there's no trailing comma after time_bucket + Assert.DoesNotContain("GROUP BY time_bucket,", result); + } + + [Fact] + public void Create_WithNullWhereClause_OmitsWhereClause() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "no_where", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + GroupByColumns = [], + WhereClause = null, + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.DoesNotContain("WHERE", result); + } + + [Fact] + public void Create_WithWhitespaceOnlyWhereClause_OmitsWhereClause() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "whitespace_where", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + GroupByColumns = [], + WhereClause = " ", + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.DoesNotContain("WHERE", result); + } + + [Fact] + public void Create_WithEmptyWhereClause_OmitsWhereClause() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "empty_where", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + GroupByColumns = [], + WhereClause = "", + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.DoesNotContain("WHERE", result); + } + + [Fact] + public void Create_WithUnsupportedAggregateFunction_ThrowsNotSupportedException() + { + // Arrange - Using an unsupported aggregate function name + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "unsupported", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["result:Percentile95:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); + + // Act & Assert + NotSupportedException ex = Assert.Throws(() => + generator.Generate(operation)); + Assert.Contains("Percentile95", ex.Message); + Assert.Contains("not supported", ex.Message); + } + + [Fact] + public void Create_WithInvalidAggregateEnum_ThrowsNotSupportedException() + { + // Arrange - Using an unrecognized aggregate function name + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "bad_func", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["result:InvalidFunction:column"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); + + // Act & Assert + NotSupportedException ex = Assert.Throws(() => + generator.Generate(operation)); + Assert.Contains("InvalidFunction", ex.Message); + } + + [Fact] + public void Create_WithAllAggregateFunctionsMalformed_GeneratesViewWithNoAggregates() + { + // Arrange - All aggregate functions are malformed + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "all_malformed", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["bad1", "bad2:only_two"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should still generate the view structure, just without aggregate columns + Assert.Contains("CREATE MATERIALIZED VIEW", result); + Assert.Contains("time_bucket", result); + Assert.DoesNotContain("bad1", result); + Assert.DoesNotContain("bad2", result); + } + + #endregion + + #region Alter Operation Edge Cases + + [Fact] + public void Alter_With_NullChunkInterval_And_OldChunkIntervalExists_RestoresOldValue() + { + // Arrange - ChunkInterval set to null but OldChunkInterval exists + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "restore_chunk", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = "7 days" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should restore old value + Assert.Contains("SET (timescaledb.chunk_interval = '7 days')", result); + } + + [Fact] + public void Alter_With_EmptyChunkInterval_And_EmptyOldChunkInterval_GeneratesNothing() + { + // Arrange - Both intervals are empty + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "empty_intervals", + Schema = "public", + ChunkInterval = "", + OldChunkInterval = "" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should generate nothing for chunk interval + Assert.DoesNotContain("chunk_interval", result); + } + + [Fact] + public void Alter_With_NullChunkInterval_And_NullOldChunkInterval_GeneratesNothing() + { + // Arrange - Both intervals are null + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "null_intervals", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = null + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert - Should generate nothing for chunk interval + Assert.DoesNotContain("chunk_interval", result); + } + + [Fact] + public void Alter_OnlyCreateGroupIndexesChanged_GeneratesSingleStatement() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "indexes_only", + Schema = "public", + CreateGroupIndexes = true, + OldCreateGroupIndexes = false, + MaterializedOnly = true, + OldMaterializedOnly = true + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("create_group_indexes = true", result); + Assert.DoesNotContain("materialized_only", result); + Assert.DoesNotContain("chunk_interval", result); + } + + [Fact] + public void Alter_OnlyMaterializedOnlyChanged_GeneratesSingleStatement() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "mat_only", + Schema = "public", + CreateGroupIndexes = false, + OldCreateGroupIndexes = false, + MaterializedOnly = true, + OldMaterializedOnly = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("materialized_only = true", result); + Assert.DoesNotContain("create_group_indexes", result); + Assert.DoesNotContain("chunk_interval", result); + } + + #endregion + + #region Design-Time vs Runtime Quote Handling + + [Fact] + public void DesignTime_UsesDoubleQuotesForEscaping() + { + // Arrange + ContinuousAggregateOperationGenerator generator = new(isDesignTime: true); + + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "test_view", + Schema = "public", + ParentName = "test_table", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + List statements = generator.Generate(operation); + string result = string.Join("\n", statements); + + // Assert - Design-time should use double quotes for escaping + Assert.Contains("\"\"public\"\"", result); + Assert.Contains("\"\"test_view\"\"", result); + } + + [Fact] + public void Runtime_UsesSingleQuotesForEscaping() + { + // Arrange + ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); + + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "test_view", + Schema = "public", + ParentName = "test_table", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + List statements = generator.Generate(operation); + string result = string.Join("\n", statements); + + // Assert - Runtime should use single quotes (standard SQL quoting) + Assert.Contains("\"public\"", result); + Assert.Contains("\"test_view\"", result); + // Should not have escaped quotes + Assert.DoesNotContain("\"\"public\"\"", result); + } + + [Fact] + public void DesignTime_WhereClause_ConvertsSingleToDoubleQuotes() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "quote_test", + Schema = "public", + ParentName = "data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg:Avg:value"], + GroupByColumns = [], + WhereClause = "\"status\" = 'active'", + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - Design time should double the quotes in WHERE clause + Assert.Contains("\"\"status\"\" = 'active'", result); + } + + #endregion + #region TimescaleDB Constraint Validation Tests [Fact] diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 560fd88..a3d2180 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -335,6 +335,283 @@ public async Task Should_Extract_Multiple_Dimensions() #endregion + #region Should_Extract_With_Already_Open_Connection + + private class AlreadyOpenMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlreadyOpenConnectionContext(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_Extract_With_Already_Open_Connection_DoesNotClose() + { + await using AlreadyOpenConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + connection.Open(); // Explicitly open before extraction + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.Equal(System.Data.ConnectionState.Open, connection.State); // Connection should still be open + Assert.Single(result); + } + + #endregion + + #region Should_Extract_With_Closed_Connection_OpensAndCloses + + private class ClosedConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ClosedConnectionContext(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_Extract_With_Closed_Connection_OpensAndCloses() + { + await using ClosedConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act - Pass closed connection + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert - Connection should be closed after extraction + Assert.Equal(System.Data.ConnectionState.Closed, connection.State); + Assert.Single(result); + } + + #endregion + + #region Should_Extract_Hypertable_Without_Compression_Explicitly + + private class NoCompressionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoCompressionContext(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"); + // No compression enabled + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Extract_Hypertable_Without_Compression_Explicitly() + { + await using NoCompressionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.False(info.CompressionEnabled); + Assert.Empty(info.ChunkSkipColumns); + } + + #endregion + + #region Should_Handle_Hypertable_With_Empty_ChunkSkipColumns + + private class EmptyChunkSkipMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyChunkSkipContext(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(); // Compression enabled but no chunk skip columns + }); + } + } + + [Fact] + public async Task Should_Handle_Hypertable_With_Empty_ChunkSkipColumns() + { + await using EmptyChunkSkipContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.True(info.CompressionEnabled); + Assert.Empty(info.ChunkSkipColumns); // No chunk skip columns configured + } + + #endregion + + #region Should_Extract_Custom_ChunkTimeInterval + + private class CustomChunkIntervalMetric + { + 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_Extract_Custom_ChunkTimeInterval() + { + await using CustomChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + Assert.NotNull(info.ChunkTimeInterval); + // Verify that a custom chunk interval is extracted (the exact value depends on database interpretation) + // Input was 86400000 (ms), extractor does EPOCH*1000 conversion + Assert.False(string.IsNullOrEmpty(info.ChunkTimeInterval)); + } + + #endregion + + #region Should_Extract_Range_Dimension_With_Integer_Interval + + private class IntegerRangeDimensionMetric + { + public DateTime Timestamp { get; set; } + public int SequenceId { get; set; } + public double Value { get; set; } + } + + private class IntegerRangeDimensionContext(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.CreateRange("SequenceId", "10000")); + }); + } + } + + [Fact] + public async Task Should_Extract_Range_Dimension_With_Integer_Interval() + { + await using IntegerRangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + + Dimension dimension = Assert.Single(info.AdditionalDimensions); + Assert.Equal("SequenceId", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.Equal("10000", dimension.Interval); + } + + #endregion + #region Should_Extract_Multiple_Hypertables private class MultipleHypertablesMetric diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings index 75f22fb..bf4bfcd 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings @@ -23,7 +23,7 @@ true false - S + diff --git a/README.md b/README.md index ba7c608..9779628 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,15 @@ 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 -dotnet test --collect:"XPlat Code Coverage" +# Run tests with coverage collection (output to ./TestResults) +dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults -# Generate HTML report -reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage/report" -reporttypes:Html +# Generate HTML report from coverage files +reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"TestResults/CoverageReport" -reporttypes:Html ``` +The HTML report will be generated at `TestResults/CoverageReport/index.html`. + ### Mutation Testing Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction) to validate test effectiveness by introducing mutations and checking if tests catch them: