From ed889c009c0328ebcceeee1a8388c5c886298246 Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Mon, 17 Nov 2025 20:02:09 +0100 Subject: [PATCH] fix: enable altering hypertable dimensions via AlterHypertableOperation This commit fixes issue #14 where changes to hypertable dimensions were not being detected or applied during migrations. Changes: - Add AdditionalDimensions and OldAdditionalDimensions properties to AlterHypertableOperation - Implement dimension comparison logic in HypertableDiffer.AreDimensionsEqual() - Update HypertableOperationGenerator to generate add_dimension() SQL for new dimensions - Add warning comment when dimensions are removed (TimescaleDB limitation) - Fix minor whitespace formatting issues in test files Technical details: - Only new dimensions generate SQL in Up() migrations - Removed dimensions generate warning comments in Down() migrations (cannot be reversed) - Dimension comparison checks ColumnName, Type, Interval, and NumberOfPartitions --- .../HypertableOperationGeneratorTests.cs | 2 +- .../ReorderPolicyOperationGeneratorTests.cs | 12 +++--- .../ContinuousAggregateTypeBuilder.cs | 4 +- .../Hypertable/HypertableAnnotations.cs | 2 +- .../HypertableOperationGenerator.cs | 41 +++++++++++++++++++ .../Features/Hypertables/HypertableDiffer.cs | 34 +++++++++++++-- .../AlterContinuousAggregateOperation.cs | 4 +- .../Operations/AlterHypertableOperation.cs | 5 ++- 8 files changed, 88 insertions(+), 16 deletions(-) diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorTests.cs index fe141d7..451ba6c 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -14,7 +14,7 @@ public class HypertableOperationGeneratorTests private static string GetGeneratedCode(dynamic operation) { IndentedStringBuilder builder = new(); - + HypertableOperationGenerator generator = new(true); List statements = generator.Generate(operation); SqlBuilderHelper.BuildQueryString(statements, builder); diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs index 1a080f0..653b750 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs @@ -53,9 +53,9 @@ public void Generate_Add_with_non_default_schedule_creates_add_and_alter_sql() IndexName = "IX_TestTable_Time", InitialStart = testDate, ScheduleInterval = "2 days", - MaxRuntime = "1 hour", - MaxRetries = 5, - RetryPeriod = "10 minutes" + MaxRuntime = "1 hour", + MaxRetries = 5, + RetryPeriod = "10 minutes" }; string expected = @".Sql(@"" @@ -78,10 +78,10 @@ FROM timescaledb_information.jobs public void Generate_Drop_creates_correct_remove_policy_sql() { // Arrange - DropReorderPolicyOperation operation = new() - { + DropReorderPolicyOperation operation = new() + { Schema = "public", - TableName = "TestTable" + TableName = "TestTable" }; string expected = @".Sql(@"" diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs index 5a0d09b..64c31f6 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs @@ -98,8 +98,8 @@ EAggregateFunction function public static ContinuousAggregateBuilder AddGroupByColumn( this ContinuousAggregateBuilder builder, - Expression> propertyExpression) - where TEntity : class + Expression> propertyExpression) + where TEntity : class where TSourceEntity : class { string propertyName = GetPropertyName(propertyExpression); diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableAnnotations.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableAnnotations.cs index 091883f..86fbabe 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableAnnotations.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Configuration/Hypertable/HypertableAnnotations.cs @@ -8,7 +8,7 @@ public static class HypertableAnnotations public const string IsHypertable = "TimescaleDB:IsHypertable"; public const string HypertableTimeColumn = "TimescaleDB:TimeColumnName"; public const string EnableCompression = "TimescaleDB:EnableCompression"; - public const string ChunkTimeInterval ="TimescaleDB:ChunkTimeInterval"; + public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval"; public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns"; public const string AdditionalDimensions = "TimescaleDB:AdditionalDimensions"; } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs index 1582f73..4a0d242 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Generators/HypertableOperationGenerator.cs @@ -139,6 +139,47 @@ public List Generate(AlterHypertableOperation operation) } } + // Handle AdditionalDimensions - only add new dimensions + // NOTE: TimescaleDB does NOT support removing dimensions from hypertables. + // Once a dimension is added, it cannot be removed. Therefore, we only generate + // SQL for adding new dimensions and ignore dimension removals. + IReadOnlyList newDimensions = operation.AdditionalDimensions ?? []; + IReadOnlyList oldDimensions = operation.OldAdditionalDimensions ?? []; + + // Find dimensions that are in new but not in old (added dimensions) + foreach (Dimension newDim in newDimensions) + { + bool exists = oldDimensions.Any(oldDim => + oldDim.ColumnName == newDim.ColumnName && + oldDim.Type == newDim.Type && + oldDim.Interval == newDim.Interval && + oldDim.NumberOfPartitions == newDim.NumberOfPartitions); + + if (!exists) + { + if (newDim.Type == EDimensionType.Range) + { + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{newDim.ColumnName}', INTERVAL '{newDim.Interval}'));"); + } + else if (newDim.Type == EDimensionType.Hash) + { + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{newDim.ColumnName}', {newDim.NumberOfPartitions}));"); + } + } + } + + // Warn if dimensions were removed (which cannot be reversed in TimescaleDB) + List removedDimensions = [.. oldDimensions + .Where(oldDim => !newDimensions.Any(newDim => + oldDim.ColumnName == newDim.ColumnName && + oldDim.Type == newDim.Type))]; + + if (removedDimensions.Count > 0) + { + string dimensionList = string.Join(", ", removedDimensions.Select(d => $"'{d.ColumnName}'")); + statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); + } + return statements; } } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs index 23d9838..10f15dd 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Internals/Features/Hypertables/HypertableDiffer.cs @@ -1,4 +1,5 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -28,7 +29,8 @@ public IReadOnlyList GetDifferences(IRelationalModel? source .Where(x => x.Target.ChunkTimeInterval != x.Source.ChunkTimeInterval || x.Target.EnableCompression != x.Source.EnableCompression || - !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) + !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) || + !AreDimensionsEqual(x.Target.AdditionalDimensions, x.Source.AdditionalDimensions) ); foreach (var hypertable in updatedHypertables) @@ -40,9 +42,11 @@ public IReadOnlyList GetDifferences(IRelationalModel? source ChunkTimeInterval = hypertable.Target.ChunkTimeInterval, EnableCompression = hypertable.Target.EnableCompression, ChunkSkipColumns = hypertable.Target.ChunkSkipColumns, + AdditionalDimensions = hypertable.Target.AdditionalDimensions, OldChunkTimeInterval = hypertable.Source.ChunkTimeInterval, OldEnableCompression = hypertable.Source.EnableCompression, - OldChunkSkipColumns = hypertable.Source.ChunkSkipColumns + OldChunkSkipColumns = hypertable.Source.ChunkSkipColumns, + OldAdditionalDimensions = hypertable.Source.AdditionalDimensions }); } @@ -59,5 +63,29 @@ private static bool AreChunkSkipColumnsEqual(IReadOnlyList? list1, IRead return new HashSet(list1).SetEquals(list2); } + + private static bool AreDimensionsEqual(IReadOnlyList? list1, IReadOnlyList? list2) + { + if (list1 == null && list2 == null) return true; + if (list1 == null || list2 == null) return false; + if (list1.Count != list2.Count) return false; + + // Compare each dimension's properties + for (int i = 0; i < list1.Count; i++) + { + Dimension dim1 = list1[i]; + Dimension dim2 = list2[i]; + + if (dim1.ColumnName != dim2.ColumnName || + dim1.Type != dim2.Type || + dim1.Interval != dim2.Interval || + dim1.NumberOfPartitions != dim2.NumberOfPartitions) + { + return false; + } + } + + return true; + } } } \ No newline at end of file diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterContinuousAggregateOperation.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterContinuousAggregateOperation.cs index f04d87c..bd42ce7 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterContinuousAggregateOperation.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterContinuousAggregateOperation.cs @@ -9,10 +9,10 @@ public class AlterContinuousAggregateOperation : MigrationOperation public string? ChunkInterval { get; set; } public string? OldChunkInterval { get; set; } - + public bool CreateGroupIndexes { get; set; } public bool OldCreateGroupIndexes { get; set; } - + public bool MaterializedOnly { get; set; } public bool OldMaterializedOnly { get; set; } } diff --git a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterHypertableOperation.cs b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterHypertableOperation.cs index 6f99e22..57fecb5 100644 --- a/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterHypertableOperation.cs +++ b/CmdScale.EntityFrameworkCore.TimescaleDB/Operations/AlterHypertableOperation.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using Microsoft.EntityFrameworkCore.Migrations.Operations; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations { @@ -13,9 +14,11 @@ public class AlterHypertableOperation : MigrationOperation // Only timestamp-like and Integer-like columns are supported for chunk skipping // Cannot be reverted once enabled public IReadOnlyList? ChunkSkipColumns { get; set; } + public IReadOnlyList? AdditionalDimensions { get; set; } public string OldChunkTimeInterval { get; set; } = string.Empty; public bool OldEnableCompression { get; set; } public IReadOnlyList? OldChunkSkipColumns { get; set; } + public IReadOnlyList? OldAdditionalDimensions { get; set; } } }