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