diff --git a/docs/fluent-api/hypertable.md b/docs/fluent-api/hypertable.md index 53ca6a8..d70ca30 100644 --- a/docs/fluent-api/hypertable.md +++ b/docs/fluent-api/hypertable.md @@ -53,6 +53,8 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration } ``` +Changing the settings of an existing dimension is applied in place: a changed partition count on a hash dimension migrates as `set_number_partitions`, and a changed interval on a secondary range dimension migrates as `set_chunk_time_interval` with the dimension name. Both only affect chunks created after the migration; existing chunks keep their layout. Changing a dimension's *type* (hash to range or vice versa) on the same column is not supported by TimescaleDB and is skipped with the dimension-removal warning described below. + ## Unsupported Model Changes Three model changes cannot be applied to an existing hypertable because TimescaleDB has no operation for them. The migration is still generated, but the affected change is **skipped**: no SQL is emitted for it, the database keeps its current shape, and the model and database intentionally diverge until the change is resolved manually (typically by recreating the table). Each skipped change surfaces in two places: @@ -63,7 +65,7 @@ Three model changes cannot be applied to an existing hypertable because Timescal The three changes are: - **Re-designating the time column** of an existing hypertable — TimescaleDB cannot repartition onto a different time column. Every *other* change carried by the same migration (chunk interval, compression, dimensions) still emits SQL normally. A pure column *rename* is not affected: it is applied as an ordinary rename and produces no warning. -- **Removing a dimension** — TimescaleDB provides no `remove_dimension`. The dimension stays in the database. +- **Removing a dimension** — TimescaleDB provides no `remove_dimension`. The dimension stays in the database. Note that changing an existing dimension's *settings* (hash partition count, range interval) is **not** affected: those changes are applied in place (see [Advanced Partitioning with Dimensions](#advanced-partitioning-with-dimensions)). - **Removing the hypertable designation** while keeping the entity — TimescaleDB cannot convert a hypertable back to a plain table. This is distinct from deleting the entity entirely, which remains a normal EF `DropTable`. The scaffolded migration contains a `RemoveHypertable(...)` call annotated with a comment stating that it only emits the warning and leaves the database unchanged. > :warning: **Note:** Because the warning honors `ConfigureWarnings`, it can be silenced with `w.Ignore(TimescaleDbEventId.UnsupportedHypertableChangeSkipped)` or promoted to a hard failure with `w.Throw(...)` — the latter is useful in CI to reject a migration that would silently drift from the model. See [Diagnostics event IDs](../05-apache-edition.md#diagnostics-event-ids). diff --git a/src/Eftdb/Generators/HypertableSqlGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs index d8b077f..2f2c0b1 100644 --- a/src/Eftdb/Generators/HypertableSqlGenerator.cs +++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs @@ -248,20 +248,16 @@ private static void ApplyDimensionChanges( string qualifiedTableName, List statements) { - // TimescaleDB does NOT support removing dimensions from hypertables. - // Once added, a dimension cannot be removed, so only additions are generated. IReadOnlyList newDimensions = operation.AdditionalDimensions ?? []; IReadOnlyList oldDimensions = operation.OldAdditionalDimensions ?? []; foreach (Dimension newDim in newDimensions) { - bool exists = oldDimensions.Any(oldDim => + Dimension? previous = oldDimensions.FirstOrDefault(oldDim => oldDim.ColumnName == newDim.ColumnName && - oldDim.Type == newDim.Type && - oldDim.Interval == newDim.Interval && - oldDim.NumberOfPartitions == newDim.NumberOfPartitions); + oldDim.Type == newDim.Type); - if (!exists) + if (previous == null) { if (newDim.Type == EDimensionType.Range) { @@ -273,6 +269,21 @@ private static void ApplyDimensionChanges( { statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {newDim.NumberOfPartitions}), if_not_exists => true);"); } + + continue; + } + + if (newDim.Type == EDimensionType.Range + && newDim.Interval != previous.Interval + && !string.IsNullOrEmpty(newDim.Interval)) + { + statements.Add($"SELECT set_chunk_time_interval({qualifiedTableName}, {SqlBuilderHelper.IntervalOrBigint(newDim.Interval)}, '{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}');"); + } + else if (newDim.Type == EDimensionType.Hash + && newDim.NumberOfPartitions != previous.NumberOfPartitions + && newDim.NumberOfPartitions != null) + { + statements.Add($"SELECT set_number_partitions({qualifiedTableName}, {newDim.NumberOfPartitions}, '{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}');"); } } diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs index d1d0d1d..95ed6b0 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs @@ -532,6 +532,151 @@ public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() result); } + [Fact] + public void DesignTime_Alter_ChangingHashPartitions_GeneratesSetNumberPartitions() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "tuned", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateHash("user_id", 8) + ], + OldAdditionalDimensions = + [ + Dimension.CreateHash("user_id", 4) + ] + }; + + string expected = @" + SELECT set_number_partitions('public.""tuned""', 8, 'user_id'); + "; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_ChangingRangeDimensionInterval_GeneratesSetChunkTimeInterval() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "ranged_tuned", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateRange("secondary_time", "14 days") + ], + OldAdditionalDimensions = + [ + Dimension.CreateRange("secondary_time", "7 days") + ] + }; + + string expected = @" + SELECT set_chunk_time_interval('public.""ranged_tuned""', INTERVAL '14 days', 'secondary_time'); + "; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_ChangingRangeDimensionIntegerInterval_GeneratesBigintCast() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "int_ranged_tuned", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateRange("sensor_id", "5000") + ], + OldAdditionalDimensions = + [ + Dimension.CreateRange("sensor_id", "1000") + ] + }; + + string expected = @" + SELECT set_chunk_time_interval('public.""int_ranged_tuned""', 5000::bigint, 'sensor_id'); + "; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Alter_ChangingDimensionType_EmitsWarningAndNoTuningSql() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "type_changed", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateRange("user_id", "1 day") + ], + OldAdditionalDimensions = + [ + Dimension.CreateHash("user_id", 4) + ] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Contains("add_dimension('public.\"type_changed\"', by_range('user_id', INTERVAL '1 day'), if_not_exists => true)", result); + Assert.Contains("does not support removing dimensions", result); + Assert.DoesNotContain("set_number_partitions", result); + Assert.DoesNotContain("set_chunk_time_interval", result); + } + + [Fact] + public void DesignTime_Alter_UnchangedDimensions_GenerateNothing() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "steady", + Schema = "public", + AdditionalDimensions = + [ + Dimension.CreateHash("user_id", 4), + Dimension.CreateRange("secondary_time", "7 days") + ], + OldAdditionalDimensions = + [ + Dimension.CreateHash("user_id", 4), + Dimension.CreateRange("secondary_time", "7 days") + ] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.DoesNotContain("add_dimension", result); + Assert.DoesNotContain("set_number_partitions", result); + Assert.DoesNotContain("set_chunk_time_interval", result); + Assert.DoesNotContain("WARNING", result); + } + [Fact] public void DesignTime_Alter_ChangingTimeColumn_EmitsWarningAndNoTimeColumnSql() { @@ -619,7 +764,7 @@ public void DesignTime_Alter_ChangingTimeColumn_WithNoOtherChanges_EmitsOnlyWarn } [Fact] - public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() + public void DesignTime_Alter_ModifyingDimension_GeneratesTuningCallInsteadOfAdd() { // Arrange AlterHypertableOperation operation = new() @@ -640,7 +785,8 @@ public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() string result = GetDesignTimeCode(operation); // Assert - Assert.Contains("by_hash('location', 8)", result); + Assert.Contains("set_number_partitions('public.\"modified_dims\"', 8, 'location')", result); + Assert.DoesNotContain("add_dimension", result); } [Fact]