Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/fluent-api/hypertable.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration<WeatherData>
}
```

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:
Expand All @@ -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).
Expand Down
25 changes: 18 additions & 7 deletions src/Eftdb/Generators/HypertableSqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,20 +248,16 @@ private static void ApplyDimensionChanges(
string qualifiedTableName,
List<string> statements)
{
// TimescaleDB does NOT support removing dimensions from hypertables.
// Once added, a dimension cannot be removed, so only additions are generated.
IReadOnlyList<Dimension> newDimensions = operation.AdditionalDimensions ?? [];
IReadOnlyList<Dimension> 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)
{
Expand All @@ -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)}');");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand Down
Loading