diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 45fe2ec..a73b811 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -104,7 +104,7 @@ Drops negative (before EF table drops, reverse dependency order); adds/alters po | -40 | DropContinuousAggregate | | -20 | DropReorderPolicy | | 0 | standard EF operations | -| 10 / 15 | CreateHypertable / AlterHypertable | +| 10 / 15 / 16 | CreateHypertable / AlterHypertable / RemoveHypertable | | 20 | Add/AlterReorderPolicy | | 30 / 40 | Create/AlterContinuousAggregate | | 45 | AddContinuousAggregatePolicy | diff --git a/docs/05-apache-edition.md b/docs/05-apache-edition.md index fc1a5d3..d815ab2 100644 --- a/docs/05-apache-edition.md +++ b/docs/05-apache-edition.md @@ -46,6 +46,7 @@ Provider warnings are dispatched through EF Core's diagnostics pipeline, so they | --- | --- | --- | | `CommunityFeatureSkipped` | 63000 | A Community-only feature (compression, policy, or continuous aggregate) was skipped at migration SQL generation because `UseApacheEdition()` is set. | | `TimeBucketColumnUnmapped` | 63001 | A continuous aggregate exposes a `time_bucket` column that no property maps to. Raised at model validation. | +| `UnsupportedHypertableChangeSkipped` | 63002 | A model change to an existing hypertable (time-column re-designation, dimension removal, or removal of the hypertable designation) was skipped because TimescaleDB cannot apply it. Raised at migration SQL generation, in every edition. See [Hypertables — Unsupported Model Changes](fluent-api/hypertable.md#unsupported-model-changes). | ```csharp using CmdScale.EntityFrameworkCore.TimescaleDB.Diagnostics; @@ -54,5 +55,6 @@ optionsBuilder.UseNpgsql(connectionString) .UseTimescaleDb() .ConfigureWarnings(w => w .Ignore(TimescaleDbEventId.CommunityFeatureSkipped) - .Throw(TimescaleDbEventId.TimeBucketColumnUnmapped)); + .Throw(TimescaleDbEventId.TimeBucketColumnUnmapped) + .Throw(TimescaleDbEventId.UnsupportedHypertableChangeSkipped)); ``` diff --git a/docs/data-annotations/hypertable.md b/docs/data-annotations/hypertable.md index 7eb1f20..b6a08e0 100644 --- a/docs/data-annotations/hypertable.md +++ b/docs/data-annotations/hypertable.md @@ -27,6 +27,10 @@ public class DeviceReading To add partitioning with dimensions, refere to the [Fluent API](../fluent-api/hypertable#advanced-partitioning-with-dimensions). +## Unsupported Model Changes + +Changing the time column, removing a dimension, or removing the `[Hypertable]` attribute from an existing hypertable cannot be applied by TimescaleDB. Such a change is skipped, leaving a `-- WARNING: Hypertable '': ...` comment in the migration SQL and raising `TimescaleDbEventId.UnsupportedHypertableChangeSkipped` (event id 63002) through the diagnostics pipeline. The behavior is identical to the Fluent API - see [Fluent API — Unsupported Model Changes](../fluent-api/hypertable.md#unsupported-model-changes). + ## Compression Time-series data can be compressed to reduce the amount of storage required, and increase the speed of some queries. This is a cornerstone feature of TimescaleDB. When new data is added to your database, it is in the form of uncompressed rows. TimescaleDB uses a built-in job scheduler to convert this data to the form of compressed columns. This occurs across chunks of TimescaleDB hypertables. diff --git a/docs/fluent-api/hypertable.md b/docs/fluent-api/hypertable.md index 6498e88..53ca6a8 100644 --- a/docs/fluent-api/hypertable.md +++ b/docs/fluent-api/hypertable.md @@ -53,6 +53,21 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration } ``` +## 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: + +- A `-- WARNING: Hypertable '
': ...` comment in the generated migration SQL (visible in `dotnet ef migrations script` output). +- A warning through EF Core's diagnostics pipeline as `TimescaleDbEventId.UnsupportedHypertableChangeSkipped` (event id 63002, `Migrations` category), reaching `ILogger`, `LogTo(...)`, and `DiagnosticSource`. + +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 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). + ## Compression Time-series data can be compressed to reduce the amount of storage required, and increase the speed of some queries. This is a cornerstone feature of TimescaleDB. When new data is added to your database, it is in the form of uncompressed rows. TimescaleDB uses a built-in job scheduler to convert this data to the form of compressed columns. This occurs across chunks of TimescaleDB hypertables. diff --git a/src/Eftdb.Design/Features/Hypertable/HypertableCSharpGenerator.cs b/src/Eftdb.Design/Features/Hypertable/HypertableCSharpGenerator.cs index e1e3c09..0e22112 100644 --- a/src/Eftdb.Design/Features/Hypertable/HypertableCSharpGenerator.cs +++ b/src/Eftdb.Design/Features/Hypertable/HypertableCSharpGenerator.cs @@ -11,6 +11,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Features.Hypertable /// internal class HypertableCSharpGenerator(ICSharpHelper code) { + internal const string RemoveHypertableWarningComment = + "// TimescaleDB cannot convert a hypertable back into a plain table; this call only emits a warning and leaves the database unchanged."; + private readonly ICSharpHelper code = code; public void Generate(CreateHypertableOperation operation, IndentedStringBuilder builder) @@ -60,6 +63,9 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b if (!string.IsNullOrEmpty(operation.Schema)) call.Arg("schema", code.Literal(operation.Schema)); + if (!string.IsNullOrEmpty(operation.TimeColumnName)) + call.Arg("timeColumnName", code.Literal(operation.TimeColumnName)); + if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval)); @@ -85,6 +91,9 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b call.Arg("compressChunkTimeInterval", code.Literal(operation.CompressChunkTimeInterval)); // Old* values — emitted for Down() reversibility, only when non-default. + if (!string.IsNullOrEmpty(operation.OldTimeColumnName)) + call.Arg("oldTimeColumnName", code.Literal(operation.OldTimeColumnName)); + if (!string.IsNullOrEmpty(operation.OldChunkTimeInterval)) call.Arg("oldChunkTimeInterval", code.Literal(operation.OldChunkTimeInterval)); @@ -110,6 +119,16 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b call.Arg("oldCompressChunkTimeInterval", code.Literal(operation.OldCompressChunkTimeInterval)); } + public void Generate(RemoveHypertableOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "RemoveHypertable"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + } + // Writes the dimension list directly into the builder so each entry is on its own // line at the correct indent level. private void AppendDimensionList(IndentedStringBuilder builder, IReadOnlyList dimensions) diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index 2e43b8d..a323ae1 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -13,6 +13,34 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design { public class TimescaleCSharpMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) : CSharpMigrationOperationGenerator(dependencies) { + public override void Generate(string builderName, IReadOnlyList operations, IndentedStringBuilder builder) + { + ArgumentNullException.ThrowIfNull(operations); + ArgumentNullException.ThrowIfNull(builder); + + bool first = true; + foreach (MigrationOperation operation in operations) + { + if (first) + { + first = false; + } + else + { + builder + .AppendLine() + .AppendLine(); + } + + if (operation is RemoveHypertableOperation) + { + builder.AppendLine(HypertableCSharpGenerator.RemoveHypertableWarningComment); + } + + base.Generate(builderName, [operation], builder); + } + } + protected override void Generate(MigrationOperation operation, IndentedStringBuilder builder) { ArgumentNullException.ThrowIfNull(operation); @@ -26,6 +54,9 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui case AlterHypertableOperation alter: new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(alter, builder); return; + case RemoveHypertableOperation remove: + new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(remove, builder); + return; case AddReorderPolicyOperation addReorder: new ReorderPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addReorder, builder); diff --git a/src/Eftdb/Diagnostics/TimescaleDbEventId.cs b/src/Eftdb/Diagnostics/TimescaleDbEventId.cs index 760a742..c2c53d6 100644 --- a/src/Eftdb/Diagnostics/TimescaleDbEventId.cs +++ b/src/Eftdb/Diagnostics/TimescaleDbEventId.cs @@ -26,6 +26,7 @@ private enum Id { CommunityFeatureSkipped = Base, TimeBucketColumnUnmapped, + UnsupportedHypertableChangeSkipped, } /// @@ -42,6 +43,15 @@ private enum Id /// public static readonly EventId TimeBucketColumnUnmapped = MakeValidationId(Id.TimeBucketColumnUnmapped); + /// + /// A hypertable model change was skipped because TimescaleDB cannot apply it to an existing hypertable + /// (e.g. changing the time column, removing a space/range dimension, or reverting a hypertable back to a + /// plain table). The provider emits a warning comment into the generated migration SQL and raises this + /// event once per skipped change, in the category, instead of + /// producing SQL that would fail. + /// + public static readonly EventId UnsupportedHypertableChangeSkipped = MakeMigrationsId(Id.UnsupportedHypertableChangeSkipped); + private static readonly string MigrationsPrefix = DbLoggerCategory.Migrations.Name + "."; private static readonly string ValidationPrefix = DbLoggerCategory.Model.Validation.Name + "."; diff --git a/src/Eftdb/Diagnostics/TimescaleDbLoggerExtensions.cs b/src/Eftdb/Diagnostics/TimescaleDbLoggerExtensions.cs index fa565f2..90c286e 100644 --- a/src/Eftdb/Diagnostics/TimescaleDbLoggerExtensions.cs +++ b/src/Eftdb/Diagnostics/TimescaleDbLoggerExtensions.cs @@ -66,6 +66,34 @@ public static void TimeBucketColumnUnmapped( } } + /// + /// Warns that a hypertable model change cannot be applied to an existing hypertable and was skipped, so a + /// warning comment stands in for the SQL that would otherwise fail. + /// + /// The migrations diagnostics logger. + /// The affected hypertable's name. + /// The human-readable description of the skipped change. + public static void UnsupportedHypertableChangeSkipped( + this IDiagnosticsLogger diagnostics, + string tableName, + string changeDescription) + { + EventDefinition definition = LogUnsupportedHypertableChangeSkipped(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log(diagnostics, tableName, changeDescription); + } + + if (diagnostics.NeedsEventData(definition, out bool diagnosticSourceEnabled, out bool simpleLogEnabled)) + { + EventData eventData = new( + definition, + (d, _) => ((EventDefinition)d).GenerateMessage(tableName, changeDescription)); + diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled); + } + } + private static EventDefinition LogCommunityFeatureSkipped(IDiagnosticsLogger diagnostics) { TimescaleDbLoggingDefinitions definitions = GetDefinitions(diagnostics); @@ -113,6 +141,29 @@ private static EventDefinition LogTimeBucketColumnUnmapp return (EventDefinition)definition; } + private static EventDefinition LogUnsupportedHypertableChangeSkipped(IDiagnosticsLogger diagnostics) + { + TimescaleDbLoggingDefinitions definitions = GetDefinitions(diagnostics); + EventDefinitionBase? definition = definitions.LogUnsupportedHypertableChangeSkipped; + if (definition == null) + { + EventDefinition created = new( + diagnostics.Options, + TimescaleDbEventId.UnsupportedHypertableChangeSkipped, + LogLevel.Warning, + "TimescaleDbEventId.UnsupportedHypertableChangeSkipped", + static level => LoggerMessage.Define( + level, + TimescaleDbEventId.UnsupportedHypertableChangeSkipped, + "The change to hypertable '{Table}' was skipped because TimescaleDB cannot apply it to an " + + "existing hypertable: {ChangeDescription} The migration emits a warning comment instead of SQL.")); + + definition = Interlocked.CompareExchange(ref definitions.LogUnsupportedHypertableChangeSkipped, created, null) ?? created; + } + + return (EventDefinition)definition; + } + // Fail loud rather than fall back: EF and Npgsql hard-cast the provider's LoggingDefinitions with no // graceful path, so a non-TimescaleDB instance here means the Replace registration was lost — a wiring // bug that must surface, not a runtime condition to tolerate. diff --git a/src/Eftdb/Diagnostics/TimescaleDbLoggingDefinitions.cs b/src/Eftdb/Diagnostics/TimescaleDbLoggingDefinitions.cs index dfc9e43..e80e6bf 100644 --- a/src/Eftdb/Diagnostics/TimescaleDbLoggingDefinitions.cs +++ b/src/Eftdb/Diagnostics/TimescaleDbLoggingDefinitions.cs @@ -20,6 +20,11 @@ public class TimescaleDbLoggingDefinitions : NpgsqlLoggingDefinitions /// Cached definition for . /// public EventDefinitionBase? LogTimeBucketColumnUnmapped; + + /// + /// Cached definition for . + /// + public EventDefinitionBase? LogUnsupportedHypertableChangeSkipped; } #pragma warning restore EF1001 } diff --git a/src/Eftdb/Generators/HypertableSqlGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs index 42b5637..d8b077f 100644 --- a/src/Eftdb/Generators/HypertableSqlGenerator.cs +++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs @@ -19,6 +19,7 @@ public static List Generate(CreateHypertableOperation operation, bool us StringBuilder createHypertableCall = new(); createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(operation.TimeColumnName)}'"); + createHypertableCall.Append(", if_not_exists => true"); createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : ""); if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) @@ -87,11 +88,11 @@ public static List Generate(CreateHypertableOperation operation, bool us { string intervalExpression = SqlBuilderHelper.IntervalOrBigint(dimension.Interval ?? string.Empty); - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {intervalExpression}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {intervalExpression}), if_not_exists => true);"); } else if (dimension.Type == EDimensionType.Hash) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {dimension.NumberOfPartitions}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {dimension.NumberOfPartitions}), if_not_exists => true);"); } } } @@ -108,6 +109,14 @@ public static List Generate(AlterHypertableOperation operation, bool use List statements = []; List communityStatements = []; + if (operation.TimeColumnName != operation.OldTimeColumnName) + { + statements.Add(SqlBuilderHelper.UnsupportedHypertableChangeComment( + operation.TableName, + $"TimescaleDB does not support changing the time column of an existing hypertable " + + $"(from '{operation.OldTimeColumnName}' to '{operation.TimeColumnName}'). The change was skipped.")); + } + if (operation.ChunkTimeInterval != operation.OldChunkTimeInterval) { StringBuilder setChunkTimeInterval = new(); @@ -127,6 +136,17 @@ public static List Generate(AlterHypertableOperation operation, bool use return statements; } + public static List Generate(RemoveHypertableOperation operation) + { + return + [ + SqlBuilderHelper.UnsupportedHypertableChangeComment( + operation.TableName, + "TimescaleDB does not support converting a hypertable back to a plain table. " + + "The hypertable designation was removed in the model but cannot be undone in the database. The change was skipped."), + ]; + } + private static void ApplyCompressionChanges( AlterHypertableOperation operation, string qualifiedIdentifier, @@ -247,11 +267,11 @@ private static void ApplyDimensionChanges( { string intervalExpression = SqlBuilderHelper.IntervalOrBigint(newDim.Interval ?? string.Empty); - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {intervalExpression}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {intervalExpression}), if_not_exists => true);"); } else if (newDim.Type == EDimensionType.Hash) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {newDim.NumberOfPartitions}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {newDim.NumberOfPartitions}), if_not_exists => true);"); } } } @@ -264,7 +284,9 @@ private static void ApplyDimensionChanges( if (removedDimensions.Count > 0) { string dimensionList = string.Join(", ", removedDimensions.Select(d => $"'{SqlBuilderHelper.EscapeStringLiteral(d.ColumnName)}'")); - statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); + statements.Add(SqlBuilderHelper.UnsupportedHypertableChangeComment( + operation.TableName, + $"TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}. The change was skipped.")); } } } diff --git a/src/Eftdb/Generators/SqlBuilderHelper.cs b/src/Eftdb/Generators/SqlBuilderHelper.cs index 2cc5305..1f1b083 100644 --- a/src/Eftdb/Generators/SqlBuilderHelper.cs +++ b/src/Eftdb/Generators/SqlBuilderHelper.cs @@ -142,6 +142,22 @@ public static string FormatTimestamp(DateTime value) /// internal static string SkipComment(string skipMessage) => $"{SkipCommentMarker}{skipMessage}"; + /// + /// The comment prefix marking a hypertable change that TimescaleDB cannot apply. The migrations SQL + /// generator detects this prefix to raise UnsupportedHypertableChangeSkipped, so both the scripted + /// SQL and the diagnostics pipeline stay in sync. + /// + internal const string UnsupportedHypertableChangeMarker = SkipCommentMarker + "WARNING: "; + + /// + /// Formats a warning about a hypertable change that cannot be applied to an existing hypertable as a SQL + /// comment statement, carrying the affected table so the change can be logged from the generated output. + /// + /// The affected hypertable's name. + /// The human-readable description of the skipped change. + internal static string UnsupportedHypertableChangeComment(string tableName, string changeDescription) + => $"{UnsupportedHypertableChangeMarker}Hypertable '{tableName}': {changeDescription}"; + /// /// Applies the edition policy to Community-only statements. For the Community edition /// (default) the statements are returned unchanged. When the provider is configured for diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs index e2703e0..45140bb 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs @@ -29,6 +29,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source (target, source) => new { Target = target, Source = source } ) .Where(x => + x.Target.TimeColumnName != x.Source.TimeColumnName || x.Target.ChunkTimeInterval != x.Source.ChunkTimeInterval || x.Target.EnableCompression != x.Source.EnableCompression || !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) || @@ -47,6 +48,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source Schema = hypertable.Target.Schema, // Current values + TimeColumnName = hypertable.Target.TimeColumnName, ChunkTimeInterval = hypertable.Target.ChunkTimeInterval, EnableCompression = hypertable.Target.EnableCompression, ChunkSkipColumns = hypertable.Target.ChunkSkipColumns, @@ -57,6 +59,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source CompressChunkTimeInterval = hypertable.Target.CompressChunkTimeInterval, // Old values + OldTimeColumnName = hypertable.Source.TimeColumnName, OldChunkTimeInterval = hypertable.Source.ChunkTimeInterval, OldEnableCompression = hypertable.Source.EnableCompression, OldChunkSkipColumns = hypertable.Source.ChunkSkipColumns, @@ -68,9 +71,40 @@ public IReadOnlyList GetDifferences(IRelationalModel? source }); } + IEnumerable removedHypertables = sourceHypertables + .Where(s => !targetHypertables.Any(t => t.Schema == s.Schema && t.TableName == s.TableName)); + + foreach (CreateHypertableOperation removed in removedHypertables) + { + if (TableExists(target, removed.Schema, removed.TableName)) + { + operations.Add(new RemoveHypertableOperation + { + Schema = removed.Schema, + TableName = removed.TableName, + }); + } + } + return operations; } + /// + /// Determines whether a table with the given schema and name is present in the target relational model, + /// normalizing a missing schema to the default so the lookup matches the extractor's schema handling. + /// + private static bool TableExists(IRelationalModel? target, string schema, string tableName) + { + if (target == null) + { + return false; + } + + return target.Tables.Any(t => + t.Name == tableName && + (t.Schema ?? DefaultValues.DefaultSchema) == schema); + } + /// /// Produces a copy of a source hypertable with its table, schema, and all column-bearing fields rewritten /// through the rename maps, so that a pure rename compares equal to its target and produces no operation. diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs index 7958c07..bfb3bb0 100644 --- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs +++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs @@ -149,6 +149,8 @@ private static int GetOperationPriority(MigrationOperation operation) return 10; case AlterHypertableOperation: return 15; + case RemoveHypertableOperation: + return 16; case AddReorderPolicyOperation: case AlterReorderPolicyOperation: diff --git a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs index 3624fa7..37832b8 100644 --- a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs +++ b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs @@ -45,6 +45,7 @@ public static OperationBuilder AlterHypertable( this MigrationBuilder migrationBuilder, string tableName, string? schema = null, + string? timeColumnName = null, string? chunkTimeInterval = null, bool enableCompression = false, IReadOnlyList? chunkSkipColumns = null, @@ -53,6 +54,7 @@ public static OperationBuilder AlterHypertable( IReadOnlyList? compressionOrderBy = null, string? compressionSparseIndex = null, string? compressChunkTimeInterval = null, + string? oldTimeColumnName = null, string? oldChunkTimeInterval = null, bool oldEnableCompression = false, IReadOnlyList? oldChunkSkipColumns = null, @@ -66,6 +68,8 @@ public static OperationBuilder AlterHypertable( { TableName = tableName, Schema = schema ?? string.Empty, + TimeColumnName = timeColumnName ?? string.Empty, + OldTimeColumnName = oldTimeColumnName ?? string.Empty, ChunkTimeInterval = chunkTimeInterval ?? string.Empty, EnableCompression = enableCompression, ChunkSkipColumns = chunkSkipColumns, @@ -87,5 +91,20 @@ public static OperationBuilder AlterHypertable( migrationBuilder.Operations.Add(operation); return new OperationBuilder(operation); } + + public static OperationBuilder RemoveHypertable( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null) + { + RemoveHypertableOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } } } diff --git a/src/Eftdb/Operations/AlterHypertableOperation.cs b/src/Eftdb/Operations/AlterHypertableOperation.cs index af76584..d7eacd3 100644 --- a/src/Eftdb/Operations/AlterHypertableOperation.cs +++ b/src/Eftdb/Operations/AlterHypertableOperation.cs @@ -7,6 +7,8 @@ public class AlterHypertableOperation : MigrationOperation { public string TableName { get; set; } = string.Empty; public string Schema { get; set; } = string.Empty; + public string TimeColumnName { get; set; } = string.Empty; + public string OldTimeColumnName { get; set; } = string.Empty; public string ChunkTimeInterval { get; set; } = string.Empty; public bool EnableCompression { get; set; } diff --git a/src/Eftdb/Operations/DropContinuousAggregateOperation.cs b/src/Eftdb/Operations/DropContinuousAggregateOperation.cs index 46199b8..9cffd56 100644 --- a/src/Eftdb/Operations/DropContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/DropContinuousAggregateOperation.cs @@ -4,6 +4,16 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations { public class DropContinuousAggregateOperation : MigrationOperation { + /// + /// Dropping a continuous aggregate destroys its materialized data. When the source + /// hypertable's retention policy has already dropped the covered raw data, that history is + /// unrecoverable, so scaffolding surfaces EF's data-loss warning for every drop. + /// + public DropContinuousAggregateOperation() + { + IsDestructiveChange = true; + } + public string Schema { get; set; } = string.Empty; public string MaterializedViewName { get; set; } = string.Empty; } diff --git a/src/Eftdb/Operations/RemoveHypertableOperation.cs b/src/Eftdb/Operations/RemoveHypertableOperation.cs new file mode 100644 index 0000000..dadcc4b --- /dev/null +++ b/src/Eftdb/Operations/RemoveHypertableOperation.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + /// + /// Signals that an entity lost its hypertable designation while its table still exists in the target model. + /// TimescaleDB cannot convert a hypertable back into a plain table, so this operation applies no SQL; it only + /// surfaces a warning so the model/database drift is visible. + /// + public class RemoveHypertableOperation : MigrationOperation + { + public string Schema { get; set; } = string.Empty; + public string TableName { get; set; } = string.Empty; + } +} diff --git a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs index 824d3b1..2e27b1a 100644 --- a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs +++ b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs @@ -41,6 +41,10 @@ protected override void Generate( statements = HypertableSqlGenerator.Generate(alterHypertableOperation, _useLegacyCompressionNames, _isApacheEdition); break; + case RemoveHypertableOperation removeHypertableOperation: + statements = HypertableSqlGenerator.Generate(removeHypertableOperation); + break; + case AlterReorderPolicyOperation alterReorderPolicyOperation: statements = ReorderPolicySqlGenerator.Generate(alterReorderPolicyOperation, _isApacheEdition); break; @@ -104,6 +108,7 @@ protected override void Generate( } LogSkippedCommunityFeatures(statements); + LogSkippedHypertableChanges(statements); bool usePerform = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent); SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform); @@ -131,6 +136,40 @@ private void LogSkippedCommunityFeatures(List statements) } } + /// + /// Surfaces the warning comments for hypertable changes TimescaleDB cannot apply as generation-time diagnostics. + /// + private void LogSkippedHypertableChanges(List statements) + { + IDiagnosticsLogger logger = migrationsLogger ?? Dependencies.MigrationsLogger; + foreach (string statement in statements) + { + if (!statement.StartsWith(SqlBuilderHelper.UnsupportedHypertableChangeMarker, StringComparison.Ordinal)) + { + continue; + } + + (string table, string description) = ParseHypertableChangeComment(statement); + logger.UnsupportedHypertableChangeSkipped(table, description); + } + } + + private static (string Table, string Description) ParseHypertableChangeComment(string statement) + { + string payload = statement[SqlBuilderHelper.UnsupportedHypertableChangeMarker.Length..]; + + const string tablePrefix = "Hypertable '"; + int separatorIndex = payload.IndexOf("': ", StringComparison.Ordinal); + if (payload.StartsWith(tablePrefix, StringComparison.Ordinal) && separatorIndex > tablePrefix.Length) + { + string table = payload[tablePrefix.Length..separatorIndex]; + string description = payload[(separatorIndex + 3)..]; + return (table, description); + } + + return (string.Empty, payload); + } + /// /// Handles raw SQL operations from migration files (migrationBuilder.Sql calls). /// In idempotent mode, replaces SELECT with PERFORM because the SQL is wrapped diff --git a/tests/Eftdb.Tests/Diagnostics/TimescaleDbWarningEmissionTests.cs b/tests/Eftdb.Tests/Diagnostics/TimescaleDbWarningEmissionTests.cs index 707a1c5..1d023d4 100644 --- a/tests/Eftdb.Tests/Diagnostics/TimescaleDbWarningEmissionTests.cs +++ b/tests/Eftdb.Tests/Diagnostics/TimescaleDbWarningEmissionTests.cs @@ -17,6 +17,7 @@ public class TimescaleDbWarningEmissionTests private const string ConnectionString = "Host=localhost;Database=dummy;Username=x;Password=x"; private const string SkipWarningFragment = "Skipping Community Edition feature"; private const string BucketWarningFragment = "cannot be queried through the entity"; + private const string HypertableChangeWarningFragment = "cannot apply it to an existing hypertable"; private sealed class CapturingLoggerProvider : ILoggerProvider { @@ -55,6 +56,20 @@ private static IReadOnlyList GenerateCommunityOperation(DbCont return generator.Generate([operation]); } + private static IReadOnlyList GenerateUnsupportedHypertableChange(DbContextOptions options) + { + using TestContext context = new(options); + IMigrationsSqlGenerator generator = context.GetService(); + + RemoveHypertableOperation operation = new() + { + TableName = "readings", + Schema = "public", + }; + + return generator.Generate([operation]); + } + #region Should_Reach_LogTo_Sink_For_Community_Feature_Skipped [Fact] @@ -149,6 +164,100 @@ public void Should_Throw_Community_Feature_Skipped_When_Configured() #endregion + #region Should_Reach_LogTo_Sink_For_Unsupported_Hypertable_Change + + [Fact] + public void Should_Reach_LogTo_Sink_For_Unsupported_Hypertable_Change() + { + // Arrange + List logToSink = []; + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseTimescaleDb() + .LogTo(logToSink.Add, LogLevel.Debug) + .EnableServiceProviderCaching(false) + .Options; + + // Act + GenerateUnsupportedHypertableChange(options); + + // Assert + Assert.Contains(logToSink, l => l.Contains(HypertableChangeWarningFragment, StringComparison.Ordinal)); + } + + #endregion + + #region Should_Reach_LoggerFactory_Sink_For_Unsupported_Hypertable_Change + + [Fact] + public void Should_Reach_LoggerFactory_Sink_For_Unsupported_Hypertable_Change() + { + // Arrange + CapturingLoggerProvider capture = new(); + using ILoggerFactory factory = LoggerFactory.Create(b => b.AddProvider(capture).SetMinimumLevel(LogLevel.Debug)); + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseTimescaleDb() + .UseLoggerFactory(factory) + .EnableServiceProviderCaching(false) + .Options; + + // Act + GenerateUnsupportedHypertableChange(options); + + // Assert + Assert.Contains(capture.Entries, e => e.Level == LogLevel.Warning + && e.EventId == TimescaleDbEventId.UnsupportedHypertableChangeSkipped + && e.Message.Contains(HypertableChangeWarningFragment, StringComparison.Ordinal)); + } + + #endregion + + #region Should_Silence_Unsupported_Hypertable_Change_When_Ignored + + [Fact] + public void Should_Silence_Unsupported_Hypertable_Change_When_Ignored() + { + // Arrange + CapturingLoggerProvider capture = new(); + using ILoggerFactory factory = LoggerFactory.Create(b => b.AddProvider(capture).SetMinimumLevel(LogLevel.Debug)); + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseTimescaleDb() + .UseLoggerFactory(factory) + .ConfigureWarnings(w => w.Ignore(TimescaleDbEventId.UnsupportedHypertableChangeSkipped)) + .EnableServiceProviderCaching(false) + .Options; + + // Act + GenerateUnsupportedHypertableChange(options); + + // Assert + Assert.DoesNotContain(capture.Entries, e => e.EventId == TimescaleDbEventId.UnsupportedHypertableChangeSkipped); + } + + #endregion + + #region Should_Throw_Unsupported_Hypertable_Change_When_Configured + + [Fact] + public void Should_Throw_Unsupported_Hypertable_Change_When_Configured() + { + // Arrange + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseTimescaleDb() + .ConfigureWarnings(w => w.Throw(TimescaleDbEventId.UnsupportedHypertableChangeSkipped)) + .EnableServiceProviderCaching(false) + .Options; + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => GenerateUnsupportedHypertableChange(options)); + Assert.Contains(HypertableChangeWarningFragment, exception.Message, StringComparison.Ordinal); + } + + #endregion + #region Should_Keep_Published_EventId_Values_Stable [Fact] @@ -157,6 +266,7 @@ public void Should_Keep_Published_EventId_Values_Stable() // Act & Assert Assert.Equal(63000, TimescaleDbEventId.CommunityFeatureSkipped.Id); Assert.Equal(63001, TimescaleDbEventId.TimeBucketColumnUnmapped.Id); + Assert.Equal(63002, TimescaleDbEventId.UnsupportedHypertableChangeSkipped.Id); } #endregion diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index dfc8618..48d6fda 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; @@ -2063,4 +2064,289 @@ public void Should_Detect_Alter_When_AdditionalDimensions_One_Null() } #endregion + + #region Should_Detect_TimeColumn_Redesignation + + private class MetricEntity30Old + { + public DateTime CreatedAt { get; set; } + public double Value { get; set; } + } + + private class MetricEntity30New + { + public DateTime RecordedAt { get; set; } + public double Value { get; set; } + } + + private class TimeColumnCreatedAtContext30 : 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("time_redesignation_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.CreatedAt); + }); + } + } + + private class TimeColumnRecordedAtContext30 : 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("time_redesignation_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.RecordedAt); + }); + } + } + + [Fact] + public void Should_Detect_TimeColumn_Redesignation() + { + // Arrange + using TimeColumnCreatedAtContext30 sourceContext = new(); + using TimeColumnRecordedAtContext30 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("CreatedAt", alterOp.OldTimeColumnName); + Assert.Equal("RecordedAt", alterOp.TimeColumnName); + } + + #endregion + + #region Should_Not_Detect_Change_When_TimeColumn_Only_Renamed + + private class MetricEntity31Old + { + public DateTime CreatedAt { get; set; } + public double Value { get; set; } + } + + private class MetricEntity31New + { + public DateTime RecordedAt { get; set; } + public double Value { get; set; } + } + + private class TimeColumnRenameSourceContext31 : 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("time_rename_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.CreatedAt); + }); + } + } + + private class TimeColumnRenameTargetContext31 : 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("time_rename_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.RecordedAt); + }); + } + } + + [Fact] + public void Should_Not_Detect_Change_When_TimeColumn_Only_Renamed() + { + // Arrange + using TimeColumnRenameSourceContext31 sourceContext = new(); + using TimeColumnRenameTargetContext31 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + FeatureDiffContext context = new() + { + ColumnRenames = new Dictionary<(string, string, string), string> + { + [("public", "time_rename_metrics", "CreatedAt")] = "RecordedAt", + }, + }; + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + Assert.Empty(operations); + } + + #endregion + + #region Should_Emit_RemoveHypertable_When_Designation_Removed_But_Table_Survives + + private class MetricEntity32 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableContext32 : 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("plain_table_survivor"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class PlainTableContext32 : 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("plain_table_survivor"); + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Emit_RemoveHypertable_When_Designation_Removed_But_Table_Survives() + { + // Arrange + using HypertableContext32 sourceContext = new(); + using PlainTableContext32 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + RemoveHypertableOperation? removeOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(removeOp); + Assert.Equal("plain_table_survivor", removeOp.TableName); + Assert.Equal("public", removeOp.Schema); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Not_Emit_RemoveHypertable_When_Entity_Removed_Entirely + + private class MetricEntity33 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableContext33 : 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("removed_entirely_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class EmptyContext33 : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + } + } + + [Fact] + public void Should_Not_Emit_RemoveHypertable_When_Entity_Removed_Entirely() + { + // Arrange + using HypertableContext33 sourceContext = new(); + using EmptyContext33 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs index 1cc0a97..993305f 100644 --- a/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs @@ -35,7 +35,7 @@ public void Should_Generate_Create_With_SparseIndex() }; string expected = @" - SELECT create_hypertable('public.""sensor_data""', 'ts'); + SELECT create_hypertable('public.""sensor_data""', 'ts', if_not_exists => true); ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = 'bloom(device_id)'); "; @@ -65,7 +65,7 @@ public void Should_Generate_Create_With_CompressChunkTimeInterval() }; string expected = @" - SELECT create_hypertable('public.""sensor_data""', 'ts'); + SELECT create_hypertable('public.""sensor_data""', 'ts', if_not_exists => true); ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = '24 hours'); "; @@ -96,7 +96,7 @@ public void Should_Generate_Create_With_SparseIndex_And_CompressChunkTimeInterva }; string expected = @" - SELECT create_hypertable('public.""sensor_data""', 'ts'); + SELECT create_hypertable('public.""sensor_data""', 'ts', if_not_exists => true); ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = 'bloom(device_id)', timescaledb.compress_chunk_time_interval = '7 days'); "; @@ -126,7 +126,7 @@ public void Should_Generate_Create_With_Empty_SparseIndex() }; string expected = @" - SELECT create_hypertable('public.""sensor_data""', 'ts'); + SELECT create_hypertable('public.""sensor_data""', 'ts', if_not_exists => true); ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''); "; diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs index 9f47069..d1d0d1d 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs @@ -46,8 +46,8 @@ public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() }; string expected = @" - SELECT create_hypertable('public.""events""', 'event_time', chunk_time_interval => INTERVAL '1 day'); - SELECT add_dimension('public.""events""', by_range('received_time', INTERVAL '7 days')); + SELECT create_hypertable('public.""events""', 'event_time', if_not_exists => true, chunk_time_interval => INTERVAL '1 day'); + SELECT add_dimension('public.""events""', by_range('received_time', INTERVAL '7 days'), if_not_exists => true); "; // Act @@ -74,9 +74,9 @@ public void DesignTime_Create_WithMultipleDimensions_GeneratesCorrectOrder() }; string expected = @" - SELECT create_hypertable('public.""distributed_events""', 'timestamp'); - SELECT add_dimension('public.""distributed_events""', by_hash('device_id', 4)); - SELECT add_dimension('public.""distributed_events""', by_range('processed_time', INTERVAL '1 month')); + SELECT create_hypertable('public.""distributed_events""', 'timestamp', if_not_exists => true); + SELECT add_dimension('public.""distributed_events""', by_hash('device_id', 4), if_not_exists => true); + SELECT add_dimension('public.""distributed_events""', by_range('processed_time', INTERVAL '1 month'), if_not_exists => true); "; // Act @@ -99,7 +99,7 @@ public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorre }; string expected = @" - SELECT create_hypertable('public.""high_freq_data""', 'ts', chunk_time_interval => 86400000000::bigint); + SELECT create_hypertable('public.""high_freq_data""', 'ts', if_not_exists => true, chunk_time_interval => 86400000000::bigint); "; // Act @@ -122,7 +122,7 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo }; string expected = @" - SELECT create_hypertable('public.""compressed_data""', 'time'); + SELECT create_hypertable('public.""compressed_data""', 'time', if_not_exists => true); ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.enable_columnstore = true); "; @@ -172,7 +172,7 @@ public void Runtime_Create_Minimal_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("SELECT create_hypertable('public.\"simple_table\"', 'time')", result); + Assert.Contains("SELECT create_hypertable('public.\"simple_table\"', 'time', if_not_exists => true)", result); Assert.EndsWith(";", result.Trim()); } @@ -235,7 +235,7 @@ public void Runtime_Create_WithHashDimension_GeneratesByHashSyntax() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"partitioned\"', by_hash('location_id', 8))", result); + Assert.Contains("add_dimension('public.\"partitioned\"', by_hash('location_id', 8), if_not_exists => true)", result); } [Fact] @@ -257,7 +257,7 @@ public void Runtime_Create_WithRangeDimension_GeneratesByRangeSyntax() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"ranged\"', by_range('secondary_time', INTERVAL '30 days'))", result); + Assert.Contains("add_dimension('public.\"ranged\"', by_range('secondary_time', INTERVAL '30 days'), if_not_exists => true)", result); } [Fact] @@ -279,7 +279,7 @@ public void Runtime_Create_WithRangeDimension_IntegerInterval_GeneratesNumericBy string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"integer_ranged\"', by_range('sensor_id', 10000::bigint))", result); + Assert.Contains("add_dimension('public.\"integer_ranged\"', by_range('sensor_id', 10000::bigint), if_not_exists => true)", result); Assert.DoesNotContain("INTERVAL", result); } @@ -302,7 +302,7 @@ public void Runtime_Create_WithRangeDimension_TimeInterval_GeneratesIntervalByRa string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"time_ranged\"', by_range('processed_time', INTERVAL '1 hour'))", result); + Assert.Contains("add_dimension('public.\"time_ranged\"', by_range('processed_time', INTERVAL '1 hour'), if_not_exists => true)", result); } [Fact] @@ -321,8 +321,8 @@ public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrec }; string expected = @" - SELECT create_hypertable('analytics.""integer_partitions""', 'timestamp'); - SELECT add_dimension('analytics.""integer_partitions""', by_range('partition_key', 5000::bigint)); + SELECT create_hypertable('analytics.""integer_partitions""', 'timestamp', if_not_exists => true); + SELECT add_dimension('analytics.""integer_partitions""', by_range('partition_key', 5000::bigint), if_not_exists => true); "; // Act @@ -349,7 +349,7 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() }; string expected = @" - SELECT create_hypertable('public.""segmented_data""', 'time'); + SELECT create_hypertable('public.""segmented_data""', 'time', if_not_exists => true); ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""tenant_id"", ""device_id""'); "; @@ -373,7 +373,7 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() }; string expected = @" - SELECT create_hypertable('public.""ordered_data""', 'time'); + SELECT create_hypertable('public.""ordered_data""', 'time', if_not_exists => true); ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.enable_columnstore = true, timescaledb.orderby = '""time"" DESC, ""value"" ASC NULLS LAST'); "; @@ -474,7 +474,7 @@ public void DesignTime_Alter_AddingDimension_GeneratesCorrectCode() }; string expected = @" - SELECT add_dimension('public.""expandable""', by_hash('user_id', 4)); + SELECT add_dimension('public.""expandable""', by_hash('user_id', 4), if_not_exists => true); "; // Act @@ -528,10 +528,96 @@ public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() // Assert Assert.Contains( - "-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: 'old_column'", + "-- WARNING: Hypertable 'cannot_remove': TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: 'old_column'. The change was skipped.", result); } + [Fact] + public void DesignTime_Alter_ChangingTimeColumn_EmitsWarningAndNoTimeColumnSql() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "readings", + Schema = "public", + TimeColumnName = "recorded_at", + OldTimeColumnName = "created_at" + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Contains( + "-- WARNING: Hypertable 'readings': TimescaleDB does not support changing the time column of an existing hypertable (from 'created_at' to 'recorded_at'). The change was skipped.", + result); + Assert.DoesNotContain("create_hypertable", result); + Assert.DoesNotContain("set_chunk_time_interval", result); + } + + [Fact] + public void DesignTime_Alter_ChangingTimeColumnAndChunkInterval_EmitsWarningAndRealSql() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "readings", + Schema = "public", + TimeColumnName = "recorded_at", + OldTimeColumnName = "created_at", + ChunkTimeInterval = "1 day", + OldChunkTimeInterval = "7 days" + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Contains("-- WARNING: Hypertable 'readings': TimescaleDB does not support changing the time column", result); + Assert.Contains("SELECT set_chunk_time_interval('public.\"readings\"', INTERVAL '1 day');", result); + } + + [Fact] + public void DesignTime_Remove_Hypertable_EmitsWarningCommentOnly() + { + // Arrange + RemoveHypertableOperation operation = new() + { + TableName = "readings", + Schema = "public" + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal( + "-- WARNING: Hypertable 'readings': TimescaleDB does not support converting a hypertable back to a plain table. " + + "The hypertable designation was removed in the model but cannot be undone in the database. The change was skipped.", + result); + } + + [Fact] + public void DesignTime_Alter_ChangingTimeColumn_WithNoOtherChanges_EmitsOnlyWarning() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "readings", + Schema = "public", + TimeColumnName = "recorded_at", + OldTimeColumnName = "created_at" + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.StartsWith("-- WARNING:", result.TrimStart()); + Assert.DoesNotContain("SELECT", result); + Assert.DoesNotContain("ALTER TABLE", result); + } + [Fact] public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() { @@ -728,7 +814,7 @@ public void Runtime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorr string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"events\"', by_range('event_id', 1000::bigint))", result); + Assert.Contains("add_dimension('public.\"events\"', by_range('event_id', 1000::bigint), if_not_exists => true)", result); Assert.DoesNotContain("INTERVAL", result); } @@ -751,7 +837,7 @@ public void Runtime_Alter_AddingRangeDimension_WithTimeInterval_GeneratesCorrect string result = GetRuntimeSql(operation); // Assert - Assert.Contains("add_dimension('public.\"logs\"', by_range('ingestion_time', INTERVAL '2 hours'))", result); + Assert.Contains("add_dimension('public.\"logs\"', by_range('ingestion_time', INTERVAL '2 hours'), if_not_exists => true)", result); } [Fact] @@ -770,7 +856,7 @@ public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesC }; string expected = @" - SELECT add_dimension('analytics.""metrics""', by_range('metric_id', 50000::bigint)); + SELECT add_dimension('analytics.""metrics""', by_range('metric_id', 50000::bigint), if_not_exists => true); "; // Act diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs index f958bfd..ee9d6f4 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs @@ -25,7 +25,7 @@ public void Generate_Create_with_minimal_details_generates_correct_sql() }; string expected = @" - SELECT create_hypertable('public.""MinimalTable""', 'Timestamp'); + SELECT create_hypertable('public.""MinimalTable""', 'Timestamp', if_not_exists => true); "; // Act @@ -54,8 +54,8 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() }; string expected = @" - SELECT create_hypertable('custom_schema.""FullTable""', 'EventTime', chunk_time_interval => INTERVAL '1 day'); - SELECT add_dimension('custom_schema.""FullTable""', by_hash('LocationId', 4)); + SELECT create_hypertable('custom_schema.""FullTable""', 'EventTime', if_not_exists => true, chunk_time_interval => INTERVAL '1 day'); + SELECT add_dimension('custom_schema.""FullTable""', by_hash('LocationId', 4), if_not_exists => true); ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.enable_columnstore = true); SET timescaledb.enable_chunk_skipping = 'ON'; SELECT enable_chunk_skipping('custom_schema.""FullTable""', 'DeviceId'); @@ -133,7 +133,7 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre }; string expected = @" - SELECT create_hypertable('public.""CompressedTable""', 'Timestamp'); + SELECT create_hypertable('public.""CompressedTable""', 'Timestamp', if_not_exists => true); ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""TenantId"", ""DeviceId""', timescaledb.orderby = '""Timestamp"" DESC, ""Value"" ASC NULLS LAST'); "; @@ -303,7 +303,7 @@ public void Generate_Create_When_MigrateData_Is_False_Does_Not_Include_Migrate_D }; string expected = @" - SELECT create_hypertable('public.""Metrics""', 'Timestamp'); + SELECT create_hypertable('public.""Metrics""', 'Timestamp', if_not_exists => true); "; // Act @@ -326,7 +326,7 @@ public void Generate_Create_When_MigrateData_Is_True_Includes_Migrate_Data_Param }; string expected = @" - SELECT create_hypertable('public.""Metrics""', 'Timestamp', migrate_data => true); + SELECT create_hypertable('public.""Metrics""', 'Timestamp', if_not_exists => true, migrate_data => true); "; // Act @@ -356,8 +356,8 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com }; string expected = @" - SELECT create_hypertable('custom_schema.""CompleteTable""', 'EventTime', migrate_data => true, chunk_time_interval => INTERVAL '1 day'); - SELECT add_dimension('custom_schema.""CompleteTable""', by_hash('LocationId', 4)); + SELECT create_hypertable('custom_schema.""CompleteTable""', 'EventTime', if_not_exists => true, migrate_data => true, chunk_time_interval => INTERVAL '1 day'); + SELECT add_dimension('custom_schema.""CompleteTable""', by_hash('LocationId', 4), if_not_exists => true); ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.enable_columnstore = true); SET timescaledb.enable_chunk_skipping = 'ON'; SELECT enable_chunk_skipping('custom_schema.""CompleteTable""', 'DeviceId'); @@ -382,7 +382,7 @@ public void Generate_Create_Default_MigrateData_Does_Not_Include_Parameter() }; string expected = @" - SELECT create_hypertable('public.""DefaultTable""', 'Timestamp'); + SELECT create_hypertable('public.""DefaultTable""', 'Timestamp', if_not_exists => true); "; // Act diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index d913284..7dbca3c 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -1184,6 +1184,86 @@ public void Should_GenerateAllThree_When_AddAlterDropCompressionPolicyPassedToge Assert.DoesNotContain(".Sql(", result); } + [Fact] + public void Generate_AlterHypertable_WithTimeColumnChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "recorded_at", + OldTimeColumnName = "created_at" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterHypertable(", result); + Assert.Contains("timeColumnName:", result); + Assert.Contains("oldTimeColumnName:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_RemoveHypertable_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + RemoveHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".RemoveHypertable(", result); + Assert.Contains("tableName:", result); + Assert.Contains("schema:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + Assert.Matches(@"// TimescaleDB cannot convert a hypertable back into a plain table.*\r?\nmigrationBuilder\.RemoveHypertable\(", result); + } + + [Fact] + public void Generate_RemoveHypertable_WithoutSchema_OmitsSchemaArgument() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + RemoveHypertableOperation operation = new() + { + TableName = "sensor_data" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains(".RemoveHypertable(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain("schema:", result); + } + #endregion #region Helper Methods diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs index 45ab9ef..eedbee2 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs @@ -141,6 +141,20 @@ public void DropContinuousAggregate_MapsArguments() #endregion + #region DropContinuousAggregate_IsDestructiveChange + + [Fact] + public void DropContinuousAggregate_IsDestructiveChange() + { + // Arrange & Act + DropContinuousAggregateOperation op = new(); + + // Assert + Assert.True(op.IsDestructiveChange); + } + + #endregion + #region AlterContinuousAggregate_NullSchema_CoalescesToEmpty [Fact] diff --git a/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs index 5fd8dea..30c639f 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs @@ -132,6 +132,102 @@ public void AlterHypertable_MapsOldArguments() #endregion + #region AlterHypertable_MapsTimeColumnArguments + + [Fact] + public void AlterHypertable_MapsTimeColumnArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterHypertable( + tableName: "sensor_data", + schema: "public", + timeColumnName: "new_ts", + oldTimeColumnName: "old_ts"); + + // Assert + AlterHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("new_ts", op.TimeColumnName); + Assert.Equal("old_ts", op.OldTimeColumnName); + } + + #endregion + + #region AlterHypertable_NullTimeColumnNames_CoalesceToEmpty + + [Fact] + public void AlterHypertable_NullTimeColumnNames_CoalesceToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterHypertable(tableName: "sensor_data", timeColumnName: null, oldTimeColumnName: null); + + // Assert + AlterHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.TimeColumnName); + Assert.Equal(string.Empty, op.OldTimeColumnName); + } + + #endregion + + #region RemoveHypertable_MapsAllArguments + + [Fact] + public void RemoveHypertable_MapsAllArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + OperationBuilder result = mb.RemoveHypertable( + tableName: "sensor_data", + schema: "public"); + + // Assert + RemoveHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.NotNull(result); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + } + + #endregion + + #region RemoveHypertable_IsNotDestructiveChange + + [Fact] + public void RemoveHypertable_IsNotDestructiveChange() + { + // Arrange & Act + RemoveHypertableOperation op = new(); + + // Assert + Assert.False(op.IsDestructiveChange); + } + + #endregion + + #region RemoveHypertable_NullSchema_CoalescesToEmpty + + [Fact] + public void RemoveHypertable_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.RemoveHypertable(tableName: "sensor_data", schema: null); + + // Assert + RemoveHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + #region AlterHypertable_NullOldChunkTimeInterval_CoalescesToEmpty [Fact]