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/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/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..c0348ef --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Conventions/HypertableConventionTests.cs @@ -0,0 +1,598 @@ +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 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)] + 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..439f1f9 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -0,0 +1,1267 @@ +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 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] + 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..a3d2180 --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -0,0 +1,674 @@ +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_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 + { + 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/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/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..bf4bfcd --- /dev/null +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/coverlet.runsettings @@ -0,0 +1,29 @@ + + + + + + + cobertura,lcov + + [CmdScale.EntityFrameworkCore.TimescaleDB]CmdScale.EntityFrameworkCore.TimescaleDB.Internals.WhereClauseExpressionVisitor + + + Obsolete, + GeneratedCodeAttribute, + CompilerGeneratedAttribute, + ExcludeFromCodeCoverageAttribute + + + **/Migrations/*.cs + + false + true + false + true + false + + + + + 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..9779628 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,67 @@ 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 (output to ./TestResults) +dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults + +# 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: + +```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