From 9a8ffd6d3ca203551cb0443b00ed9e55173f4da7 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 9 Jun 2026 13:13:25 +0200 Subject: [PATCH 1/5] refactor: Rework migration code generation and add typed migration API Split runtime SQL generation into per-feature *SqlGenerator classes with a shared PolicyJobSqlBuilder, and add per-feature *CSharpGenerator classes so scaffolded migrations emit typed migrationBuilder calls instead of raw .Sql() strings and introduce FeatureDiffContext for rename maps and the recreated-aggregate cascade --- .editorconfig | 7 + .../Generators/CSharpGeneratorHelper.cs | 30 ++++ .../ContinuousAggregateCSharpGenerator.cs | 128 ++++++++++++++ ...ontinuousAggregatePolicyCSharpGenerator.cs | 65 +++++++ .../Generators/HypertableCSharpGenerator.cs | 118 +++++++++++++ .../Generators/MigrationCallWriter.cs | 55 ++++++ .../ReorderPolicyCSharpGenerator.cs | 95 ++++++++++ .../RetentionPolicyCSharpGenerator.cs | 108 ++++++++++++ ...escaleCSharpMigrationOperationGenerator.cs | 104 +++++------ .../ContinuousAggregateFunction.cs | 25 +++ ... ContinuousAggregatePolicySqlGenerator.cs} | 27 +-- ....cs => ContinuousAggregateSqlGenerator.cs} | 44 ++--- ...Generator.cs => HypertableSqlGenerator.cs} | 75 ++------ src/Eftdb/Generators/PolicyJobSqlBuilder.cs | 71 ++++++++ .../ReorderPolicyOperationGenerator.cs | 165 ------------------ .../Generators/ReorderPolicySqlGenerator.cs | 99 +++++++++++ .../RetentionPolicyOperationGenerator.cs | 165 ------------------ .../Generators/RetentionPolicySqlGenerator.cs | 97 ++++++++++ src/Eftdb/Generators/SqlBuilderHelper.cs | 21 ++- .../ContinuousAggregatePolicyDiffer.cs | 17 +- .../ContinuousAggregateDiffer.cs | 14 +- .../Internals/Features/FeatureDiffContext.cs | 51 ++++++ .../Features/Hypertables/HypertableDiffer.cs | 63 ++++++- .../Internals/Features/IFeatureDiffer.cs | 6 +- .../ReorderPolicies/ReorderPolicyDiffer.cs | 34 +++- .../RetentionPolicyDiffer.cs | 40 ++++- .../TimescaleMigrationsModelDiffer.cs | 96 ++++++++-- .../ContinuousAggregateMigrationExtensions.cs | 90 ++++++++++ ...nuousAggregatePolicyMigrationExtensions.cs | 58 ++++++ .../HypertableMigrationExtensions.cs | 79 +++++++++ .../ReorderPolicyMigrationExtensions.cs | 89 ++++++++++ .../RetentionPolicyMigrationExtensions.cs | 95 ++++++++++ .../CreateContinuousAggregateOperation.cs | 4 +- .../TimescaleDbMigrationsSqlGenerator.cs | 44 ++--- 34 files changed, 1707 insertions(+), 572 deletions(-) create mode 100644 src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs create mode 100644 src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs create mode 100644 src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs create mode 100644 src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs create mode 100644 src/Eftdb.Design/Generators/MigrationCallWriter.cs create mode 100644 src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs create mode 100644 src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs create mode 100644 src/Eftdb/Abstractions/ContinuousAggregateFunction.cs rename src/Eftdb/Generators/{ContinuousAggregatePolicyOperationGenerator.cs => ContinuousAggregatePolicySqlGenerator.cs} (78%) rename src/Eftdb/Generators/{ContinuousAggregateOperationGenerator.cs => ContinuousAggregateSqlGenerator.cs} (83%) rename src/Eftdb/Generators/{HypertableOperationGenerator.cs => HypertableSqlGenerator.cs} (77%) create mode 100644 src/Eftdb/Generators/PolicyJobSqlBuilder.cs delete mode 100644 src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs create mode 100644 src/Eftdb/Generators/ReorderPolicySqlGenerator.cs delete mode 100644 src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs create mode 100644 src/Eftdb/Generators/RetentionPolicySqlGenerator.cs create mode 100644 src/Eftdb/Internals/Features/FeatureDiffContext.cs create mode 100644 src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs create mode 100644 src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs create mode 100644 src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs create mode 100644 src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs create mode 100644 src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs diff --git a/.editorconfig b/.editorconfig index 6757b53..a770ddc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,3 +5,10 @@ csharp_style_prefer_switch_expression = false # IDE0079: Remove unnecessary suppression dotnet_diagnostic.IDE0079.severity = none + +# MigrationBuilder extension methods intentionally live in the EF Core namespace +# (Microsoft.EntityFrameworkCore.Migrations) so generated migrations resolve them +# without an extra using. The namespace deliberately does not match the folder, +# so suppress IDE0130 for these files. +[**/MigrationExtensions/*.cs] +dotnet_diagnostic.IDE0130.severity = none diff --git a/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs b/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs new file mode 100644 index 0000000..270b343 --- /dev/null +++ b/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Design; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Shared helpers for emitting C# literals into migration files. + /// + internal static class CSharpGeneratorHelper + { + /// + /// Formats a string collection as a C# collection expression, e.g. ["a", "b"]. + /// + public static string LiteralStringList(ICSharpHelper code, IReadOnlyList items) + { + string elements = string.Join(", ", items.Select(code.Literal)); + return $"[{elements}]"; + } + + /// + /// Formats a static method call like TypeRef.Method(arg1, arg2), literalizing + /// each argument via so primitive types are + /// rendered correctly without per-call Literal boilerplate. + /// + public static string StaticCall(ICSharpHelper code, string typeRef, string method, params object?[] args) + { + string argList = string.Join(", ", args.Select(code.UnknownLiteral)); + return $"{typeRef}.{method}({argList})"; + } + } +} diff --git a/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs new file mode 100644 index 0000000..50a6f10 --- /dev/null +++ b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs @@ -0,0 +1,128 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class ContinuousAggregateCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(CreateContinuousAggregateOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "CreateContinuousAggregate"); + + call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName)); + call.Arg("parentName", code.Literal(operation.ParentName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.ChunkInterval)) + call.Arg("chunkInterval", code.Literal(operation.ChunkInterval)); + + if (operation.WithNoData) + call.Arg("withNoData", code.Literal(true)); + + if (operation.CreateGroupIndexes) + call.Arg("createGroupIndexes", code.Literal(true)); + + if (operation.MaterializedOnly) + call.Arg("materializedOnly", code.Literal(true)); + + if (!string.IsNullOrEmpty(operation.TimeBucketWidth)) + call.Arg("timeBucketWidth", code.Literal(operation.TimeBucketWidth)); + + if (!string.IsNullOrEmpty(operation.TimeBucketSourceColumn)) + call.Arg("timeBucketSourceColumn", code.Literal(operation.TimeBucketSourceColumn)); + + // timeBucketGroupBy defaults to true — only emit when explicitly disabled. + if (!operation.TimeBucketGroupBy) + call.Arg("timeBucketGroupBy", code.Literal(false)); + + if (operation.AggregateFunctions is { Count: > 0 }) + call.Arg("aggregateFunctions", b => AppendAggregateFunctionList(b, operation.AggregateFunctions)); + + if (operation.GroupByColumns is { Count: > 0 }) + call.Arg("groupByColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.GroupByColumns)); + + if (!string.IsNullOrEmpty(operation.WhereClause)) + call.Arg("whereClause", code.Literal(operation.WhereClause)); + + if (!string.IsNullOrEmpty(operation.ViewDefinition)) + call.Arg("viewDefinition", code.Literal(operation.ViewDefinition)); + } + + public void Generate(AlterContinuousAggregateOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AlterContinuousAggregate"); + + call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.ChunkInterval)) + call.Arg("chunkInterval", code.Literal(operation.ChunkInterval)); + + if (operation.CreateGroupIndexes) + call.Arg("createGroupIndexes", code.Literal(true)); + + if (operation.MaterializedOnly) + call.Arg("materializedOnly", code.Literal(true)); + + // Old* values — emitted for Down() reversibility, only when non-default. + if (!string.IsNullOrEmpty(operation.OldChunkInterval)) + call.Arg("oldChunkInterval", code.Literal(operation.OldChunkInterval)); + + if (operation.OldCreateGroupIndexes) + call.Arg("oldCreateGroupIndexes", code.Literal(true)); + + if (operation.OldMaterializedOnly) + call.Arg("oldMaterializedOnly", code.Literal(true)); + } + + public void Generate(DropContinuousAggregateOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "DropContinuousAggregate"); + + call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + } + + // Writes the aggregate functions as a collection expression + private void AppendAggregateFunctionList(IndentedStringBuilder builder, IReadOnlyList aggregateFunctions) + { + string typeRef = typeof(ContinuousAggregateFunction).FullName!; + string enumRef = typeof(EAggregateFunction).FullName!; + + List entries = []; + foreach (string aggregateFunction in aggregateFunctions) + { + string[] parts = aggregateFunction.Split(':'); + if (parts.Length != 3) + { + continue; + } + + entries.Add($"new {typeRef}({code.Literal(parts[0])}, {enumRef}.{parts[1]}, {code.Literal(parts[2])})"); + } + + builder.AppendLine("["); + using (builder.Indent()) + { + for (int i = 0; i < entries.Count; i++) + { + builder.AppendLine(i < entries.Count - 1 ? entries[i] + "," : entries[i]); + } + } + builder.Append("]"); + } + } +} diff --git a/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs new file mode 100644 index 0000000..19acdac --- /dev/null +++ b/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs @@ -0,0 +1,65 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class ContinuousAggregatePolicyCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(AddContinuousAggregatePolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AddContinuousAggregatePolicy"); + + call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.StartOffset)) + call.Arg("startOffset", code.Literal(operation.StartOffset)); + + if (!string.IsNullOrEmpty(operation.EndOffset)) + call.Arg("endOffset", code.Literal(operation.EndOffset)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (operation.IfNotExists) + call.Arg("ifNotExists", code.Literal(true)); + + if (operation.IncludeTieredData.HasValue) + call.Arg("includeTieredData", code.Literal(operation.IncludeTieredData.Value)); + + if (operation.BucketsPerBatch != 1) + call.Arg("bucketsPerBatch", code.Literal(operation.BucketsPerBatch)); + + if (operation.MaxBatchesPerExecution != 0) + call.Arg("maxBatchesPerExecution", code.Literal(operation.MaxBatchesPerExecution)); + + // refreshNewestFirst defaults to true — only emit when explicitly disabled. + if (!operation.RefreshNewestFirst) + call.Arg("refreshNewestFirst", code.Literal(false)); + } + + public void Generate(RemoveContinuousAggregatePolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "RemoveContinuousAggregatePolicy"); + + call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (operation.IfExists) + call.Arg("ifExists", code.Literal(true)); + } + } +} diff --git a/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs new file mode 100644 index 0000000..2d8ab81 --- /dev/null +++ b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs @@ -0,0 +1,118 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class HypertableCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(CreateHypertableOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "CreateHypertable"); + + call.Arg("tableName", code.Literal(operation.TableName)); + call.Arg("timeColumnName", code.Literal(operation.TimeColumnName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) + call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval)); + + if (operation.EnableCompression) + call.Arg("enableCompression", code.Literal(true)); + + if (operation.MigrateData) + call.Arg("migrateData", code.Literal(true)); + + if (operation.ChunkSkipColumns is { Count: > 0 }) + call.Arg("chunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.ChunkSkipColumns)); + + if (operation.AdditionalDimensions is { Count: > 0 }) + call.Arg("additionalDimensions", b => AppendDimensionList(b, operation.AdditionalDimensions)); + + if (operation.CompressionSegmentBy is { Count: > 0 }) + call.Arg("compressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionSegmentBy)); + + if (operation.CompressionOrderBy is { Count: > 0 }) + call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); + } + + public void Generate(AlterHypertableOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AlterHypertable"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) + call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval)); + + if (operation.EnableCompression) + call.Arg("enableCompression", code.Literal(true)); + + if (operation.ChunkSkipColumns is { Count: > 0 }) + call.Arg("chunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.ChunkSkipColumns)); + + if (operation.AdditionalDimensions is { Count: > 0 }) + call.Arg("additionalDimensions", b => AppendDimensionList(b, operation.AdditionalDimensions)); + + if (operation.CompressionSegmentBy is { Count: > 0 }) + call.Arg("compressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionSegmentBy)); + + if (operation.CompressionOrderBy is { Count: > 0 }) + call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); + + // Old* values — emitted for Down() reversibility, only when non-default. + if (!string.IsNullOrEmpty(operation.OldChunkTimeInterval)) + call.Arg("oldChunkTimeInterval", code.Literal(operation.OldChunkTimeInterval)); + + if (operation.OldEnableCompression) + call.Arg("oldEnableCompression", code.Literal(true)); + + if (operation.OldChunkSkipColumns is { Count: > 0 }) + call.Arg("oldChunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.OldChunkSkipColumns)); + + if (operation.OldAdditionalDimensions is { Count: > 0 }) + call.Arg("oldAdditionalDimensions", b => AppendDimensionList(b, operation.OldAdditionalDimensions)); + + if (operation.OldCompressionSegmentBy is { Count: > 0 }) + call.Arg("oldCompressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.OldCompressionSegmentBy)); + + if (operation.OldCompressionOrderBy is { Count: > 0 }) + call.Arg("oldCompressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.OldCompressionOrderBy)); + } + + // 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) + { + // Migration files only import Microsoft.EntityFrameworkCore.Migrations. + // Fully qualify Dimension so generated code compiles without extra usings. + string typeRef = typeof(Dimension).FullName!; + + builder.AppendLine("["); + using (builder.Indent()) + { + for (int i = 0; i < dimensions.Count; i++) + { + Dimension d = dimensions[i]; + string entry = d.Type == EDimensionType.Hash + ? CSharpGeneratorHelper.StaticCall(code, typeRef, nameof(Dimension.CreateHash), d.ColumnName, d.NumberOfPartitions ?? 0) + : CSharpGeneratorHelper.StaticCall(code, typeRef, nameof(Dimension.CreateRange), d.ColumnName, d.Interval ?? string.Empty); + + builder.AppendLine(i < dimensions.Count - 1 ? entry + "," : entry); + } + } + builder.Append("]"); + } + } +} diff --git a/src/Eftdb.Design/Generators/MigrationCallWriter.cs b/src/Eftdb.Design/Generators/MigrationCallWriter.cs new file mode 100644 index 0000000..ab57b94 --- /dev/null +++ b/src/Eftdb.Design/Generators/MigrationCallWriter.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Writes a typed migrationBuilder call with one named argument per line. + /// + internal sealed class MigrationCallWriter : IDisposable + { + private readonly IndentedStringBuilder builder; + private readonly IDisposable indent; + private bool hasArgs; + + public MigrationCallWriter(IndentedStringBuilder builder, string methodName) + { + this.builder = builder; + builder.AppendLine($".{methodName}("); + indent = builder.Indent(); + } + + /// Adds a named argument whose value is the given pre-rendered literal. + public void Arg(string name, string renderedValue) + { + WriteName(name); + builder.Append(renderedValue); + } + + /// + /// Adds a named argument whose value is written directly into the builder, for + /// multi-line values. + /// + public void Arg(string name, Action writeValue) + { + WriteName(name); + writeValue(builder); + } + + private void WriteName(string name) + { + if (hasArgs) + { + builder.AppendLine(","); + } + + hasArgs = true; + builder.Append(name).Append(": "); + } + + public void Dispose() + { + builder.Append(")"); + indent.Dispose(); + } + } +} diff --git a/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs new file mode 100644 index 0000000..eb0983e --- /dev/null +++ b/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs @@ -0,0 +1,95 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class ReorderPolicyCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(AddReorderPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AddReorderPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + call.Arg("indexName", code.Literal(operation.IndexName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.MaxRuntime)) + call.Arg("maxRuntime", code.Literal(operation.MaxRuntime)); + + if (operation.MaxRetries.HasValue) + call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.RetryPeriod)) + call.Arg("retryPeriod", code.Literal(operation.RetryPeriod)); + } + + public void Generate(AlterReorderPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AlterReorderPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + call.Arg("indexName", code.Literal(operation.IndexName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.MaxRuntime)) + call.Arg("maxRuntime", code.Literal(operation.MaxRuntime)); + + if (operation.MaxRetries.HasValue) + call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.RetryPeriod)) + call.Arg("retryPeriod", code.Literal(operation.RetryPeriod)); + + // Old* values — emitted for Down() reversibility, only when non-default. + if (!string.IsNullOrEmpty(operation.OldIndexName)) + call.Arg("oldIndexName", code.Literal(operation.OldIndexName)); + + if (operation.OldInitialStart.HasValue) + call.Arg("oldInitialStart", code.Literal(operation.OldInitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.OldScheduleInterval)) + call.Arg("oldScheduleInterval", code.Literal(operation.OldScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.OldMaxRuntime)) + call.Arg("oldMaxRuntime", code.Literal(operation.OldMaxRuntime)); + + if (operation.OldMaxRetries.HasValue) + call.Arg("oldMaxRetries", code.Literal(operation.OldMaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.OldRetryPeriod)) + call.Arg("oldRetryPeriod", code.Literal(operation.OldRetryPeriod)); + } + + public void Generate(DropReorderPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "DropReorderPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + } + } +} diff --git a/src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs new file mode 100644 index 0000000..1651d4f --- /dev/null +++ b/src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs @@ -0,0 +1,108 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class RetentionPolicyCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(AddRetentionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AddRetentionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.DropAfter)) + call.Arg("dropAfter", code.Literal(operation.DropAfter)); + + if (!string.IsNullOrEmpty(operation.DropCreatedBefore)) + call.Arg("dropCreatedBefore", code.Literal(operation.DropCreatedBefore)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.MaxRuntime)) + call.Arg("maxRuntime", code.Literal(operation.MaxRuntime)); + + if (operation.MaxRetries.HasValue) + call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.RetryPeriod)) + call.Arg("retryPeriod", code.Literal(operation.RetryPeriod)); + } + + public void Generate(AlterRetentionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AlterRetentionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.DropAfter)) + call.Arg("dropAfter", code.Literal(operation.DropAfter)); + + if (!string.IsNullOrEmpty(operation.DropCreatedBefore)) + call.Arg("dropCreatedBefore", code.Literal(operation.DropCreatedBefore)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.MaxRuntime)) + call.Arg("maxRuntime", code.Literal(operation.MaxRuntime)); + + if (operation.MaxRetries.HasValue) + call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.RetryPeriod)) + call.Arg("retryPeriod", code.Literal(operation.RetryPeriod)); + + // Old* values — emitted for Down() reversibility, only when non-default. + if (!string.IsNullOrEmpty(operation.OldDropAfter)) + call.Arg("oldDropAfter", code.Literal(operation.OldDropAfter)); + + if (!string.IsNullOrEmpty(operation.OldDropCreatedBefore)) + call.Arg("oldDropCreatedBefore", code.Literal(operation.OldDropCreatedBefore)); + + if (operation.OldInitialStart.HasValue) + call.Arg("oldInitialStart", code.Literal(operation.OldInitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.OldScheduleInterval)) + call.Arg("oldScheduleInterval", code.Literal(operation.OldScheduleInterval)); + + if (!string.IsNullOrEmpty(operation.OldMaxRuntime)) + call.Arg("oldMaxRuntime", code.Literal(operation.OldMaxRuntime)); + + if (operation.OldMaxRetries.HasValue) + call.Arg("oldMaxRetries", code.Literal(operation.OldMaxRetries.Value)); + + if (!string.IsNullOrEmpty(operation.OldRetryPeriod)) + call.Arg("oldRetryPeriod", code.Literal(operation.OldRetryPeriod)); + } + + public void Generate(DropRetentionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "DropRetentionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + } + } +} diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index 306cb1f..1801030 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations.Design; @@ -13,90 +13,76 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui ArgumentNullException.ThrowIfNull(operation); ArgumentNullException.ThrowIfNull(builder); - HypertableOperationGenerator? hypertableOperationGenerator = null; - ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null; - RetentionPolicyOperationGenerator? retentionPolicyOperationGenerator = null; - ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null; - ContinuousAggregatePolicyOperationGenerator? continuousAggregatePolicyOperationGenerator = null; - - List statements; - bool suppressTransaction = false; + HypertableCSharpGenerator? hypertableCSharpGenerator = null; + ReorderPolicyCSharpGenerator? reorderPolicyCSharpGenerator = null; + RetentionPolicyCSharpGenerator? retentionPolicyCSharpGenerator = null; + ContinuousAggregateCSharpGenerator? continuousAggregateCSharpGenerator = null; + ContinuousAggregatePolicyCSharpGenerator? continuousAggregatePolicyCSharpGenerator = null; switch (operation) { case CreateHypertableOperation create: - hypertableOperationGenerator ??= new(isDesignTime: true); - statements = hypertableOperationGenerator.Generate(create); - break; + hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper); + hypertableCSharpGenerator.Generate(create, builder); + return; case AlterHypertableOperation alter: - hypertableOperationGenerator ??= new(isDesignTime: true); - statements = hypertableOperationGenerator.Generate(alter); - break; + hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper); + hypertableCSharpGenerator.Generate(alter, builder); + return; case AddReorderPolicyOperation addReorder: - reorderPolicyOperationGenerator ??= new(isDesignTime: true); - statements = reorderPolicyOperationGenerator.Generate(addReorder); - break; + reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + reorderPolicyCSharpGenerator.Generate(addReorder, builder); + return; case AlterReorderPolicyOperation alterReorder: - reorderPolicyOperationGenerator ??= new(isDesignTime: true); - statements = reorderPolicyOperationGenerator.Generate(alterReorder); - break; + reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + reorderPolicyCSharpGenerator.Generate(alterReorder, builder); + return; case DropReorderPolicyOperation dropReorder: - reorderPolicyOperationGenerator ??= new(isDesignTime: true); - statements = reorderPolicyOperationGenerator.Generate(dropReorder); - break; + reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + reorderPolicyCSharpGenerator.Generate(dropReorder, builder); + return; case AddRetentionPolicyOperation addRetention: - retentionPolicyOperationGenerator ??= new(isDesignTime: true); - statements = retentionPolicyOperationGenerator.Generate(addRetention); - break; + retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + retentionPolicyCSharpGenerator.Generate(addRetention, builder); + return; case AlterRetentionPolicyOperation alterRetention: - retentionPolicyOperationGenerator ??= new(isDesignTime: true); - statements = retentionPolicyOperationGenerator.Generate(alterRetention); - break; + retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + retentionPolicyCSharpGenerator.Generate(alterRetention, builder); + return; case DropRetentionPolicyOperation dropRetention: - retentionPolicyOperationGenerator ??= new(isDesignTime: true); - statements = retentionPolicyOperationGenerator.Generate(dropRetention); - break; + retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + retentionPolicyCSharpGenerator.Generate(dropRetention, builder); + return; case CreateContinuousAggregateOperation createContinuousAggregate: - continuousAggregateOperationGenerator ??= new(isDesignTime: true); - statements = continuousAggregateOperationGenerator.Generate(createContinuousAggregate); - suppressTransaction = true; - break; + continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); + continuousAggregateCSharpGenerator.Generate(createContinuousAggregate, builder); + return; case AlterContinuousAggregateOperation alterContinuousAggregate: - continuousAggregateOperationGenerator ??= new(isDesignTime: true); - statements = continuousAggregateOperationGenerator.Generate(alterContinuousAggregate); - break; + continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); + continuousAggregateCSharpGenerator.Generate(alterContinuousAggregate, builder); + return; case DropContinuousAggregateOperation dropContinuousAggregate: - continuousAggregateOperationGenerator ??= new(isDesignTime: true); - statements = continuousAggregateOperationGenerator.Generate(dropContinuousAggregate); - break; + continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); + continuousAggregateCSharpGenerator.Generate(dropContinuousAggregate, builder); + return; case AddContinuousAggregatePolicyOperation addContinuousAggregatePolicy: - continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: true); - statements = continuousAggregatePolicyOperationGenerator.Generate(addContinuousAggregatePolicy); - break; + continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + continuousAggregatePolicyCSharpGenerator.Generate(addContinuousAggregatePolicy, builder); + return; case RemoveContinuousAggregatePolicyOperation removeContinuousAggregatePolicy: - continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: true); - statements = continuousAggregatePolicyOperationGenerator.Generate(removeContinuousAggregatePolicy); - break; + continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); + continuousAggregatePolicyCSharpGenerator.Generate(removeContinuousAggregatePolicy, builder); + return; default: base.Generate(operation, builder); return; } - - // Guard: if no statements were generated, output a no-op SQL comment to maintain valid C# syntax. - if (statements.Count == 0) - { - builder.Append(".Sql(@\"-- No SQL generated for this operation\")"); - return; - } - - SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction); } - } } diff --git a/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs b/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs new file mode 100644 index 0000000..a1e34fe --- /dev/null +++ b/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs @@ -0,0 +1,25 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions +{ + /// + /// Maps a column on a continuous aggregate to an aggregate function applied to a source + /// hypertable column — e.g. average_temperature = AVG(temperature). + /// Used as the strongly typed vocabulary for the aggregateFunctions argument. + /// + public sealed class ContinuousAggregateFunction(string alias, EAggregateFunction function, string sourceColumn) + { + /// The name of the resulting column on the continuous aggregate. + public string Alias { get; } = alias; + + /// The aggregate function to apply. + public EAggregateFunction Function { get; } = function; + + /// The source hypertable column to aggregate. + public string SourceColumn { get; } = sourceColumn; + + /// + /// Serializes to the alias:Function:sourceColumn wire format stored on + /// CreateContinuousAggregateOperation.AggregateFunctions. + /// + public string ToAnnotationValue() => $"{Alias}:{Function}:{SourceColumn}"; + } +} diff --git a/src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs similarity index 78% rename from src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs rename to src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs index abe08a8..4a411fc 100644 --- a/src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs @@ -6,33 +6,16 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators /// /// Generates SQL for continuous aggregate refresh policy operations. /// - public class ContinuousAggregatePolicyOperationGenerator + public class ContinuousAggregatePolicySqlGenerator { - private readonly string quoteString = "\""; - private readonly SqlBuilderHelper sqlHelper; - - /// - /// Initializes a new instance of the ContinuousAggregatePolicyOperationGenerator class. - /// - /// Whether this generator is being used at design-time (for C# code generation) or runtime (for SQL execution). - public ContinuousAggregatePolicyOperationGenerator(bool isDesignTime = false) - { - if (isDesignTime) - { - quoteString = "\"\""; - } - - sqlHelper = new SqlBuilderHelper(quoteString); - } - /// /// Generates SQL statements for adding a continuous aggregate refresh policy. /// /// The add policy operation. /// A list of SQL statements to execute. - public List Generate(AddContinuousAggregatePolicyOperation operation) + public static List Generate(AddContinuousAggregatePolicyOperation operation) { - string qualifiedViewName = sqlHelper.Regclass(operation.MaterializedViewName, operation.Schema); + string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema); List arguments = []; @@ -119,9 +102,9 @@ public List Generate(AddContinuousAggregatePolicyOperation operation) /// /// The remove policy operation. /// A list of SQL statements to execute. - public List Generate(RemoveContinuousAggregatePolicyOperation operation) + public static List Generate(RemoveContinuousAggregatePolicyOperation operation) { - string qualifiedViewName = sqlHelper.Regclass(operation.MaterializedViewName, operation.Schema); + string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema); List arguments = [qualifiedViewName]; diff --git a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs similarity index 83% rename from src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs rename to src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index 9871555..b354af9 100644 --- a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -3,26 +3,12 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - public class ContinuousAggregateOperationGenerator + public class ContinuousAggregateSqlGenerator { - private readonly string quoteString = "\""; - private readonly SqlBuilderHelper sqlHelper; - - public ContinuousAggregateOperationGenerator(bool isDesignTime = false) - { - if (isDesignTime) - { - quoteString = "\"\""; - } - - sqlHelper = new SqlBuilderHelper(quoteString); - - } - - public List Generate(CreateContinuousAggregateOperation operation) + public static List Generate(CreateContinuousAggregateOperation operation) { - string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); - string parentQualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.ParentName, operation.Schema); + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); + string parentQualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.ParentName, operation.Schema); List statements = []; @@ -48,7 +34,7 @@ public List Generate(CreateContinuousAggregateOperation operation) rawSqlBuilder.AppendLine(); rawSqlBuilder.Append($"WITH ({string.Join(", ", withOptions)}) AS"); rawSqlBuilder.AppendLine(); - rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';').Replace("\"", quoteString)); + rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';')); if (operation.WithNoData) { rawSqlBuilder.AppendLine(); @@ -63,7 +49,7 @@ public List Generate(CreateContinuousAggregateOperation operation) List selectList = []; // Add time_bucket column - string timeBucketColumn = $"{quoteString}{operation.TimeBucketSourceColumn}{quoteString}"; + string timeBucketColumn = $"{SqlBuilderHelper.QuoteIdentifier(operation.TimeBucketSourceColumn)}"; string timeBucketWidthSql = $"'{operation.TimeBucketWidth}'"; selectList.Add($"time_bucket({timeBucketWidthSql}, {timeBucketColumn}) AS time_bucket"); @@ -74,7 +60,7 @@ public List Generate(CreateContinuousAggregateOperation operation) bool isRawSqlExpression = groupByColumn.Contains(',') || groupByColumn.Contains('(') || groupByColumn.Contains(' '); if (!isRawSqlExpression) { - selectList.Add($"{quoteString}{groupByColumn}{quoteString}"); + selectList.Add($"{SqlBuilderHelper.QuoteIdentifier(groupByColumn)}"); } } @@ -93,8 +79,8 @@ public List Generate(CreateContinuousAggregateOperation operation) string sourceColumn = parts[2]; string sqlFunction = GetSqlAggregateFunction(functionEnumString); - string quotedSourceColumn = $"{quoteString}{sourceColumn}{quoteString}"; - string quotedAlias = $"{quoteString}{alias}{quoteString}"; + string quotedSourceColumn = $"{SqlBuilderHelper.QuoteIdentifier(sourceColumn)}"; + string quotedAlias = $"{SqlBuilderHelper.QuoteIdentifier(alias)}"; string aggregateExpression; // Handle special TimescaleDB aggregates 'first' and 'last' @@ -129,7 +115,7 @@ public List Generate(CreateContinuousAggregateOperation operation) else { // It's a column name, quote it - groupByList.Add($"{quoteString}{groupByColumn}{quoteString}"); + groupByList.Add($"{SqlBuilderHelper.QuoteIdentifier(groupByColumn)}"); } } @@ -146,7 +132,7 @@ public List Generate(CreateContinuousAggregateOperation operation) // Add WHERE clause if specified if (!string.IsNullOrWhiteSpace(operation.WhereClause)) { - string whereClause = operation.WhereClause.Replace("\"", quoteString); + string whereClause = operation.WhereClause; sqlBuilder.AppendLine(); sqlBuilder.Append($"WHERE {whereClause}"); } @@ -171,9 +157,9 @@ public List Generate(CreateContinuousAggregateOperation operation) return statements; } - public List Generate(AlterContinuousAggregateOperation operation) + public static List Generate(AlterContinuousAggregateOperation operation) { - string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); List statements = []; // Check for ChunkInterval change @@ -213,9 +199,9 @@ public List Generate(AlterContinuousAggregateOperation operation) return statements; } - public List Generate(DropContinuousAggregateOperation operation) + public static List Generate(DropContinuousAggregateOperation operation) { - string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); List statements = []; statements.Add($"DROP MATERIALIZED VIEW IF EXISTS {qualifiedIdentifier};"); diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs similarity index 77% rename from src/Eftdb/Generators/HypertableOperationGenerator.cs rename to src/Eftdb/Generators/HypertableSqlGenerator.cs index cd2ac0f..3b401ee 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs @@ -1,48 +1,31 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using System.Text; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - public class HypertableOperationGenerator + public class HypertableSqlGenerator { - private readonly string quoteString = "\""; - private readonly SqlBuilderHelper sqlHelper; - - public HypertableOperationGenerator(bool isDesignTime = false) - { - if (isDesignTime) - { - quoteString = "\"\""; - } - - sqlHelper = new SqlBuilderHelper(quoteString); - } - - public List Generate(CreateHypertableOperation operation) + public static List Generate(CreateHypertableOperation operation) { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema); + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); List statements = []; List communityStatements = []; - // Build create_hypertable with chunk_time_interval if provided StringBuilder createHypertableCall = new(); createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'"); createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : ""); if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) { - // Check if the interval is a plain number (e.g., for microseconds). if (long.TryParse(operation.ChunkTimeInterval, out _)) { - // If it's a number, don't wrap it in quotes. createHypertableCall.Append($", chunk_time_interval => {operation.ChunkTimeInterval}::bigint"); } else { - // If it's a string like '7 days', wrap it in quotes. createHypertableCall.Append($", chunk_time_interval => INTERVAL '{operation.ChunkTimeInterval}'"); } } @@ -65,7 +48,7 @@ public List Generate(CreateHypertableOperation operation) if (hasSegmentBy) { - string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(QuoteIdentifier)); + string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(SqlBuilderHelper.QuoteIdentifier)); compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'"); } @@ -75,13 +58,11 @@ public List Generate(CreateHypertableOperation operation) compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'"); } - // If there are compression settings, add the ALTER TABLE SET (...) statement if (compressionSettings.Count > 0) { communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"); } - // ChunkSkipColumns (Community Edition only) if (operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0) { communityStatements.Add("SET timescaledb.enable_chunk_skipping = 'ON';"); @@ -92,14 +73,12 @@ public List Generate(CreateHypertableOperation operation) } } - // AdditionalDimensions (Available in both editions) if (operation.AdditionalDimensions != null && operation.AdditionalDimensions.Count > 0) { foreach (Dimension dimension in operation.AdditionalDimensions) { if (dimension.Type == EDimensionType.Range) { - // Detect if interval is numeric (integer range) or time-based (timestamp range) bool isIntegerRange = long.TryParse(dimension.Interval, out _); string intervalExpression = isIntegerRange ? dimension.Interval! @@ -114,7 +93,6 @@ public List Generate(CreateHypertableOperation operation) } } - // Add wrapped community statements if any exist if (communityStatements.Count > 0) { statements.Add(WrapCommunityFeatures(communityStatements)); @@ -122,29 +100,25 @@ public List Generate(CreateHypertableOperation operation) return statements; } - public List Generate(AlterHypertableOperation operation) + public static List Generate(AlterHypertableOperation operation) { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema); + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); List statements = []; List communityStatements = []; - // Check for ChunkTimeInterval change (Available in both editions) if (operation.ChunkTimeInterval != operation.OldChunkTimeInterval) { StringBuilder setChunkTimeInterval = new(); setChunkTimeInterval.Append($"SELECT set_chunk_time_interval({qualifiedTableName}, "); - // Check if the interval is a plain number (e.g., for microseconds). if (long.TryParse(operation.ChunkTimeInterval, out _)) { - // If it's a number, don't wrap it in quotes. setChunkTimeInterval.Append($"{operation.ChunkTimeInterval}::bigint"); } else { - // If it's a string like '7 days', wrap it in quotes. setChunkTimeInterval.Append($"INTERVAL '{operation.ChunkTimeInterval}'"); } @@ -177,7 +151,7 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy)) { string val = (operation.CompressionSegmentBy?.Count > 0) - ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(QuoteIdentifier))}'" + ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(SqlBuilderHelper.QuoteIdentifier))}'" : "''"; compressionSettings.Add($"timescaledb.compress_segmentby = {val}"); } @@ -190,13 +164,11 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? compressionSettings.Add($"timescaledb.compress_orderby = {val}"); } - // If there are compression settings, add the ALTER TABLE SET (...) statement if (compressionSettings.Count > 0) { communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"); } - // Handle ChunkSkipColumns (Community Edition only) IReadOnlyList newColumns = operation.ChunkSkipColumns ?? []; IReadOnlyList oldColumns = operation.OldChunkSkipColumns ?? []; List addedColumns = [.. newColumns.Except(oldColumns)]; @@ -220,14 +192,11 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? } } - // 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. + // 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 ?? []; - // Find dimensions that are in new but not in old (added dimensions) foreach (Dimension newDim in newDimensions) { bool exists = oldDimensions.Any(oldDim => @@ -240,7 +209,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? { if (newDim.Type == EDimensionType.Range) { - // Detect if interval is numeric (integer range) or time-based (timestamp range) bool isIntegerRange = long.TryParse(newDim.Interval, out _); string intervalExpression = isIntegerRange ? newDim.Interval! @@ -255,7 +223,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? } } - // Warn if dimensions were removed (which cannot be reversed in TimescaleDB) List removedDimensions = [.. oldDimensions .Where(oldDim => !newDimensions.Any(newDim => oldDim.ColumnName == newDim.ColumnName && @@ -267,7 +234,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); } - // Add wrapped community statements if any exist if (communityStatements.Count > 0) { statements.Add(WrapCommunityFeatures(communityStatements)); @@ -291,7 +257,6 @@ private static string WrapCommunityFeatures(List sqlStatements) foreach (string sql in sqlStatements) { - // Remove trailing semicolon and escape single quotes for EXECUTE string cleanSql = sql.TrimEnd(';').Replace("'", "''"); sb.AppendLine($" EXECUTE '{cleanSql}';"); } @@ -304,21 +269,11 @@ private static string WrapCommunityFeatures(List sqlStatements) return sb.ToString(); } - /// - /// Wraps an identifier in double quotes to preserve case-sensitivity in Postgres. - /// Escapes existing double quotes. - /// Example: TenantId -> "TenantId" - /// - private string QuoteIdentifier(string identifier) - { - return $"{quoteString}{identifier.Replace("\"", "\"\"")}{quoteString}"; - } - /// /// Quotes the column name within an ORDER BY clause while preserving direction/nulls. /// Example: Timestamp DESC -> "Timestamp" DESC /// - private string QuoteOrderByList(IEnumerable orderByClauses) + private static string QuoteOrderByList(IEnumerable orderByClauses) { return string.Join(", ", orderByClauses.Select(clause => { @@ -326,8 +281,8 @@ private string QuoteOrderByList(IEnumerable orderByClauses) string col = parts[0]; string suffix = parts.Length > 1 ? " " + parts[1] : ""; - return QuoteIdentifier(col) + suffix; + return SqlBuilderHelper.QuoteIdentifier(col) + suffix; })); } } -} \ No newline at end of file +} diff --git a/src/Eftdb/Generators/PolicyJobSqlBuilder.cs b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs new file mode 100644 index 0000000..8e77e42 --- /dev/null +++ b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs @@ -0,0 +1,71 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + /// + /// Builds the SQL shared by TimescaleDB automation policies whose + /// scheduling is tuned through the common alter_job function. + /// + public static class PolicyJobSqlBuilder + { + /// + /// Builds alter_job tuning clauses for a newly added policy, including every value + /// that was explicitly provided. + /// + public static List BuildJobClauses(string? scheduleInterval, string? maxRuntime, int? maxRetries, string? retryPeriod) + { + List clauses = []; + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'"); + + if (!string.IsNullOrWhiteSpace(maxRuntime)) + clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'"); + + if (maxRetries != null) + clauses.Add($"max_retries => {maxRetries}"); + + if (!string.IsNullOrWhiteSpace(retryPeriod)) + clauses.Add($"retry_period => INTERVAL '{retryPeriod}'"); + + return clauses; + } + + /// + /// Builds alter_job tuning clauses for only the values that changed relative to the + /// previous policy state. + /// + public static List BuildChangedJobClauses( + string? scheduleInterval, string? oldScheduleInterval, + string? maxRuntime, string? oldMaxRuntime, + int? maxRetries, int? oldMaxRetries, + string? retryPeriod, string? oldRetryPeriod) + { + List clauses = []; + + if (!string.IsNullOrWhiteSpace(scheduleInterval) && scheduleInterval != oldScheduleInterval) + clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'"); + + if (!string.IsNullOrWhiteSpace(maxRuntime) && maxRuntime != oldMaxRuntime) + clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'"); + + if (maxRetries != null && maxRetries != oldMaxRetries) + clauses.Add($"max_retries => {maxRetries}"); + + if (!string.IsNullOrWhiteSpace(retryPeriod) && retryPeriod != oldRetryPeriod) + clauses.Add($"retry_period => INTERVAL '{retryPeriod}'"); + + return clauses; + } + + /// + /// Builds the alter_job statement that tunes the policy job identified by + /// for the given table. + /// + public static string BuildAlterJobSql(string tableName, string schema, string procName, IEnumerable clauses) + { + return $@" + SELECT alter_job(job_id, {string.Join(", ", clauses)}) + FROM timescaledb_information.jobs + WHERE proc_name = '{procName}' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim(); + } + } +} diff --git a/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs b/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs deleted file mode 100644 index 8306a47..0000000 --- a/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs +++ /dev/null @@ -1,165 +0,0 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using System.Globalization; - -namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators -{ - public class ReorderPolicyOperationGenerator - { - private readonly string quoteString = "\""; - private readonly SqlBuilderHelper sqlHelper; - - public ReorderPolicyOperationGenerator(bool isDesignTime = false) - { - if (isDesignTime) - { - quoteString = "\"\""; - } - - sqlHelper = new SqlBuilderHelper(quoteString); - } - - public List Generate(AddReorderPolicyOperation operation) - { - List statements = - [ - BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart) - ]; - - List alterJobClauses = BuildAlterJobClauses(operation); - if (alterJobClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, alterJobClauses)); - } - - return statements; - } - - public List Generate(AlterReorderPolicyOperation operation) - { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - - List statements = []; - bool needsRecreation = operation.IndexName != operation.OldIndexName || operation.InitialStart != operation.OldInitialStart; - - if (needsRecreation) - { - statements.Add($"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);"); - statements.Add(BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart)); - - // Create a temporary "add" operation representing the final desired state to ensure existing settings are reapplied. - AddReorderPolicyOperation finalStateOperation = new() - { - TableName = operation.TableName, - IndexName = operation.IndexName, - InitialStart = operation.InitialStart, - ScheduleInterval = operation.ScheduleInterval, - MaxRuntime = operation.MaxRuntime, - MaxRetries = operation.MaxRetries, - RetryPeriod = operation.RetryPeriod - }; - - List finalStateClauses = BuildAlterJobClauses(finalStateOperation); - if (finalStateClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, finalStateClauses)); - } - } - else - { - List changedClauses = BuildAlterJobClauses(operation); - if (changedClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, changedClauses)); - } - } - - return statements; - } - - public List Generate(DropReorderPolicyOperation operation) - { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - - List statements = - [ - $"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);" - ]; - return statements; - } - - private static List BuildAlterJobClauses(AddReorderPolicyOperation operation) - { - List clauses = []; - - if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval)) - clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); - - if (!string.IsNullOrWhiteSpace(operation.MaxRuntime)) - clauses.Add($"max_runtime => INTERVAL '{operation.MaxRuntime}'"); - - if (operation.MaxRetries != null) - clauses.Add($"max_retries => {operation.MaxRetries}"); - - if (!string.IsNullOrWhiteSpace(operation.RetryPeriod)) - clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); - - return clauses; - } - - private static List BuildAlterJobClauses(AlterReorderPolicyOperation operation) - { - List clauses = []; - - if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval) && operation.ScheduleInterval != operation.OldScheduleInterval) - clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); - - if (!string.IsNullOrWhiteSpace(operation.MaxRuntime) && operation.MaxRuntime != operation.OldMaxRuntime) - { - string maxRuntimeValue = string.IsNullOrWhiteSpace(operation.MaxRuntime) ? "NULL" : $"INTERVAL '{operation.MaxRuntime}'"; - clauses.Add($"max_runtime => {maxRuntimeValue}"); - } - - if (operation.MaxRetries != null && operation.MaxRetries != operation.OldMaxRetries) - clauses.Add($"max_retries => {operation.MaxRetries}"); - - if (!string.IsNullOrWhiteSpace(operation.RetryPeriod) && operation.RetryPeriod != operation.OldRetryPeriod) - clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); - - return clauses; - } - - private static string BuildAlterJobSql(string tableName, string schema, IEnumerable clauses) - { - // Note: hypertable_name is a varchar column, so it compares against a string literal, not a regclass. - return $@" - SELECT alter_job(job_id, {string.Join(", ", clauses)}) - FROM timescaledb_information.jobs - WHERE proc_name = 'policy_reorder' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim(); - } - - private string BuildAddReorderPolicySql(string tableName, string schema, string indexName, DateTime? initialStart) - { - string qualifiedTableName = sqlHelper.Regclass(tableName, schema); - - string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{indexName}'"; - - List optionalArgs = []; - - // Add optional arguments if they are provided - if (initialStart.HasValue) - { - // Use ISO 8601 format for timestamps to avoid ambiguity - string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - optionalArgs.Add($"initial_start => '{timestamp}'"); - } - - if (optionalArgs.Count > 0) - { - baseSql += $", {string.Join(", ", optionalArgs)}"; - } - - baseSql += ");"; - return baseSql; - } - } -} diff --git a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs new file mode 100644 index 0000000..151c7d5 --- /dev/null +++ b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs @@ -0,0 +1,99 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using System.Globalization; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + public class ReorderPolicySqlGenerator + { + private const string ProcName = "policy_reorder"; + + public static List Generate(AddReorderPolicyOperation operation) + { + List statements = + [ + BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart) + ]; + + List jobClauses = PolicyJobSqlBuilder.BuildJobClauses( + operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod); + if (jobClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses)); + } + + return statements; + } + + public static List Generate(AlterReorderPolicyOperation operation) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = []; + bool needsRecreation = operation.IndexName != operation.OldIndexName || operation.InitialStart != operation.OldInitialStart; + + if (needsRecreation) + { + statements.Add($"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);"); + statements.Add(BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart)); + + // After recreation, reapply the full desired job configuration so existing settings are not lost. + List finalStateClauses = PolicyJobSqlBuilder.BuildJobClauses( + operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod); + if (finalStateClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, finalStateClauses)); + } + } + else + { + List changedClauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + operation.ScheduleInterval, operation.OldScheduleInterval, + operation.MaxRuntime, operation.OldMaxRuntime, + operation.MaxRetries, operation.OldMaxRetries, + operation.RetryPeriod, operation.OldRetryPeriod); + if (changedClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, changedClauses)); + } + } + + return statements; + } + + public static List Generate(DropReorderPolicyOperation operation) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = + [ + $"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);" + ]; + return statements; + } + + private static string BuildAddReorderPolicySql(string tableName, string schema, string indexName, DateTime? initialStart) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema); + + string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{indexName}'"; + + List optionalArgs = []; + + // Add optional arguments if they are provided + if (initialStart.HasValue) + { + // Use ISO 8601 format for timestamps to avoid ambiguity + string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + optionalArgs.Add($"initial_start => '{timestamp}'"); + } + + if (optionalArgs.Count > 0) + { + baseSql += $", {string.Join(", ", optionalArgs)}"; + } + + baseSql += ");"; + return baseSql; + } + } +} diff --git a/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs b/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs deleted file mode 100644 index ffb54fd..0000000 --- a/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs +++ /dev/null @@ -1,165 +0,0 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using System.Globalization; - -namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators -{ - public class RetentionPolicyOperationGenerator - { - private readonly string quoteString = "\""; - private readonly SqlBuilderHelper sqlHelper; - - public RetentionPolicyOperationGenerator(bool isDesignTime = false) - { - if (isDesignTime) - { - quoteString = "\"\""; - } - - sqlHelper = new SqlBuilderHelper(quoteString); - } - - public List Generate(AddRetentionPolicyOperation operation) - { - List statements = - [ - BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart) - ]; - - List alterJobClauses = BuildAlterJobClauses(operation); - if (alterJobClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, alterJobClauses)); - } - - return statements; - } - - public List Generate(AlterRetentionPolicyOperation operation) - { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - - List statements = []; - bool needsRecreation = - operation.DropAfter != operation.OldDropAfter || - operation.DropCreatedBefore != operation.OldDropCreatedBefore || - operation.InitialStart != operation.OldInitialStart; - - if (needsRecreation) - { - statements.Add($"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);"); - statements.Add(BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart)); - - // Create a temporary "add" operation representing the final desired state to ensure existing settings are reapplied. - AddRetentionPolicyOperation finalStateOperation = new() - { - TableName = operation.TableName, - Schema = operation.Schema, - DropAfter = operation.DropAfter, - DropCreatedBefore = operation.DropCreatedBefore, - InitialStart = operation.InitialStart, - ScheduleInterval = operation.ScheduleInterval, - MaxRuntime = operation.MaxRuntime, - MaxRetries = operation.MaxRetries, - RetryPeriod = operation.RetryPeriod - }; - - List finalStateClauses = BuildAlterJobClauses(finalStateOperation); - if (finalStateClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, finalStateClauses)); - } - } - else - { - List changedClauses = BuildAlterJobClauses(operation); - if (changedClauses.Count != 0) - { - statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, changedClauses)); - } - } - - return statements; - } - - public List Generate(DropRetentionPolicyOperation operation) - { - string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); - - List statements = - [ - $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);" - ]; - return statements; - } - - private static List BuildAlterJobClauses(AddRetentionPolicyOperation operation) - { - List clauses = []; - - if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval)) - clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); - - if (!string.IsNullOrWhiteSpace(operation.MaxRuntime)) - clauses.Add($"max_runtime => INTERVAL '{operation.MaxRuntime}'"); - - if (operation.MaxRetries != null) - clauses.Add($"max_retries => {operation.MaxRetries}"); - - if (!string.IsNullOrWhiteSpace(operation.RetryPeriod)) - clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); - - return clauses; - } - - private static List BuildAlterJobClauses(AlterRetentionPolicyOperation operation) - { - List clauses = []; - - if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval) && operation.ScheduleInterval != operation.OldScheduleInterval) - clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); - - if (!string.IsNullOrWhiteSpace(operation.MaxRuntime) && operation.MaxRuntime != operation.OldMaxRuntime) - { - string maxRuntimeValue = string.IsNullOrWhiteSpace(operation.MaxRuntime) ? "NULL" : $"INTERVAL '{operation.MaxRuntime}'"; - clauses.Add($"max_runtime => {maxRuntimeValue}"); - } - - if (operation.MaxRetries != null && operation.MaxRetries != operation.OldMaxRetries) - clauses.Add($"max_retries => {operation.MaxRetries}"); - - if (!string.IsNullOrWhiteSpace(operation.RetryPeriod) && operation.RetryPeriod != operation.OldRetryPeriod) - clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); - - return clauses; - } - - private static string BuildAlterJobSql(string tableName, string schema, IEnumerable clauses) - { - // Note: hypertable_name is a varchar column, so it compares against a string literal, not a regclass. - return $@" - SELECT alter_job(job_id, {string.Join(", ", clauses)}) - FROM timescaledb_information.jobs - WHERE proc_name = 'policy_retention' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim(); - } - - private string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart) - { - string qualifiedTableName = sqlHelper.Regclass(tableName, schema); - - List args = []; - - if (!string.IsNullOrWhiteSpace(dropAfter)) - args.Add($"drop_after => INTERVAL '{dropAfter}'"); - else if (!string.IsNullOrWhiteSpace(dropCreatedBefore)) - args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'"); - - if (initialStart.HasValue) - { - string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - args.Add($"initial_start => '{timestamp}'"); - } - - return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});"; - } - } -} diff --git a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs new file mode 100644 index 0000000..d9e5d78 --- /dev/null +++ b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs @@ -0,0 +1,97 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using System.Globalization; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + public class RetentionPolicySqlGenerator + { + private const string ProcName = "policy_retention"; + + public static List Generate(AddRetentionPolicyOperation operation) + { + List statements = + [ + BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart) + ]; + + List jobClauses = PolicyJobSqlBuilder.BuildJobClauses( + operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod); + if (jobClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses)); + } + + return statements; + } + + public static List Generate(AlterRetentionPolicyOperation operation) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = []; + bool needsRecreation = + operation.DropAfter != operation.OldDropAfter || + operation.DropCreatedBefore != operation.OldDropCreatedBefore || + operation.InitialStart != operation.OldInitialStart; + + if (needsRecreation) + { + statements.Add($"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);"); + statements.Add(BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart)); + + // After recreation, reapply the full desired job configuration so existing settings are not lost. + List finalStateClauses = PolicyJobSqlBuilder.BuildJobClauses( + operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod); + if (finalStateClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, finalStateClauses)); + } + } + else + { + List changedClauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + operation.ScheduleInterval, operation.OldScheduleInterval, + operation.MaxRuntime, operation.OldMaxRuntime, + operation.MaxRetries, operation.OldMaxRetries, + operation.RetryPeriod, operation.OldRetryPeriod); + if (changedClauses.Count != 0) + { + statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, changedClauses)); + } + } + + return statements; + } + + public static List Generate(DropRetentionPolicyOperation operation) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = + [ + $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);" + ]; + return statements; + } + + private static string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema); + + List args = []; + + if (!string.IsNullOrWhiteSpace(dropAfter)) + args.Add($"drop_after => INTERVAL '{dropAfter}'"); + else if (!string.IsNullOrWhiteSpace(dropCreatedBefore)) + args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'"); + + if (initialStart.HasValue) + { + string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + args.Add($"initial_start => '{timestamp}'"); + } + + return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});"; + } + } +} diff --git a/src/Eftdb/Generators/SqlBuilderHelper.cs b/src/Eftdb/Generators/SqlBuilderHelper.cs index 84aa402..8f82aed 100644 --- a/src/Eftdb/Generators/SqlBuilderHelper.cs +++ b/src/Eftdb/Generators/SqlBuilderHelper.cs @@ -3,9 +3,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - public class SqlBuilderHelper(string quoteString) + public static class SqlBuilderHelper { - private readonly string quoteString = quoteString; + private static readonly string quoteString = "\""; public static void BuildQueryString(List statements, MigrationCommandListBuilder builder, bool suppressTransaction = false, bool usePerform = false) { @@ -17,13 +17,16 @@ public static void BuildQueryString(List statements, MigrationCommandLis // Group consecutive statements that don't end with semicolon into single commands List> commandGroups = []; List currentGroup = []; + int dollarQuoteCount = 0; foreach (string statement in statements) { currentGroup.Add(statement); - // If statement ends with semicolon, it's a complete command - if (statement.TrimEnd().EndsWith(';')) + dollarQuoteCount += statement.AsSpan().Count("$$"); + bool insideDollarQuote = dollarQuoteCount % 2 != 0; + + if (!insideDollarQuote && statement.TrimEnd().EndsWith(';')) { commandGroups.Add([.. currentGroup]); currentGroup.Clear(); @@ -107,14 +110,20 @@ public static void BuildQueryString(List statements, IndentedStringBuild } } - public string Regclass(string tableName, string schema = DefaultValues.DefaultSchema) + public static string Regclass(string tableName, string schema = DefaultValues.DefaultSchema) { return $"'{schema}.{quoteString}{tableName}{quoteString}'"; } - public string QualifiedIdentifier(string tableName, string schema = DefaultValues.DefaultSchema) + public static string QualifiedIdentifier(string tableName, string schema = DefaultValues.DefaultSchema) { return $"{quoteString}{schema}{quoteString}.{quoteString}{tableName}{quoteString}"; } + + /// + /// Wraps a single identifier in PostgreSQL double quotes. Used by SQL generators + /// to quote column references in compression segment/order-by lists, group-by clauses, etc. + /// + public static string QuoteIdentifier(string identifier) => $"\"{identifier}\""; } } diff --git a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs index 4ad5468..97fdc3c 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs @@ -15,12 +15,23 @@ public class ContinuousAggregatePolicyDiffer : IFeatureDiffer /// The source model (from the last migration). /// The target model (the current state). /// A collection of migration operations. - public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; + List operations = []; - List sourcePolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(source)]; - List targetPolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(target)]; + List allSourcePolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(source)]; + List allTargetPolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(target)]; + + // Recreating an aggregate drops its refresh policy, so re-add it and skip the normal diff. + foreach (AddContinuousAggregatePolicyOperation policy in allTargetPolicies.Where(t => context.RecreatedAggregates.Contains((t.Schema, t.MaterializedViewName)))) + { + operations.Add(policy); + } + + List sourcePolicies = [.. allSourcePolicies.Where(s => !context.RecreatedAggregates.Contains((s.Schema, s.MaterializedViewName)))]; + List targetPolicies = [.. allTargetPolicies.Where(t => !context.RecreatedAggregates.Contains((t.Schema, t.MaterializedViewName)))]; // Find new policies - continuous aggregates that now have a policy but didn't before IEnumerable newPolicies = targetPolicies diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index aa4657d..9e73c3c 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -6,13 +6,21 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Continuous { public class ContinuousAggregateDiffer : IFeatureDiffer { - public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; + List operations = []; List sourceAggregates = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(source)]; List targetAggregates = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(target)]; + // Apply the parent table's rename so a renamed parent alone doesn't force a recreate. + foreach (CreateContinuousAggregateOperation aggregate in sourceAggregates) + { + (_, aggregate.ParentName) = context.ResolveTable(aggregate.Schema, aggregate.ParentName); + } + // Find new continuous aggregates - only compare by MaterializedViewName, not Schema IEnumerable newAggregates = targetAggregates .Where(t => !sourceAggregates.Any(s => s.MaterializedViewName == t.MaterializedViewName)); @@ -93,7 +101,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source return operations; } - private static bool AreAggregateFunctionsEqual(List? list1, List? list2) + private static bool AreAggregateFunctionsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { if (list1 == null && list2 == null) return true; if (list1 == null || list2 == null) return false; @@ -102,7 +110,7 @@ private static bool AreAggregateFunctionsEqual(List? list1, List return list1.SequenceEqual(list2); } - private static bool AreGroupByColumnsEqual(List? list1, List? list2) + private static bool AreGroupByColumnsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { if (list1 == null && list2 == null) return true; if (list1 == null || list2 == null) return false; diff --git a/src/Eftdb/Internals/Features/FeatureDiffContext.cs b/src/Eftdb/Internals/Features/FeatureDiffContext.cs new file mode 100644 index 0000000..75489f6 --- /dev/null +++ b/src/Eftdb/Internals/Features/FeatureDiffContext.cs @@ -0,0 +1,51 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features +{ + /// + /// Carries cross-cutting information that individual implementations + /// need but cannot derive on their own: renames detected by EF Core's base differ, and parent + /// objects that another feature differ has decided to drop and recreate. + /// + /// + /// Schemas are always stored normalized to a concrete value (never null); callers must normalize + /// missing schemas to before building or querying the maps, + /// matching how the model extractors normalize GetSchema(). + /// + public sealed class FeatureDiffContext + { + /// Maps a source object's (schema, oldTableName) to its (schema, newTableName). + public IReadOnlyDictionary<(string Schema, string Name), (string Schema, string Name)> TableRenames { get; init; } + = new Dictionary<(string, string), (string, string)>(); + + /// Maps an index's (schema, oldIndexName) to its (schema, newIndexName). + public IReadOnlyDictionary<(string Schema, string Name), (string Schema, string Name)> IndexRenames { get; init; } + = new Dictionary<(string, string), (string, string)>(); + + /// + /// Maps a column's (schema, newTableName, oldColumnName) to its new column name. The table key uses + /// the post-rename table name because EF Core emits RenameColumnOperation against the renamed table. + /// + public IReadOnlyDictionary<(string Schema, string Table, string Column), string> ColumnRenames { get; init; } + = new Dictionary<(string, string, string), string>(); + + /// + /// Continuous aggregates (by (schema, viewName)) that are being dropped and recreated in this diff. + /// Recreating a continuous aggregate cascades to drop its refresh and retention policies, so dependent + /// policy differs must re-add those policies even when their configuration is unchanged. Populated by the + /// orchestrator after the continuous aggregate differ runs. + /// + public ISet<(string Schema, string ViewName)> RecreatedAggregates { get; init; } + = new HashSet<(string, string)>(); + + /// An empty context with identity rename maps; used when a differ is invoked without orchestration. + public static FeatureDiffContext Empty { get; } = new(); + + public (string Schema, string Name) ResolveTable(string schema, string name) + => TableRenames.TryGetValue((schema, name), out (string Schema, string Name) mapped) ? mapped : (schema, name); + + public (string Schema, string Name) ResolveIndex(string schema, string name) + => IndexRenames.TryGetValue((schema, name), out (string Schema, string Name) mapped) ? mapped : (schema, name); + + public string ResolveColumn(string schema, string table, string column) + => ColumnRenames.TryGetValue((schema, table, column), out string? mapped) ? mapped : column; + } +} diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs index f657c7d..9840273 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs @@ -7,15 +7,17 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertable { public class HypertableDiffer : IFeatureDiffer { - public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; + List operations = []; - List sourceHypertables = [.. HypertableModelExtractor.GetHypertables(source)]; + List sourceHypertables = [.. HypertableModelExtractor.GetHypertables(source).Select(s => RewriteSource(s, context))]; List targetHypertables = [.. HypertableModelExtractor.GetHypertables(target)]; // Find new hypertables - IEnumerable newHypertables = targetHypertables.Where(t => !sourceHypertables.Any(s => s.TableName == t.TableName)); + IEnumerable newHypertables = targetHypertables.Where(t => !sourceHypertables.Any(s => s.Schema == t.Schema && s.TableName == t.TableName)); operations.AddRange(newHypertables); // Find updated hypertables @@ -60,11 +62,62 @@ public IReadOnlyList GetDifferences(IRelationalModel? source }); } - // TODO: Detect dropped hypertables if TimescaleDB supports a "de-hyper" operation. - return operations; } + /// + /// 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. + /// + private static CreateHypertableOperation RewriteSource(CreateHypertableOperation source, FeatureDiffContext context) + { + (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName); + + return new CreateHypertableOperation + { + TableName = tableName, + Schema = schema, + TimeColumnName = context.ResolveColumn(schema, tableName, source.TimeColumnName), + ChunkTimeInterval = source.ChunkTimeInterval, + EnableCompression = source.EnableCompression, + MigrateData = source.MigrateData, + ChunkSkipColumns = RewriteColumns(source.ChunkSkipColumns, schema, tableName, context), + CompressionSegmentBy = RewriteColumns(source.CompressionSegmentBy, schema, tableName, context), + CompressionOrderBy = RewriteOrderByColumns(source.CompressionOrderBy, schema, tableName, context), + AdditionalDimensions = RewriteDimensions(source.AdditionalDimensions, schema, tableName, context), + }; + } + + private static List? RewriteColumns(IReadOnlyList? columns, string schema, string table, FeatureDiffContext context) + => columns?.Select(c => context.ResolveColumn(schema, table, c)).ToList(); + + private static List? RewriteOrderByColumns(IReadOnlyList? columns, string schema, string table, FeatureDiffContext context) + { + // Order-by entries carry a direction suffix (e.g. "time DESC"); only the leading column name is renamed. + return columns?.Select(c => + { + string[] parts = c.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return c; + } + + string column = context.ResolveColumn(schema, table, parts[0]); + return parts.Length > 1 ? $"{column} {parts[1]}" : column; + }).ToList(); + } + + private static List? RewriteDimensions(IReadOnlyList? dimensions, string schema, string table, FeatureDiffContext context) + { + return dimensions?.Select(d => new Dimension + { + ColumnName = context.ResolveColumn(schema, table, d.ColumnName), + Type = d.Type, + Interval = d.Interval, + NumberOfPartitions = d.NumberOfPartitions, + }).ToList(); + } + private static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { return (list1 ?? []).SequenceEqual(list2 ?? []); diff --git a/src/Eftdb/Internals/Features/IFeatureDiffer.cs b/src/Eftdb/Internals/Features/IFeatureDiffer.cs index e977a4a..e12b041 100644 --- a/src/Eftdb/Internals/Features/IFeatureDiffer.cs +++ b/src/Eftdb/Internals/Features/IFeatureDiffer.cs @@ -14,7 +14,11 @@ public interface IFeatureDiffer /// /// The source model (from the last migration). /// The target model (the current state). + /// + /// Cross-cutting diff information. When omitted, the differ behaves as if + /// nothing was renamed or recreated (). + /// /// A collection of migration operations. - IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target); + IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null); } } diff --git a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs index dd8729a..741e40f 100644 --- a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs @@ -6,17 +6,19 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPol { public class ReorderPolicyDiffer : IFeatureDiffer { - public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; + // Get the standard migration operations (CreateTable, AddColumn, etc.) from the base MigrationsModelDiffer. List operations = []; - // Reorder diffs - List sourcePolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(source)]; + // Apply table/index renames to the source so a rename isn't seen as a new policy. + List sourcePolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(source).Select(s => RewriteSource(s, context))]; List targetPolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(target)]; - // Identiy new reorder policies - IEnumerable newReorderPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.TableName == t.TableName)); + // Identiy new reorder policies (keyed on schema and table name, consistent with the update join below) + IEnumerable newReorderPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.Schema == t.Schema && s.TableName == t.TableName)); operations.AddRange(newReorderPolicies); // Identify updated reorder policies @@ -65,5 +67,27 @@ public IReadOnlyList GetDifferences(IRelationalModel? source return operations; } + + /// + /// Produces a copy of a source reorder policy with its table, schema, and referenced index rewritten through + /// the rename maps, so that a pure rename compares equal to its target and produces no operation. + /// + private static AddReorderPolicyOperation RewriteSource(AddReorderPolicyOperation source, FeatureDiffContext context) + { + (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName); + (_, string indexName) = context.ResolveIndex(source.Schema, source.IndexName); + + return new AddReorderPolicyOperation + { + TableName = tableName, + Schema = schema, + IndexName = indexName, + InitialStart = source.InitialStart, + ScheduleInterval = source.ScheduleInterval, + MaxRuntime = source.MaxRuntime, + MaxRetries = source.MaxRetries, + RetryPeriod = source.RetryPeriod, + }; + } } } diff --git a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs index 2da4b02..73c2906 100644 --- a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs @@ -6,12 +6,24 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionP { public class RetentionPolicyDiffer : IFeatureDiffer { - public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; + List operations = []; - List sourcePolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(source)]; - List targetPolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(target)]; + // Apply table renames to the source so a rename isn't seen as a drop-and-add. + List allSourcePolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(source).Select(s => RewriteSource(s, context))]; + List allTargetPolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(target)]; + + // Recreating an aggregate drops its retention policy, so re-add it and skip the normal diff. + foreach (AddRetentionPolicyOperation policy in allTargetPolicies.Where(t => context.RecreatedAggregates.Contains((t.Schema, t.TableName)))) + { + operations.Add(policy); + } + + List sourcePolicies = [.. allSourcePolicies.Where(s => !context.RecreatedAggregates.Contains((s.Schema, s.TableName)))]; + List targetPolicies = [.. allTargetPolicies.Where(t => !context.RecreatedAggregates.Contains((t.Schema, t.TableName)))]; // Identify new retention policies IEnumerable newRetentionPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.TableName == t.TableName && s.Schema == t.Schema)); @@ -67,5 +79,27 @@ public IReadOnlyList GetDifferences(IRelationalModel? source return operations; } + + /// + /// Produces a copy of a source retention policy with its table and schema rewritten through the table-rename + /// map, so that a pure rename compares equal to its target and produces no operation. + /// + private static AddRetentionPolicyOperation RewriteSource(AddRetentionPolicyOperation source, FeatureDiffContext context) + { + (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName); + + return new AddRetentionPolicyOperation + { + TableName = tableName, + Schema = schema, + DropAfter = source.DropAfter, + DropCreatedBefore = source.DropCreatedBefore, + InitialStart = source.InitialStart, + ScheduleInterval = source.ScheduleInterval, + MaxRuntime = source.MaxRuntime, + MaxRetries = source.MaxRetries, + RetryPeriod = source.RetryPeriod, + }; + } } } diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs index fd1e553..5a728f8 100644 --- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs +++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs @@ -22,29 +22,97 @@ public class TimescaleMigrationsModelDiffer( IRowIdentityMapFactory rowIdentityMapFactory, CommandBatchPreparerDependencies commandBatchPreparerDependencies) : MigrationsModelDiffer(typeMappingSource, migrationsAnnotationProvider, relationalAnnotationProvider, rowIdentityMapFactory, commandBatchPreparerDependencies) { - private readonly IReadOnlyList _featureDiffers = [ - new HypertableDiffer(), - new ReorderPolicyDiffer(), - new ContinuousAggregateDiffer(), - new ContinuousAggregatePolicyDiffer(), - new RetentionPolicyDiffer(), - ]; - public override IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) { - // Get all operations + // Standard EF Core operations, which include the rename operations the feature differs need to + // distinguish a rename from a drop-and-create. List allOperations = [.. base.GetDifferences(source, target)]; - foreach (IFeatureDiffer differ in _featureDiffers) - { - allOperations.AddRange(differ.GetDifferences(source, target)); - } + FeatureDiffContext context = BuildContext(allOperations); + + allOperations.AddRange(new HypertableDiffer().GetDifferences(source, target, context)); + allOperations.AddRange(new ReorderPolicyDiffer().GetDifferences(source, target, context)); + + IReadOnlyList aggregateOperations = new ContinuousAggregateDiffer().GetDifferences(source, target, context); + allOperations.AddRange(aggregateOperations); + + PopulateRecreatedAggregates(aggregateOperations, context.RecreatedAggregates); + + allOperations.AddRange(new ContinuousAggregatePolicyDiffer().GetDifferences(source, target, context)); + allOperations.AddRange(new RetentionPolicyDiffer().GetDifferences(source, target, context)); // Sort the entire list based on the priority defined in the helper method List sortedOperations = [.. allOperations.OrderBy(GetOperationPriority)]; return sortedOperations; } + /// + /// Builds rename maps from the standard EF Core operations so feature differs can recognize renamed + /// tables, indexes, and columns instead of treating them as drop-and-create. + /// + private static FeatureDiffContext BuildContext(IEnumerable baseOperations) + { + Dictionary<(string, string), (string, string)> tableRenames = []; + Dictionary<(string, string), (string, string)> indexRenames = []; + Dictionary<(string, string, string), string> columnRenames = []; + + foreach (MigrationOperation operation in baseOperations) + { + switch (operation) + { + case RenameTableOperation rename: + { + string oldSchema = rename.Schema ?? DefaultValues.DefaultSchema; + string newSchema = rename.NewSchema ?? rename.Schema ?? DefaultValues.DefaultSchema; + string newName = rename.NewName ?? rename.Name; + tableRenames[(oldSchema, rename.Name)] = (newSchema, newName); + break; + } + case RenameIndexOperation rename when rename.NewName != null: + { + string schema = rename.Schema ?? DefaultValues.DefaultSchema; + indexRenames[(schema, rename.Name)] = (schema, rename.NewName); + break; + } + case RenameColumnOperation rename: + { + // RenameColumnOperation targets the table by its post-rename name, so the column key + // uses the new table name to line up with rename-rewritten source operations. + string schema = rename.Schema ?? DefaultValues.DefaultSchema; + columnRenames[(schema, rename.Table, rename.Name)] = rename.NewName; + break; + } + } + } + + return new FeatureDiffContext + { + TableRenames = tableRenames, + IndexRenames = indexRenames, + ColumnRenames = columnRenames, + RecreatedAggregates = new HashSet<(string, string)>(), + }; + } + + /// + /// Records continuous aggregates that appear in both a drop and a create operation, signalling that their + /// refresh and retention policies must be re-added after the recreate. + /// + private static void PopulateRecreatedAggregates(IReadOnlyList aggregateOperations, ISet<(string Schema, string ViewName)> recreated) + { + HashSet<(string, string)> dropped = [.. aggregateOperations + .OfType() + .Select(o => (o.Schema, o.MaterializedViewName))]; + + foreach (CreateContinuousAggregateOperation create in aggregateOperations.OfType()) + { + if (dropped.Contains((create.Schema, create.MaterializedViewName))) + { + recreated.Add((create.Schema, create.MaterializedViewName)); + } + } + } + /// /// Assigns a priority to operations to ensure correct execution order. /// Lower numbers execute first. @@ -75,6 +143,8 @@ private static int GetOperationPriority(MigrationOperation operation) // --- Add/Alter operations: positive priorities, dependency order --- case CreateHypertableOperation: return 10; + case AlterHypertableOperation: + return 15; case AddReorderPolicyOperation: case AlterReorderPolicyOperation: diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs new file mode 100644 index 0000000..837d464 --- /dev/null +++ b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs @@ -0,0 +1,90 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class ContinuousAggregateMigrationExtensions + { + public static OperationBuilder CreateContinuousAggregate( + this MigrationBuilder migrationBuilder, + string materializedViewName, + string parentName, + string? schema = null, + string? chunkInterval = null, + bool withNoData = false, + bool createGroupIndexes = false, + bool materializedOnly = false, + string? timeBucketWidth = null, + string? timeBucketSourceColumn = null, + bool timeBucketGroupBy = true, + IReadOnlyList? aggregateFunctions = null, + IReadOnlyList? groupByColumns = null, + string? whereClause = null, + string? viewDefinition = null) + { + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = materializedViewName, + ParentName = parentName, + Schema = schema ?? string.Empty, + ChunkInterval = chunkInterval, + WithNoData = withNoData, + CreateGroupIndexes = createGroupIndexes, + MaterializedOnly = materializedOnly, + TimeBucketWidth = timeBucketWidth ?? string.Empty, + TimeBucketSourceColumn = timeBucketSourceColumn ?? string.Empty, + TimeBucketGroupBy = timeBucketGroupBy, + AggregateFunctions = aggregateFunctions is null ? [] : [.. aggregateFunctions.Select(f => f.ToAnnotationValue())], + GroupByColumns = groupByColumns ?? [], + WhereClause = whereClause, + ViewDefinition = viewDefinition, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder AlterContinuousAggregate( + this MigrationBuilder migrationBuilder, + string materializedViewName, + string? schema = null, + string? chunkInterval = null, + bool createGroupIndexes = false, + bool materializedOnly = false, + string? oldChunkInterval = null, + bool oldCreateGroupIndexes = false, + bool oldMaterializedOnly = false) + { + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = materializedViewName, + Schema = schema ?? string.Empty, + ChunkInterval = chunkInterval, + CreateGroupIndexes = createGroupIndexes, + MaterializedOnly = materializedOnly, + OldChunkInterval = oldChunkInterval, + OldCreateGroupIndexes = oldCreateGroupIndexes, + OldMaterializedOnly = oldMaterializedOnly, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder DropContinuousAggregate( + this MigrationBuilder migrationBuilder, + string materializedViewName, + string? schema = null) + { + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = materializedViewName, + Schema = schema ?? string.Empty, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs new file mode 100644 index 0000000..46f123f --- /dev/null +++ b/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs @@ -0,0 +1,58 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class ContinuousAggregatePolicyMigrationExtensions + { + public static OperationBuilder AddContinuousAggregatePolicy( + this MigrationBuilder migrationBuilder, + string materializedViewName, + string? schema = null, + string? startOffset = null, + string? endOffset = null, + string? scheduleInterval = null, + DateTime? initialStart = null, + bool ifNotExists = false, + bool? includeTieredData = null, + int bucketsPerBatch = 1, + int maxBatchesPerExecution = 0, + bool refreshNewestFirst = true) + { + AddContinuousAggregatePolicyOperation operation = new() + { + MaterializedViewName = materializedViewName, + Schema = schema ?? string.Empty, + StartOffset = startOffset, + EndOffset = endOffset, + ScheduleInterval = scheduleInterval, + InitialStart = initialStart, + IfNotExists = ifNotExists, + IncludeTieredData = includeTieredData, + BucketsPerBatch = bucketsPerBatch, + MaxBatchesPerExecution = maxBatchesPerExecution, + RefreshNewestFirst = refreshNewestFirst, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder RemoveContinuousAggregatePolicy( + this MigrationBuilder migrationBuilder, + string materializedViewName, + string? schema = null, + bool ifExists = false) + { + RemoveContinuousAggregatePolicyOperation operation = new() + { + MaterializedViewName = materializedViewName, + Schema = schema ?? string.Empty, + IfExists = ifExists, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs new file mode 100644 index 0000000..a5cf9a2 --- /dev/null +++ b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs @@ -0,0 +1,79 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class HypertableMigrationExtensions + { + public static OperationBuilder CreateHypertable( + this MigrationBuilder migrationBuilder, + string tableName, + string timeColumnName, + string? schema = null, + string? chunkTimeInterval = null, + bool enableCompression = false, + bool migrateData = false, + IReadOnlyList? chunkSkipColumns = null, + IReadOnlyList? additionalDimensions = null, + IReadOnlyList? compressionSegmentBy = null, + IReadOnlyList? compressionOrderBy = null) + { + CreateHypertableOperation operation = new() + { + TableName = tableName, + TimeColumnName = timeColumnName, + Schema = schema ?? string.Empty, + ChunkTimeInterval = chunkTimeInterval ?? string.Empty, + EnableCompression = enableCompression, + MigrateData = migrateData, + ChunkSkipColumns = chunkSkipColumns, + AdditionalDimensions = additionalDimensions, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder AlterHypertable( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null, + string? chunkTimeInterval = null, + bool enableCompression = false, + IReadOnlyList? chunkSkipColumns = null, + IReadOnlyList? additionalDimensions = null, + IReadOnlyList? compressionSegmentBy = null, + IReadOnlyList? compressionOrderBy = null, + string? oldChunkTimeInterval = null, + bool oldEnableCompression = false, + IReadOnlyList? oldChunkSkipColumns = null, + IReadOnlyList? oldAdditionalDimensions = null, + IReadOnlyList? oldCompressionSegmentBy = null, + IReadOnlyList? oldCompressionOrderBy = null) + { + AlterHypertableOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + ChunkTimeInterval = chunkTimeInterval ?? string.Empty, + EnableCompression = enableCompression, + ChunkSkipColumns = chunkSkipColumns, + AdditionalDimensions = additionalDimensions, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy, + OldChunkTimeInterval = oldChunkTimeInterval ?? string.Empty, + OldEnableCompression = oldEnableCompression, + OldChunkSkipColumns = oldChunkSkipColumns, + OldAdditionalDimensions = oldAdditionalDimensions, + OldCompressionSegmentBy = oldCompressionSegmentBy, + OldCompressionOrderBy = oldCompressionOrderBy, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs new file mode 100644 index 0000000..f8ee758 --- /dev/null +++ b/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs @@ -0,0 +1,89 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class ReorderPolicyMigrationExtensions + { + public static OperationBuilder AddReorderPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string indexName, + string? schema = null, + DateTime? initialStart = null, + string? scheduleInterval = null, + string? maxRuntime = null, + int? maxRetries = null, + string? retryPeriod = null) + { + AddReorderPolicyOperation operation = new() + { + TableName = tableName, + IndexName = indexName, + Schema = schema ?? string.Empty, + InitialStart = initialStart, + ScheduleInterval = scheduleInterval, + MaxRuntime = maxRuntime, + MaxRetries = maxRetries, + RetryPeriod = retryPeriod, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder AlterReorderPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string indexName, + string? schema = null, + DateTime? initialStart = null, + string? scheduleInterval = null, + string? maxRuntime = null, + int? maxRetries = null, + string? retryPeriod = null, + string? oldIndexName = null, + DateTime? oldInitialStart = null, + string? oldScheduleInterval = null, + string? oldMaxRuntime = null, + int? oldMaxRetries = null, + string? oldRetryPeriod = null) + { + AlterReorderPolicyOperation operation = new() + { + TableName = tableName, + IndexName = indexName, + Schema = schema ?? string.Empty, + InitialStart = initialStart, + ScheduleInterval = scheduleInterval, + MaxRuntime = maxRuntime, + MaxRetries = maxRetries, + RetryPeriod = retryPeriod, + OldIndexName = oldIndexName ?? string.Empty, + OldInitialStart = oldInitialStart, + OldScheduleInterval = oldScheduleInterval, + OldMaxRuntime = oldMaxRuntime, + OldMaxRetries = oldMaxRetries, + OldRetryPeriod = oldRetryPeriod, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder DropReorderPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null) + { + DropReorderPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs new file mode 100644 index 0000000..f829882 --- /dev/null +++ b/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs @@ -0,0 +1,95 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class RetentionPolicyMigrationExtensions + { + public static OperationBuilder AddRetentionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null, + string? dropAfter = null, + string? dropCreatedBefore = null, + DateTime? initialStart = null, + string? scheduleInterval = null, + string? maxRuntime = null, + int? maxRetries = null, + string? retryPeriod = null) + { + AddRetentionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + DropAfter = dropAfter, + DropCreatedBefore = dropCreatedBefore, + InitialStart = initialStart, + ScheduleInterval = scheduleInterval, + MaxRuntime = maxRuntime, + MaxRetries = maxRetries, + RetryPeriod = retryPeriod, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder AlterRetentionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null, + string? dropAfter = null, + string? dropCreatedBefore = null, + DateTime? initialStart = null, + string? scheduleInterval = null, + string? maxRuntime = null, + int? maxRetries = null, + string? retryPeriod = null, + string? oldDropAfter = null, + string? oldDropCreatedBefore = null, + DateTime? oldInitialStart = null, + string? oldScheduleInterval = null, + string? oldMaxRuntime = null, + int? oldMaxRetries = null, + string? oldRetryPeriod = null) + { + AlterRetentionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + DropAfter = dropAfter, + DropCreatedBefore = dropCreatedBefore, + InitialStart = initialStart, + ScheduleInterval = scheduleInterval, + MaxRuntime = maxRuntime, + MaxRetries = maxRetries, + RetryPeriod = retryPeriod, + OldDropAfter = oldDropAfter, + OldDropCreatedBefore = oldDropCreatedBefore, + OldInitialStart = oldInitialStart, + OldScheduleInterval = oldScheduleInterval, + OldMaxRuntime = oldMaxRuntime, + OldMaxRetries = oldMaxRetries, + OldRetryPeriod = oldRetryPeriod, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder DropRetentionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null) + { + DropRetentionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs index 2fd50d6..ae2b562 100644 --- a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs @@ -17,8 +17,8 @@ public class CreateContinuousAggregateOperation : MigrationOperation public string TimeBucketSourceColumn { get; set; } = string.Empty; public bool TimeBucketGroupBy { get; set; } - public List AggregateFunctions { get; set; } = []; - public List GroupByColumns { get; set; } = []; + public IReadOnlyList AggregateFunctions { get; set; } = []; + public IReadOnlyList GroupByColumns { get; set; } = []; public string? WhereClause { get; set; } /// diff --git a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs index 77b6683..a5bad98 100644 --- a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs +++ b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs @@ -17,79 +17,61 @@ protected override void Generate( MigrationCommandListBuilder builder) { List statements; - HypertableOperationGenerator? hypertableOperationGenerator = null; - ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null; - RetentionPolicyOperationGenerator? retentionPolicyOperationGenerator = null; - ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null; - ContinuousAggregatePolicyOperationGenerator? continuousAggregatePolicyOperationGenerator = null; bool suppressTransaction = false; switch (operation) { case CreateHypertableOperation hypertableOperation: - hypertableOperationGenerator ??= new(isDesignTime: false); - statements = hypertableOperationGenerator.Generate(hypertableOperation); + statements = HypertableSqlGenerator.Generate(hypertableOperation); break; case AlterHypertableOperation alterHypertableOperation: - hypertableOperationGenerator ??= new(isDesignTime: false); - statements = hypertableOperationGenerator.Generate(alterHypertableOperation); + statements = HypertableSqlGenerator.Generate(alterHypertableOperation); break; case AlterReorderPolicyOperation alterReorderPolicyOperation: - reorderPolicyOperationGenerator ??= new(isDesignTime: false); - statements = reorderPolicyOperationGenerator.Generate(alterReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(alterReorderPolicyOperation); break; case AddReorderPolicyOperation addReorderPolicyOperation: - reorderPolicyOperationGenerator ??= new(isDesignTime: false); - statements = reorderPolicyOperationGenerator.Generate(addReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(addReorderPolicyOperation); break; case DropReorderPolicyOperation dropReorderPolicyOperation: - reorderPolicyOperationGenerator ??= new(isDesignTime: false); - statements = reorderPolicyOperationGenerator.Generate(dropReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(dropReorderPolicyOperation); break; case AddRetentionPolicyOperation addRetentionPolicyOperation: - retentionPolicyOperationGenerator ??= new(isDesignTime: false); - statements = retentionPolicyOperationGenerator.Generate(addRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(addRetentionPolicyOperation); break; case AlterRetentionPolicyOperation alterRetentionPolicyOperation: - retentionPolicyOperationGenerator ??= new(isDesignTime: false); - statements = retentionPolicyOperationGenerator.Generate(alterRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(alterRetentionPolicyOperation); break; case DropRetentionPolicyOperation dropRetentionPolicyOperation: - retentionPolicyOperationGenerator ??= new(isDesignTime: false); - statements = retentionPolicyOperationGenerator.Generate(dropRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(dropRetentionPolicyOperation); break; case CreateContinuousAggregateOperation createContinuousAggregateOperation: - continuousAggregateOperationGenerator ??= new(isDesignTime: false); - statements = continuousAggregateOperationGenerator.Generate(createContinuousAggregateOperation); + statements = ContinuousAggregateSqlGenerator.Generate(createContinuousAggregateOperation); suppressTransaction = true; break; case AlterContinuousAggregateOperation alterContinuousAggregateOperation: - continuousAggregateOperationGenerator ??= new(isDesignTime: false); - statements = continuousAggregateOperationGenerator.Generate(alterContinuousAggregateOperation); + statements = ContinuousAggregateSqlGenerator.Generate(alterContinuousAggregateOperation); break; case DropContinuousAggregateOperation dropContinuousAggregateOperation: - continuousAggregateOperationGenerator ??= new(isDesignTime: false); - statements = continuousAggregateOperationGenerator.Generate(dropContinuousAggregateOperation); + statements = ContinuousAggregateSqlGenerator.Generate(dropContinuousAggregateOperation); break; case AddContinuousAggregatePolicyOperation addContinuousAggregatePolicyOperation: - continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: false); - statements = continuousAggregatePolicyOperationGenerator.Generate(addContinuousAggregatePolicyOperation); + statements = ContinuousAggregatePolicySqlGenerator.Generate(addContinuousAggregatePolicyOperation); break; case RemoveContinuousAggregatePolicyOperation removeContinuousAggregatePolicyOperation: - continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: false); - statements = continuousAggregatePolicyOperationGenerator.Generate(removeContinuousAggregatePolicyOperation); + statements = ContinuousAggregatePolicySqlGenerator.Generate(removeContinuousAggregatePolicyOperation); break; default: From c123d8510b709d0ef362a0cb0aefd5ee18bbe3be Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 9 Jun 2026 13:14:00 +0200 Subject: [PATCH 2/5] test: cover the reworked migration code generation --- .../ContinuousAggregateFunctionTests.cs | 72 +++ .../Generators/CSharpGeneratorHelperTests.cs | 120 ++++ ...ContinuousAggregateCSharpGeneratorTests.cs | 163 +++++ ...uousAggregatePolicyCSharpGeneratorTests.cs | 112 ++++ .../HypertableCSharpGeneratorTests.cs | 216 +++++++ .../Generators/MigrationCallWriterTests.cs | 101 ++++ .../ReorderPolicyCSharpGeneratorTests.cs | 114 ++++ .../RetentionPolicyCSharpGeneratorTests.cs | 103 ++++ .../Differs/FeatureDifferContextTests.cs | 565 ++++++++++++++++++ .../Differs/HypertableDifferTests.cs | 2 +- ...nuousAggregatePolicyModelExtractorTests.cs | 10 +- ...tinuousAggregateOperationGeneratorTests.cs | 206 ++----- ...sAggregatePolicyOperationGeneratorTests.cs | 88 +-- ...pertableSqlGeneratorComprehensiveTests.cs} | 154 ++--- ...ests.cs => HypertableSqlGeneratorTests.cs} | 117 ++-- .../Generators/PolicyJobSqlBuilderTests.cs | 219 +++++++ ...icyOperationGeneratorComprehensiveTests.cs | 57 +- .../ReorderPolicyOperationGeneratorTests.cs | 48 +- .../RetentionPolicyOperationGeneratorTests.cs | 156 +++-- .../Generators/SqlBuilderHelperTests.cs | 113 ++-- ...eCSharpMigrationOperationGeneratorTests.cs | 198 +++--- .../TimescaleDbMigrationsSqlGeneratorTests.cs | 63 ++ .../Integration/MigrationDifferRenameTests.cs | 388 ++++++++++++ .../Internals/FeatureDiffContextTests.cs | 223 +++++++ .../Internals/OperationOrderingTests.cs | 262 ++++++++ ...inuousAggregateMigrationExtensionsTests.cs | 141 +++++ ...AggregatePolicyMigrationExtensionsTests.cs | 117 ++++ .../HypertableMigrationExtensionsTests.cs | 153 +++++ .../ReorderPolicyMigrationExtensionsTests.cs | 154 +++++ ...RetentionPolicyMigrationExtensionsTests.cs | 138 +++++ tests/Eftdb.Tests/Utils/DesignTimeHelper.cs | 19 + 31 files changed, 3995 insertions(+), 597 deletions(-) create mode 100644 tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs rename tests/Eftdb.Tests/Generators/{HypertableOperationGeneratorComprehensiveTests.cs => HypertableSqlGeneratorComprehensiveTests.cs} (89%) rename tests/Eftdb.Tests/Generators/{HypertableOperationGeneratorTests.cs => HypertableSqlGeneratorTests.cs} (81%) create mode 100644 tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs create mode 100644 tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs create mode 100644 tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs create mode 100644 tests/Eftdb.Tests/Internals/OperationOrderingTests.cs create mode 100644 tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/MigrationExtensions/ReorderPolicyMigrationExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs create mode 100644 tests/Eftdb.Tests/Utils/DesignTimeHelper.cs diff --git a/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs b/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs new file mode 100644 index 0000000..43b3594 --- /dev/null +++ b/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs @@ -0,0 +1,72 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Abstractions +{ + public class ContinuousAggregateFunctionTests + { + #region ToAnnotationValue_SerializesToAliasFunctionSourceColumn + + [Fact] + public void ToAnnotationValue_SerializesToAliasFunctionSourceColumn() + { + // Arrange + ContinuousAggregateFunction function = new("avg_t", EAggregateFunction.Avg, "temp"); + + // Act + string annotationValue = function.ToAnnotationValue(); + + // Assert + Assert.Equal("avg_t:Avg:temp", annotationValue); + } + + #endregion + + #region Constructor_ExposesPropertiesUnchanged + + [Fact] + public void Constructor_ExposesPropertiesUnchanged() + { + // Arrange & Act + ContinuousAggregateFunction function = new("total", EAggregateFunction.Sum, "value"); + + // Assert + Assert.Equal("total", function.Alias); + Assert.Equal(EAggregateFunction.Sum, function.Function); + Assert.Equal("value", function.SourceColumn); + } + + #endregion + + #region RoundTrip_AnnotationValue_FeedsContinuousAggregateGenerator + + [Fact] + public void RoundTrip_AnnotationValue_FeedsContinuousAggregateGenerator() + { + // Arrange — the wire format produced by ContinuousAggregateFunction must be + // parseable by ContinuousAggregateSqlGenerator's ':'-delimited parser. + ContinuousAggregateFunction function = new("avg_t", EAggregateFunction.Avg, "temp"); + + CreateContinuousAggregateOperation operation = new() + { + Schema = "public", + MaterializedViewName = "daily_avg", + ParentName = "measurements", + TimeBucketWidth = "1 day", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = [function.ToAnnotationValue()] + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(operation); + string sql = string.Join("\n", statements); + + // Assert — Avg maps to AVG("temp") with the alias "avg_t" + Assert.Contains("AVG(\"temp\") AS \"avg_t\"", sql); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs new file mode 100644 index 0000000..37e60bd --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs @@ -0,0 +1,120 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests for the internal CSharpGeneratorHelper behavior. The type is internal in the + /// Eftdb.Design assembly which does NOT expose InternalsVisibleTo to the test project, + /// so its two behaviors are exercised indirectly through public generators: + /// + /// LiteralStringList via string list args. + /// StaticCall (with int rendered unquoted via UnknownLiteral) via the + /// additionalDimensions hash dimension emission. + /// + /// + public class CSharpGeneratorHelperTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(CreateHypertableOperation operation) + { + IndentedStringBuilder builder = new(); + new HypertableCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region LiteralStringList_RendersBracketedQuotedCommaSeparated + + [Fact] + public void LiteralStringList_RendersBracketedQuotedCommaSeparated() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "t", + TimeColumnName = "ts", + ChunkSkipColumns = ["a", "b"], + }; + + // Act + string result = Generate(op); + + // Assert — collection expression ["a", "b"]. + Assert.Contains("[\"a\", \"b\"]", result); + } + + #endregion + + #region LiteralStringList_SingleElement + + [Fact] + public void LiteralStringList_SingleElement() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "t", + TimeColumnName = "ts", + CompressionSegmentBy = ["device_id"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSegmentBy: [\"device_id\"]", result); + } + + #endregion + + #region StaticCall_RendersIntArgUnquoted + + [Fact] + public void StaticCall_RendersIntArgUnquoted() + { + // Arrange — a hash dimension forces StaticCall(..., columnName, numberOfPartitions:int). + CreateHypertableOperation op = new() + { + TableName = "t", + TimeColumnName = "ts", + AdditionalDimensions = [Dimension.CreateHash("hash_col", 4)], + }; + + // Act + string result = Generate(op); + + // Assert — string arg is quoted, int arg is unquoted. + Assert.Contains(".CreateHash(\"hash_col\", 4)", result); + Assert.DoesNotContain("CreateHash(\"hash_col\", \"4\")", result); + } + + #endregion + + #region StaticCall_RendersStringArgQuoted + + [Fact] + public void StaticCall_RendersStringArgQuoted() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "t", + TimeColumnName = "ts", + AdditionalDimensions = [Dimension.CreateRange("range_col", "1 day")], + }; + + // Act + string result = Generate(op); + + // Assert — both args are strings and quoted. + Assert.Contains(".CreateRange(\"range_col\", \"1 day\")", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs new file mode 100644 index 0000000..57c122a --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs @@ -0,0 +1,163 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by + /// using a real . + /// + public class ContinuousAggregateCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(CreateContinuousAggregateOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregateCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterContinuousAggregateOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregateCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region CreateContinuousAggregate_AggregateFunction_FullyQualifiedTypedEntry + + [Fact] + public void CreateContinuousAggregate_AggregateFunction_FullyQualifiedTypedEntry() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + AggregateFunctions = ["avg_t:Avg:temp"], + }; + + // Act + string result = Generate(op); + + // Assert — fully qualified type and enum reference. + Assert.Contains( + "new CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.ContinuousAggregateFunction(\"avg_t\", CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.EAggregateFunction.Avg, \"temp\")", + result); + } + + #endregion + + #region CreateContinuousAggregate_MalformedAggregateString_IsSkipped + + [Fact] + public void CreateContinuousAggregate_MalformedAggregateString_IsSkipped() + { + // Arrange — second entry has only two parts and must be silently skipped. + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + AggregateFunctions = ["avg_t:Avg:temp", "malformed:Sum"], + }; + + // Act + string result = Generate(op); + + // Assert — only the well-formed entry is emitted. + Assert.Contains("EAggregateFunction.Avg, \"temp\")", result); + Assert.DoesNotContain("malformed", result); + } + + #endregion + + #region CreateContinuousAggregate_TimeBucketGroupBy_OnlyEmittedWhenDisabled + + [Fact] + public void CreateContinuousAggregate_TimeBucketGroupBy_OnlyEmittedWhenDisabled() + { + // Arrange — default true must NOT be emitted. + CreateContinuousAggregateOperation enabled = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + TimeBucketGroupBy = true, + }; + + // Act + string enabledResult = Generate(enabled); + + // Assert + Assert.DoesNotContain("timeBucketGroupBy:", enabledResult); + + // Arrange — non-default false must be emitted as false. + CreateContinuousAggregateOperation disabled = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + TimeBucketGroupBy = false, + }; + + // Act + string disabledResult = Generate(disabled); + + // Assert + Assert.Contains("timeBucketGroupBy: false", disabledResult); + } + + #endregion + + #region CreateContinuousAggregate_GroupByColumns_EmitCollectionExpression + + [Fact] + public void CreateContinuousAggregate_GroupByColumns_EmitCollectionExpression() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + GroupByColumns = ["device_id", "region"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("groupByColumns: [\"device_id\", \"region\"]", result); + } + + #endregion + + #region AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault + + [Fact] + public void AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ChunkInterval = "7 days", + OldChunkInterval = "1 day", + OldCreateGroupIndexes = true, + OldMaterializedOnly = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("chunkInterval: \"7 days\"", result); + Assert.Contains("oldChunkInterval: \"1 day\"", result); + Assert.Contains("oldCreateGroupIndexes: true", result); + Assert.DoesNotContain("oldMaterializedOnly:", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs new file mode 100644 index 0000000..7799adb --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs @@ -0,0 +1,112 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by + /// using a real . + /// + public class ContinuousAggregatePolicyCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(AddContinuousAggregatePolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregatePolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(RemoveContinuousAggregatePolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregatePolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region AddPolicy_DefaultValues_OmitInvertedAndDefaultArgs + + [Fact] + public void AddPolicy_DefaultValues_OmitInvertedAndDefaultArgs() + { + // Arrange — all defaults (BucketsPerBatch=1, MaxBatchesPerExecution=0, RefreshNewestFirst=true). + AddContinuousAggregatePolicyOperation op = new() + { + MaterializedViewName = "hourly", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("materializedViewName: \"hourly\"", result); + Assert.DoesNotContain("bucketsPerBatch:", result); + Assert.DoesNotContain("maxBatchesPerExecution:", result); + Assert.DoesNotContain("refreshNewestFirst:", result); + Assert.DoesNotContain("ifNotExists:", result); + } + + #endregion + + #region AddPolicy_NonDefaultValues_AreEmitted + + [Fact] + public void AddPolicy_NonDefaultValues_AreEmitted() + { + // Arrange + AddContinuousAggregatePolicyOperation op = new() + { + MaterializedViewName = "hourly", + BucketsPerBatch = 5, + MaxBatchesPerExecution = 10, + RefreshNewestFirst = false, + IfNotExists = true, + IncludeTieredData = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("bucketsPerBatch: 5", result); + Assert.Contains("maxBatchesPerExecution: 10", result); + Assert.Contains("refreshNewestFirst: false", result); + Assert.Contains("ifNotExists: true", result); + Assert.Contains("includeTieredData: true", result); + } + + #endregion + + #region RemovePolicy_IfExists_OnlyEmittedWhenTrue + + [Fact] + public void RemovePolicy_IfExists_OnlyEmittedWhenTrue() + { + // Arrange + RemoveContinuousAggregatePolicyOperation withFlag = new() + { + MaterializedViewName = "hourly", + IfExists = true, + }; + RemoveContinuousAggregatePolicyOperation withoutFlag = new() + { + MaterializedViewName = "hourly", + IfExists = false, + }; + + // Act + string withResult = Generate(withFlag); + string withoutResult = Generate(withoutFlag); + + // Assert + Assert.Contains("ifExists: true", withResult); + Assert.DoesNotContain("ifExists:", withoutResult); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs new file mode 100644 index 0000000..c572f97 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs @@ -0,0 +1,216 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by using a + /// real so literal values are asserted, not just structure. + /// + public class HypertableCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(CreateHypertableOperation operation) + { + IndentedStringBuilder builder = new(); + new HypertableCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterHypertableOperation operation) + { + IndentedStringBuilder builder = new(); + new HypertableCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region CreateHypertable_EmitsRequiredArgsAndOmitsEmptyAndFalse + + [Fact] + public void CreateHypertable_EmitsRequiredArgsAndOmitsEmptyAndFalse() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + Schema = string.Empty, + ChunkTimeInterval = string.Empty, + EnableCompression = false, + MigrateData = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("timeColumnName: \"ts\"", result); + Assert.DoesNotContain("schema:", result); + Assert.DoesNotContain("chunkTimeInterval:", result); + Assert.DoesNotContain("enableCompression:", result); + Assert.DoesNotContain("migrateData:", result); + Assert.EndsWith(")", result.TrimEnd()); + } + + #endregion + + #region CreateHypertable_OneNamedArgPerLine + + [Fact] + public void CreateHypertable_OneNamedArgPerLine() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + Schema = "public", + }; + + // Act + string result = Generate(op); + string[] lines = result.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray(); + + // Assert — each named argument appears on its own line. + Assert.Contains(lines, l => l.StartsWith("tableName: ")); + Assert.Contains(lines, l => l.StartsWith("timeColumnName: ")); + Assert.Contains(lines, l => l.StartsWith("schema: ")); + } + + #endregion + + #region CreateHypertable_StringListArgs_EmitCollectionExpression + + [Fact] + public void CreateHypertable_StringListArgs_EmitCollectionExpression() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + ChunkSkipColumns = ["a", "b"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("chunkSkipColumns: [\"a\", \"b\"]", result); + } + + #endregion + + #region CreateHypertable_RangeAndHashDimensions_FullyQualified + + [Fact] + public void CreateHypertable_RangeAndHashDimensions_FullyQualified() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + AdditionalDimensions = + [ + Dimension.CreateRange("range_col", "1 day"), + Dimension.CreateHash("hash_col", 4), + ], + }; + + // Act + string result = Generate(op); + + // Assert — fully qualified factory calls. + Assert.Contains("CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.Dimension.CreateRange(\"range_col\", \"1 day\")", result); + Assert.Contains("CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.Dimension.CreateHash(\"hash_col\", 4)", result); + // Multi-line list with trailing comma on all but last entry. + Assert.Contains("CreateRange(\"range_col\", \"1 day\"),", result); + } + + #endregion + + #region CreateHypertable_DimensionWithNullIntervalAndPartitions_DefaultsEmitted + + [Fact] + public void CreateHypertable_DimensionWithNullIntervalAndPartitions_DefaultsEmitted() + { + // Arrange — dimensions constructed directly so Interval/NumberOfPartitions stay null. + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + AdditionalDimensions = + [ + new Dimension { ColumnName = "range_col", Type = EDimensionType.Range, Interval = null }, + new Dimension { ColumnName = "hash_col", Type = EDimensionType.Hash, NumberOfPartitions = null }, + ], + }; + + // Act + string result = Generate(op); + + // Assert — null Interval renders as "" and null NumberOfPartitions renders as 0. + Assert.Contains("Dimension.CreateRange(\"range_col\", \"\")", result); + Assert.Contains("Dimension.CreateHash(\"hash_col\", 0)", result); + } + + #endregion + + #region AlterHypertable_EmitsOldArgsOnlyWhenNonDefault + + [Fact] + public void AlterHypertable_EmitsOldArgsOnlyWhenNonDefault() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + ChunkTimeInterval = "2 days", + EnableCompression = true, + OldChunkTimeInterval = "1 day", + OldEnableCompression = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("chunkTimeInterval: \"2 days\"", result); + Assert.Contains("enableCompression: true", result); + Assert.Contains("oldChunkTimeInterval: \"1 day\"", result); + Assert.Contains("oldEnableCompression: true", result); + } + + #endregion + + #region AlterHypertable_OmitsOldArgsWhenDefault + + [Fact] + public void AlterHypertable_OmitsOldArgsWhenDefault() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + OldChunkTimeInterval = string.Empty, + OldEnableCompression = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldChunkTimeInterval:", result); + Assert.DoesNotContain("oldEnableCompression:", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs b/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs new file mode 100644 index 0000000..9859fd7 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs @@ -0,0 +1,101 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests for the internal MigrationCallWriter behavior. The type is internal in the + /// Eftdb.Design assembly and that assembly does NOT expose InternalsVisibleTo to the + /// test project, so it is exercised indirectly through the public + /// (the simplest consumer). + /// + public class MigrationCallWriterTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string GenerateAddReorder(AddReorderPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ReorderPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region FirstArg_HasNoLeadingComma_LaterArgsCommaSeparatedOnNewLines + + [Fact] + public void FirstArg_HasNoLeadingComma_LaterArgsCommaSeparatedOnNewLines() + { + // Arrange + AddReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "ix_ts", + ScheduleInterval = "1 day", + }; + + // Act + string result = GenerateAddReorder(op); + string[] lines = result.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); + + // Assert — opens with ".AddReorderPolicy(". + Assert.Contains(lines, l => l.TrimEnd().EndsWith(".AddReorderPolicy(")); + + // First argument line (tableName) must not be preceded by a trailing comma. + string trimmed = result.Replace("\r", string.Empty); + int tableNameIndex = trimmed.IndexOf("tableName:"); + string beforeTableName = trimmed[..tableNameIndex]; + Assert.DoesNotContain(",", beforeTableName); + + // Subsequent argument lines end the previous line with a comma. + Assert.Contains(",\n", trimmed); + } + + #endregion + + #region Dispose_AppendsClosingParen + + [Fact] + public void Dispose_AppendsClosingParen() + { + // Arrange + AddReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "ix_ts", + }; + + // Act + string result = GenerateAddReorder(op); + + // Assert — the call is closed with a trailing ). + Assert.EndsWith(")", result.TrimEnd()); + } + + #endregion + + #region NamedArgFormat_NameColonSpaceValue + + [Fact] + public void NamedArgFormat_NameColonSpaceValue() + { + // Arrange + AddReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "ix_ts", + }; + + // Act + string result = GenerateAddReorder(op); + + // Assert — name followed by ": " then the value. + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("indexName: \"ix_ts\"", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs new file mode 100644 index 0000000..03228f9 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs @@ -0,0 +1,114 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by using a + /// real . + /// + public class ReorderPolicyCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(AddReorderPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ReorderPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterReorderPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ReorderPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(DropReorderPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new ReorderPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region AddReorderPolicy_EmitsRequiredArgsAndJobTuning + + [Fact] + public void AddReorderPolicy_EmitsRequiredArgsAndJobTuning() + { + // Arrange + AddReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "ix_ts", + ScheduleInterval = "1 day", + MaxRetries = 3, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("indexName: \"ix_ts\"", result); + Assert.Contains("scheduleInterval: \"1 day\"", result); + Assert.Contains("maxRetries: 3", result); + Assert.DoesNotContain("maxRuntime:", result); + Assert.DoesNotContain("retryPeriod:", result); + } + + #endregion + + #region AlterReorderPolicy_EmitsOldArgsOnlyWhenNonDefault + + [Fact] + public void AlterReorderPolicy_EmitsOldArgsOnlyWhenNonDefault() + { + // Arrange + AlterReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "new_ix", + OldIndexName = "old_ix", + OldScheduleInterval = "4 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("indexName: \"new_ix\"", result); + Assert.Contains("oldIndexName: \"old_ix\"", result); + Assert.Contains("oldScheduleInterval: \"4 days\"", result); + Assert.DoesNotContain("oldMaxRuntime:", result); + } + + #endregion + + #region DropReorderPolicy_OmitsEmptySchema + + [Fact] + public void DropReorderPolicy_OmitsEmptySchema() + { + // Arrange + DropReorderPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = string.Empty, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.DoesNotContain("schema:", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs new file mode 100644 index 0000000..13c5e81 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs @@ -0,0 +1,103 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by using a + /// real . + /// + public class RetentionPolicyCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(AddRetentionPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new RetentionPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterRetentionPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new RetentionPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + #region AddRetentionPolicy_EmitsDropAfter_OmitsNullDropCreatedBefore + + [Fact] + public void AddRetentionPolicy_EmitsDropAfter_OmitsNullDropCreatedBefore() + { + // Arrange + AddRetentionPolicyOperation op = new() + { + TableName = "sensor_data", + DropAfter = "30 days", + DropCreatedBefore = null, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("dropAfter: \"30 days\"", result); + Assert.DoesNotContain("dropCreatedBefore:", result); + } + + #endregion + + #region AddRetentionPolicy_EmitsDropCreatedBefore + + [Fact] + public void AddRetentionPolicy_EmitsDropCreatedBefore() + { + // Arrange + AddRetentionPolicyOperation op = new() + { + TableName = "sensor_data", + DropCreatedBefore = "60 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("dropCreatedBefore: \"60 days\"", result); + Assert.DoesNotContain("dropAfter:", result); + } + + #endregion + + #region AlterRetentionPolicy_EmitsOldArgsOnlyWhenNonDefault + + [Fact] + public void AlterRetentionPolicy_EmitsOldArgsOnlyWhenNonDefault() + { + // Arrange + AlterRetentionPolicyOperation op = new() + { + TableName = "sensor_data", + DropAfter = "60 days", + OldDropAfter = "30 days", + OldScheduleInterval = "4 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("dropAfter: \"60 days\"", result); + Assert.Contains("oldDropAfter: \"30 days\"", result); + Assert.Contains("oldScheduleInterval: \"4 days\"", result); + Assert.DoesNotContain("oldMaxRuntime:", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs b/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs new file mode 100644 index 0000000..920bccc --- /dev/null +++ b/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs @@ -0,0 +1,565 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregatePolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +/// +/// Differ-level tests that hand a feature differ an explicit carrying a +/// rename or a recreated-aggregate signal, and assert the differ treats it as a rename/cascade rather than +/// emitting drop+create operations. Models are built in-memory; no database is touched. +/// +public class FeatureDifferContextTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region HypertableDiffer_Resolves_Table_Rename + + private class HtRenameMetricSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HtRenameMetricTarget + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HtRenameSourceContext : 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("ht_rename_old"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class HtRenameTargetContext : 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("ht_rename_new"); // <-- Renamed from ht_rename_old + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void HypertableDiffer_Treats_Table_Rename_As_Rename_Not_Recreate() + { + // Arrange + using HtRenameSourceContext sourceContext = new(); + using HtRenameTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [(DefaultValues.DefaultSchema, "ht_rename_old")] = (DefaultValues.DefaultSchema, "ht_rename_new"), + }, + }; + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert - a pure rename should produce no hypertable operations at all + Assert.DoesNotContain(operations, op => op is CreateHypertableOperation); + Assert.DoesNotContain(operations, op => op is AlterHypertableOperation); + } + + #endregion + + #region HypertableDiffer_Rewrites_CompressionOrderBy_Column_Rename + + private class HtOrderByMetricSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HtOrderByMetricTarget + { + public DateTime Moment { get; set; } // <-- Renamed from Timestamp + public double Value { get; set; } + } + + private class HtOrderBySourceContext : 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("ht_orderby_rename"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(b => [b.ByDescending(x => x.Timestamp)]); + }); + } + } + + private class HtOrderByTargetContext : 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("ht_orderby_rename"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Moment) + .WithCompressionOrderBy(b => [b.ByDescending(x => x.Moment)]); + }); + } + } + + [Fact] + public void HypertableDiffer_Rewrites_OrderBy_Column_On_Rename_And_Preserves_Direction() + { + // The time column AND the compression order-by reference the same renamed column. With a column-rename + // context, the source order-by ("Timestamp DESC") rewrites to ("Moment DESC") and compares equal to the + // target, so no AlterHypertableOperation is emitted. + + // Arrange + using HtOrderBySourceContext sourceContext = new(); + using HtOrderByTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + ColumnRenames = new Dictionary<(string, string, string), string> + { + // Keyed on the post-rename table name (table name unchanged here). + [(DefaultValues.DefaultSchema, "ht_orderby_rename", "Timestamp")] = "Moment", + }, + }; + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert - the order-by column rename is resolved (direction suffix preserved), so no alter is produced. + Assert.DoesNotContain(operations, op => op is AlterHypertableOperation); + Assert.DoesNotContain(operations, op => op is CreateHypertableOperation); + } + + #endregion + + #region ReorderPolicyDiffer_Resolves_Index_Rename + + private class RpRenameMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RpRenameSourceContext : 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("rp_rename_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("rp_idx_old"); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("rp_idx_old"); + }); + } + } + + private class RpRenameTargetContext : 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("rp_rename_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("rp_idx_new"); // <-- Index renamed from rp_idx_old + entity.HasIndex(x => x.Timestamp).HasDatabaseName("rp_idx_new"); + }); + } + } + + [Fact] + public void ReorderPolicyDiffer_Treats_Index_Rename_As_Rename_Not_Alter() + { + // Arrange + using RpRenameSourceContext sourceContext = new(); + using RpRenameTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + IndexRenames = new Dictionary<(string, string), (string, string)> + { + [(DefaultValues.DefaultSchema, "rp_idx_old")] = (DefaultValues.DefaultSchema, "rp_idx_new"), + }, + }; + + ReorderPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert - only the index changed, and that change is a known rename, so no alter is needed. + Assert.DoesNotContain(operations, op => op is AlterReorderPolicyOperation); + Assert.DoesNotContain(operations, op => op is AddReorderPolicyOperation); + Assert.DoesNotContain(operations, op => op is DropReorderPolicyOperation); + } + + #endregion + + #region RetentionPolicyDiffer_Recreate_Cascade + + private class RetCascadeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetCascadeAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class RetCascadeContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("ret_cascade_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "ret_cascade_view", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + [Fact] + public void RetentionPolicyDiffer_Readds_Policy_When_Aggregate_Is_Recreated() + { + // Source and target are identical, so a normal diff would produce nothing. But the orchestrator has + // decided to recreate the "ret_cascade_view" aggregate, which cascades to drop its retention policy. + // The differ must therefore re-add the retention policy. + + // Arrange + using RetCascadeContext sourceContext = new(); + using RetCascadeContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + RecreatedAggregates = new HashSet<(string, string)> + { + (DefaultValues.DefaultSchema, "ret_cascade_view"), + }, + }; + + RetentionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + AddRetentionPolicyOperation? addOp = operations.OfType() + .FirstOrDefault(op => op.TableName == "ret_cascade_view"); + Assert.NotNull(addOp); + Assert.Equal("30 days", addOp.DropAfter); + + // It must NOT also drop the policy for the recreated view. + Assert.DoesNotContain(operations, op => op is DropRetentionPolicyOperation); + } + + #endregion + + #region ContinuousAggregatePolicyDiffer_Recreate_Cascade + + private class CapCascadeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CapCascadeAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CapCascadeContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cap_cascade_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cap_cascade_view", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + }); + } + } + + [Fact] + public void ContinuousAggregatePolicyDiffer_Readds_Refresh_Policy_When_Aggregate_Is_Recreated() + { + // Identical source/target: a normal diff yields nothing. With the view marked as recreated, the refresh + // policy is dropped by the recreate cascade and must be re-added without a matching remove. + + // Arrange + using CapCascadeContext sourceContext = new(); + using CapCascadeContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + RecreatedAggregates = new HashSet<(string, string)> + { + (DefaultValues.DefaultSchema, "cap_cascade_view"), + }, + }; + + ContinuousAggregatePolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + AddContinuousAggregatePolicyOperation? addOp = operations.OfType() + .FirstOrDefault(op => op.MaterializedViewName == "cap_cascade_view"); + Assert.NotNull(addOp); + + // No remove for the recreated view. + Assert.DoesNotContain(operations, op => + op is RemoveContinuousAggregatePolicyOperation remove && remove.MaterializedViewName == "cap_cascade_view"); + } + + #endregion + + #region ContinuousAggregateDiffer_Resolves_Parent_Table_Rename + + private class CaParentRenameSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaParentRenameTargetMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaParentRenameAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaParentRenameSourceContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("ca_parent_old"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "ca_parent_rename_view", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class CaParentRenameTargetContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("ca_parent_new"); // <-- Parent table renamed from ca_parent_old + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "ca_parent_rename_view", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void ContinuousAggregateDiffer_Treats_Parent_Table_Rename_As_Rename_Not_Recreate() + { + // Only the CA's parent hypertable was renamed. With the rename in the context, the differ should NOT + // drop and recreate the aggregate. (CA column/where-clause rewriting is explicitly out of scope; only + // the parent-table rename is asserted here.) + + // Arrange + using CaParentRenameSourceContext sourceContext = new(); + using CaParentRenameTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [(DefaultValues.DefaultSchema, "ca_parent_old")] = (DefaultValues.DefaultSchema, "ca_parent_new"), + }, + }; + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index e58c041..9f90105 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -1411,7 +1411,7 @@ public void Should_Detect_Change_When_SegmentBy_Order_Different() AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); - // Assert: A diff SHOULD be generated because SequenceEqual checks order + // Assert Assert.NotNull(alterOp); Assert.Equal("TenantId", alterOp.OldCompressionSegmentBy![0]); Assert.Equal("DeviceId", alterOp.OldCompressionSegmentBy![1]); diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs index bb1b276..4eab68b 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs @@ -723,8 +723,7 @@ public void Should_Emit_Policy_When_ParentName_Annotation_Is_Missing() List operations = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)]; - // Assert: policy is still produced; schema falls through to the view's own schema - // because the parent-lookup branch is short-circuited by the IsNullOrWhiteSpace guard. + // Assert AddContinuousAggregatePolicyOperation op = Assert.Single(operations); Assert.Equal("agg_view", op.MaterializedViewName); Assert.Equal("custom_schema", op.Schema); @@ -792,7 +791,7 @@ public void Should_Emit_Policy_When_ParentName_Does_Not_Match_Any_Entity() List operations = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)]; - // Assert: parent lookup yields null, schema falls through to the view's own schema. + // Assert AddContinuousAggregatePolicyOperation op = Assert.Single(operations); Assert.Equal("custom_schema", op.Schema); } @@ -853,7 +852,7 @@ public void Should_Emit_Policy_With_Null_Offsets_And_ScheduleInterval() List operations = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)]; - // Assert: optional fields stay null when their annotations are absent. + // Assert AddContinuousAggregatePolicyOperation op = Assert.Single(operations); Assert.Null(op.StartOffset); Assert.Null(op.EndOffset); @@ -921,8 +920,7 @@ public void Should_Use_DefaultSchema_When_No_Schema_Sources_Available() List operations = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)]; - // Assert: with no view schema, no entity schema, and no parent schema, - // resolution falls through to DefaultValues.DefaultSchema ("public"). + // Assert AddContinuousAggregatePolicyOperation op = Assert.Single(operations); Assert.Equal("public", op.Schema); } diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index 440a24c..f42c89c 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -12,24 +12,13 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators public class ContinuousAggregateOperationGeneratorTests { /// - /// Helper to run the generator and capture design-time C# code output. + /// Helper to run the generator and capture the SQL output. /// - private static string GetDesignTimeCode(dynamic operation) - { - IndentedStringBuilder builder = new(); - ContinuousAggregateOperationGenerator generator = new(isDesignTime: true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); - } + private static string GetDesignTimeCode(dynamic operation) => GetRuntimeSql(operation); - /// - /// Helper to run the generator and capture runtime SQL output. - /// private static string GetRuntimeSql(dynamic operation) { - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); - List statements = generator.Generate(operation); + List statements = ContinuousAggregateSqlGenerator.Generate(operation); return string.Join("\n", statements); } @@ -54,13 +43,13 @@ public void DesignTime_Create_MinimalAggregate_GeneratesCorrectCSharpCode() WithNoData = false }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"public"""".""""hourly_metrics"""" + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""hourly_metrics"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', """"timestamp"""") AS time_bucket, AVG(""""value"""") AS """"avg_value"""" - FROM """"public"""".""""metrics"""" + SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""value"") AS ""avg_value"" + FROM ""public"".""metrics"" GROUP BY time_bucket; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -95,14 +84,14 @@ public void DesignTime_Create_WithAllStandardAggregates_GeneratesCorrectCode() WithNoData = true }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"analytics"""".""""daily_stats"""" + string expected = @" + CREATE MATERIALIZED VIEW ""analytics"".""daily_stats"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = true, timescaledb.materialized_only = true) AS - SELECT time_bucket('1 day', """"time"""") AS time_bucket, AVG(""""temperature"""") AS """"avg_temp"""", MAX(""""temperature"""") AS """"max_temp"""", MIN(""""temperature"""") AS """"min_temp"""", COUNT(""""id"""") AS """"total_readings"""", SUM(""""voltage"""") AS """"sum_voltage"""" - FROM """"analytics"""".""""sensor_data"""" + SELECT time_bucket('1 day', ""time"") AS time_bucket, AVG(""temperature"") AS ""avg_temp"", MAX(""temperature"") AS ""max_temp"", MIN(""temperature"") AS ""min_temp"", COUNT(""id"") AS ""total_readings"", SUM(""voltage"") AS ""sum_voltage"" + FROM ""analytics"".""sensor_data"" GROUP BY time_bucket WITH NO DATA; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -134,13 +123,13 @@ public void DesignTime_Create_WithTimescaleDBFirstLastFunctions_GeneratesCorrect WithNoData = false }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"public"""".""""price_aggregates"""" + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""price_aggregates"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('5 minutes', """"timestamp"""") AS time_bucket, first(""""price"""", """"timestamp"""") AS """"first_price"""", last(""""price"""", """"timestamp"""") AS """"last_price"""" - FROM """"public"""".""""trades"""" + SELECT time_bucket('5 minutes', ""timestamp"") AS time_bucket, first(""price"", ""timestamp"") AS ""first_price"", last(""price"", ""timestamp"") AS ""last_price"" + FROM ""public"".""trades"" GROUP BY time_bucket; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -168,13 +157,13 @@ public void DesignTime_Create_WithGroupByColumns_GeneratesCorrectGrouping() WithNoData = false }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"public"""".""""sales_by_region"""" + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""sales_by_region"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', """"sale_time"""") AS time_bucket, """"region"""", """"store_id"""", SUM(""""amount"""") AS """"total_amount"""" - FROM """"public"""".""""sales"""" - GROUP BY time_bucket, """"region"""", """"store_id""""; - "")"; + SELECT time_bucket('1 hour', ""sale_time"") AS time_bucket, ""region"", ""store_id"", SUM(""amount"") AS ""total_amount"" + FROM ""public"".""sales"" + GROUP BY time_bucket, ""region"", ""store_id""; + "; // Act string result = GetDesignTimeCode(operation); @@ -203,14 +192,14 @@ public void DesignTime_Create_WithWhereClause_GeneratesCorrectFiltering() WithNoData = false }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"public"""".""""high_value_trades"""" + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""high_value_trades"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', """"timestamp"""") AS time_bucket, AVG(""""price"""") AS """"avg_price"""" - FROM """"public"""".""""trades"""" - WHERE """"price"""" > 100 AND """"volume"""" > 1000 + SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""price"") AS ""avg_price"" + FROM ""public"".""trades"" + WHERE ""price"" > 100 AND ""volume"" > 1000 GROUP BY time_bucket; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -239,13 +228,13 @@ public void DesignTime_Create_WithChunkInterval_GeneratesCorrectOption() WithNoData = false }; - string expected = @".Sql(@"" - CREATE MATERIALIZED VIEW """"public"""".""""monthly_summary"""" + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""monthly_summary"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false, timescaledb.chunk_interval = '7 days') AS - SELECT time_bucket('1 month', """"event_time"""") AS time_bucket, COUNT(""""id"""") AS """"event_count"""" - FROM """"public"""".""""events"""" + SELECT time_bucket('1 month', ""event_time"") AS time_bucket, COUNT(""id"") AS ""event_count"" + FROM ""public"".""events"" GROUP BY time_bucket; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -389,9 +378,9 @@ public void DesignTime_Alter_ChunkInterval_GeneratesCorrectCode() OldChunkInterval = "7 days" }; - string expected = @".Sql(@"" - ALTER MATERIALIZED VIEW """"public"""".""""hourly_stats"""" SET (timescaledb.chunk_interval = '30 days'); - "")"; + string expected = @" + ALTER MATERIALIZED VIEW ""public"".""hourly_stats"" SET (timescaledb.chunk_interval = '30 days'); + "; // Act string result = GetDesignTimeCode(operation); @@ -432,9 +421,9 @@ public void DesignTime_Alter_CreateGroupIndexes_GeneratesCorrectCode() OldCreateGroupIndexes = false }; - string expected = @".Sql(@"" - ALTER MATERIALIZED VIEW """"public"""".""""metrics_view"""" SET (timescaledb.create_group_indexes = true); - "")"; + string expected = @" + ALTER MATERIALIZED VIEW ""public"".""metrics_view"" SET (timescaledb.create_group_indexes = true); + "; // Act string result = GetDesignTimeCode(operation); @@ -455,9 +444,9 @@ public void DesignTime_Alter_MaterializedOnly_GeneratesCorrectCode() OldMaterializedOnly = true }; - string expected = @".Sql(@"" - ALTER MATERIALIZED VIEW """"public"""".""""stats_view"""" SET (timescaledb.materialized_only = false); - "")"; + string expected = @" + ALTER MATERIALIZED VIEW ""public"".""stats_view"" SET (timescaledb.materialized_only = false); + "; // Act string result = GetDesignTimeCode(operation); @@ -528,9 +517,9 @@ public void DesignTime_Drop_GeneratesCorrectCode() Schema = "public" }; - string expected = @".Sql(@"" - DROP MATERIALIZED VIEW IF EXISTS """"public"""".""""old_aggregate""""; - "")"; + string expected = @" + DROP MATERIALIZED VIEW IF EXISTS ""public"".""old_aggregate""; + "; // Act string result = GetDesignTimeCode(operation); @@ -875,11 +864,10 @@ public void Create_WithUnsupportedAggregateFunction_ThrowsNotSupportedException( WithNoData = false }; - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); // Act & Assert NotSupportedException ex = Assert.Throws(() => - generator.Generate(operation)); + ContinuousAggregateSqlGenerator.Generate(operation)); Assert.Contains("Percentile95", ex.Message); Assert.Contains("not supported", ex.Message); } @@ -903,11 +891,10 @@ public void Create_WithInvalidAggregateEnum_ThrowsNotSupportedException() WithNoData = false }; - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); // Act & Assert NotSupportedException ex = Assert.Throws(() => - generator.Generate(operation)); + ContinuousAggregateSqlGenerator.Generate(operation)); Assert.Contains("InvalidFunction", ex.Message); } @@ -1049,42 +1036,12 @@ public void Alter_OnlyMaterializedOnlyChanged_GeneratesSingleStatement() #endregion - #region Design-Time vs Runtime Quote Handling - - [Fact] - public void DesignTime_UsesDoubleQuotesForEscaping() - { - // Arrange - ContinuousAggregateOperationGenerator generator = new(isDesignTime: true); - - CreateContinuousAggregateOperation operation = new() - { - MaterializedViewName = "test_view", - Schema = "public", - ParentName = "test_table", - TimeBucketWidth = "1 hour", - TimeBucketSourceColumn = "time", - TimeBucketGroupBy = true, - AggregateFunctions = ["cnt:Count:id"], - GroupByColumns = [], - CreateGroupIndexes = false, - MaterializedOnly = false, - WithNoData = false - }; - - List statements = generator.Generate(operation); - string result = string.Join("\n", statements); - - // Assert - Design-time should use double quotes for escaping - Assert.Contains("\"\"public\"\"", result); - Assert.Contains("\"\"test_view\"\"", result); - } + #region Quote Handling [Fact] public void Runtime_UsesSingleQuotesForEscaping() { // Arrange - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); CreateContinuousAggregateOperation operation = new() { @@ -1101,7 +1058,7 @@ public void Runtime_UsesSingleQuotesForEscaping() WithNoData = false }; - List statements = generator.Generate(operation); + List statements = ContinuousAggregateSqlGenerator.Generate(operation); string result = string.Join("\n", statements); // Assert - Runtime should use single quotes (standard SQL quoting) @@ -1111,33 +1068,6 @@ public void Runtime_UsesSingleQuotesForEscaping() Assert.DoesNotContain("\"\"public\"\"", result); } - [Fact] - public void DesignTime_WhereClause_ConvertsSingleToDoubleQuotes() - { - // Arrange - CreateContinuousAggregateOperation operation = new() - { - MaterializedViewName = "quote_test", - Schema = "public", - ParentName = "data", - TimeBucketWidth = "1 hour", - TimeBucketSourceColumn = "time", - TimeBucketGroupBy = true, - AggregateFunctions = ["avg:Avg:value"], - GroupByColumns = [], - WhereClause = "\"status\" = 'active'", - CreateGroupIndexes = false, - MaterializedOnly = false, - WithNoData = false - }; - - // Act - string result = GetDesignTimeCode(operation); - - // Assert - Design time should double the quotes in WHERE clause - Assert.Contains("\"\"status\"\" = 'active'", result); - } - #endregion #region TimescaleDB Constraint Validation Tests @@ -1282,8 +1212,7 @@ public void Runtime_Create_WithViewDefinition_GeneratesRawCreateMaterializedView }; // Act - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); - List statements = generator.Generate(operation); + List statements = ContinuousAggregateSqlGenerator.Generate(operation); // Assert string sql = Assert.Single(statements); @@ -1313,8 +1242,7 @@ public void Runtime_Create_WithViewDefinition_AndWithNoData_AppendsWithNoDataBef }; // Act - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); - List statements = generator.Generate(operation); + List statements = ContinuousAggregateSqlGenerator.Generate(operation); // Assert string sql = Assert.Single(statements); @@ -1323,31 +1251,6 @@ public void Runtime_Create_WithViewDefinition_AndWithNoData_AppendsWithNoDataBef Assert.EndsWith("WITH NO DATA;", sql); } - [Fact] - public void DesignTime_Create_WithViewDefinition_DoublesEmbeddedQuotes() - { - // Arrange - CreateContinuousAggregateOperation operation = new() - { - MaterializedViewName = "hourly_metrics", - Schema = "public", - ParentName = "metrics", - ViewDefinition = "SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\" GROUP BY bucket;" - }; - - // Act - design-time mode escapes embedded `"` to `""` for the C# verbatim string literal - ContinuousAggregateOperationGenerator generator = new(isDesignTime: true); - List statements = generator.Generate(operation); - - // Assert - string sql = Assert.Single(statements); - Assert.Contains("CREATE MATERIALIZED VIEW \"\"public\"\".\"\"hourly_metrics\"\"", sql); - Assert.Contains("\"\"time\"\"", sql); - Assert.Contains("\"\"src\"\"", sql); - // No raw single `"` should appear in the body — all should be doubled - Assert.DoesNotContain("\"time\"", sql.Replace("\"\"time\"\"", "")); - } - [Fact] public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields() { @@ -1368,8 +1271,7 @@ public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields() }; // Act - ContinuousAggregateOperationGenerator generator = new(isDesignTime: false); - List statements = generator.Generate(operation); + List statements = ContinuousAggregateSqlGenerator.Generate(operation); // Assert - raw body is present, structured fields are not string sql = Assert.Single(statements); diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs index bffb633..878348d 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs @@ -1,34 +1,23 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; -using Microsoft.EntityFrameworkCore.Infrastructure; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators; public class ContinuousAggregatePolicyOperationGeneratorTests { /// - /// Helper to run the generator and capture its string output for design-time (migration code generation). + /// Helper to run the generator and capture the SQL output. /// - private static string GetGeneratedCode(dynamic operation) - { - IndentedStringBuilder builder = new(); - ContinuousAggregatePolicyOperationGenerator generator = new(true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); - } + private static string GetGeneratedCode(dynamic operation) => GetRuntimeSql(operation); /// /// Helper to run the generator for runtime SQL execution. /// private static string GetRuntimeSql(dynamic operation) { - IndentedStringBuilder builder = new(); - ContinuousAggregatePolicyOperationGenerator generator = new(false); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); + List statements = ContinuousAggregatePolicySqlGenerator.Generate(operation); + return string.Join("\n", statements); } [Fact] @@ -51,9 +40,9 @@ public void Generate_Add_With_All_Parameters() RefreshNewestFirst = false }; - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('public.""""hourly_metrics""""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour', if_not_exists => true, include_tiered_data => true, buckets_per_batch => 5, max_batches_per_execution => 10, refresh_newest_first => false, initial_start => '2025-12-15T03:00:00.0000000Z'); - "")"; + string expected = @" + SELECT add_continuous_aggregate_policy('public.""hourly_metrics""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour', if_not_exists => true, include_tiered_data => true, buckets_per_batch => 5, max_batches_per_execution => 10, refresh_newest_first => false, initial_start => '2025-12-15T03:00:00.0000000Z'); + "; // Act string result = GetGeneratedCode(operation); @@ -74,9 +63,9 @@ public void Generate_Add_With_Minimal_Parameters() EndOffset = "1 hour" }; - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('public.""""hourly_metrics""""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); - "")"; + string expected = @" + SELECT add_continuous_aggregate_policy('public.""hourly_metrics""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); + "; // Act string result = GetGeneratedCode(operation); @@ -98,9 +87,9 @@ public void Generate_Add_With_Null_Offsets() ScheduleInterval = "1 hour" }; - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('public.""""hourly_metrics""""', start_offset => NULL, end_offset => NULL, schedule_interval => INTERVAL '1 hour'); - "")"; + string expected = @" + SELECT add_continuous_aggregate_policy('public.""hourly_metrics""', start_offset => NULL, end_offset => NULL, schedule_interval => INTERVAL '1 hour'); + "; // Act string result = GetGeneratedCode(operation); @@ -122,9 +111,9 @@ public void Generate_Add_With_Integer_Offsets() ScheduleInterval = "1 hour" }; - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('public.""""sensor_data_hourly""""', start_offset => 100000, end_offset => 1000, schedule_interval => INTERVAL '1 hour'); - "")"; + string expected = @" + SELECT add_continuous_aggregate_policy('public.""sensor_data_hourly""', start_offset => 100000, end_offset => 1000, schedule_interval => INTERVAL '1 hour'); + "; // Act string result = GetGeneratedCode(operation); @@ -144,9 +133,9 @@ public void Generate_Remove_Policy() IfExists = false }; - string expected = @".Sql(@"" - SELECT remove_continuous_aggregate_policy('public.""""hourly_metrics""""'); - "")"; + string expected = @" + SELECT remove_continuous_aggregate_policy('public.""hourly_metrics""'); + "; // Act string result = GetGeneratedCode(operation); @@ -166,9 +155,9 @@ public void Generate_Remove_Policy_With_IfExists() IfExists = true }; - string expected = @".Sql(@"" - SELECT remove_continuous_aggregate_policy('public.""""hourly_metrics""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_continuous_aggregate_policy('public.""hourly_metrics""', if_exists => true); + "; // Act string result = GetGeneratedCode(operation); @@ -189,9 +178,9 @@ public void Use_Correct_Quotes_For_Runtime() EndOffset = "1 hour" }; - string expected = @".Sql(@"" + string expected = @" SELECT add_continuous_aggregate_policy('public.""hourly_metrics""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); - "")"; + "; // Act string result = GetRuntimeSql(operation); @@ -200,29 +189,6 @@ public void Use_Correct_Quotes_For_Runtime() Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } - [Fact] - public void Use_Correct_Quotes_For_DesignTime() - { - // Arrange - AddContinuousAggregatePolicyOperation operation = new() - { - Schema = "public", - MaterializedViewName = "hourly_metrics", - StartOffset = "1 month", - EndOffset = "1 hour" - }; - - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('public.""""hourly_metrics""""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); - "")"; - - // Act - string result = GetGeneratedCode(operation); - - // Assert - Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); - } - [Fact] public void Include_Schema_In_Regclass() { @@ -235,9 +201,9 @@ public void Include_Schema_In_Regclass() EndOffset = "1 hour" }; - string expected = @".Sql(@"" - SELECT add_continuous_aggregate_policy('analytics.""""hourly_metrics""""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); - "")"; + string expected = @" + SELECT add_continuous_aggregate_policy('analytics.""hourly_metrics""', start_offset => INTERVAL '1 month', end_offset => INTERVAL '1 hour'); + "; // Act string result = GetGeneratedCode(operation); diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs similarity index 89% rename from tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs rename to tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs index e17633e..9601050 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs @@ -2,13 +2,12 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; -using Microsoft.EntityFrameworkCore.Infrastructure; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { /// - /// Comprehensive tests for HypertableOperationGenerator validating design-time and runtime - /// SQL generation according to TimescaleDB requirements. + /// Comprehensive tests for HypertableSqlGenerator validating SQL generation + /// according to TimescaleDB requirements. /// /// TimescaleDB Requirements (researched from official docs): /// - create_hypertable(relation, by_range/by_hash) - modern API (v2.13+) @@ -18,27 +17,16 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators /// - add_dimension() uses by_hash(column, partitions) or by_range(column, interval) /// - Dimensions can only be added to empty hypertables (in practice, add during creation) /// - public class HypertableOperationGeneratorComprehensiveTests + public class HypertableSqlGeneratorComprehensiveTests { /// - /// Helper to run the generator and capture design-time C# code output. + /// Helper to run the generator and capture the SQL output. /// - private static string GetDesignTimeCode(dynamic operation) - { - IndentedStringBuilder builder = new(); - HypertableOperationGenerator generator = new(isDesignTime: true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); - } + private static string GetDesignTimeCode(dynamic operation) => GetRuntimeSql(operation); - /// - /// Helper to run the generator and capture runtime SQL output. - /// private static string GetRuntimeSql(dynamic operation) { - HypertableOperationGenerator generator = new(isDesignTime: false); - List statements = generator.Generate(operation); + List statements = HypertableSqlGenerator.Generate(operation); return string.Join("\n", statements); } @@ -60,10 +48,10 @@ public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() ] }; - string expected = @".Sql(@"" - 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')); - "")"; + 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')); + "; // Act string result = GetDesignTimeCode(operation); @@ -88,11 +76,11 @@ public void DesignTime_Create_WithMultipleDimensions_GeneratesCorrectOrder() ] }; - string expected = @".Sql(@"" - 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')); - "")"; + 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')); + "; // Act string result = GetDesignTimeCode(operation); @@ -113,9 +101,9 @@ public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorre ChunkTimeInterval = "86400000000" // 1 day in microseconds }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""high_freq_data""""', 'ts', chunk_time_interval => 86400000000::bigint); - "")"; + string expected = @" + SELECT create_hypertable('public.""high_freq_data""', 'ts', chunk_time_interval => 86400000000::bigint); + "; // Act string result = GetDesignTimeCode(operation); @@ -136,8 +124,8 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo EnableCompression = true }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""compressed_data""""', 'time'); + string expected = @" + SELECT create_hypertable('public.""compressed_data""', 'time'); DO $$ DECLARE license TEXT; @@ -145,12 +133,12 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.compress = true)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -346,10 +334,10 @@ public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrec ] }; - string expected = @".Sql(@"" - SELECT create_hypertable('analytics.""""integer_partitions""""', 'timestamp'); - SELECT add_dimension('analytics.""""integer_partitions""""', by_range('partition_key', 5000)); - "")"; + string expected = @" + SELECT create_hypertable('analytics.""integer_partitions""', 'timestamp'); + SELECT add_dimension('analytics.""integer_partitions""', by_range('partition_key', 5000)); + "; // Act string result = GetDesignTimeCode(operation); @@ -374,8 +362,8 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() CompressionSegmentBy = ["tenant_id", "device_id"] }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""segmented_data""""', 'time'); + string expected = @" + SELECT create_hypertable('public.""segmented_data""', 'time'); DO $$ DECLARE license TEXT; @@ -383,12 +371,12 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""segmented_data"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""""tenant_id"""", """"device_id""""'')'; + EXECUTE 'ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""tenant_id"", ""device_id""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -409,8 +397,8 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"] }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""ordered_data""""', 'time'); + string expected = @" + SELECT create_hypertable('public.""ordered_data""', 'time'); DO $$ DECLARE license TEXT; @@ -418,12 +406,12 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""ordered_data"""" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''""""time"""" DESC, """"value"""" ASC NULLS LAST'')'; + EXECUTE 'ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -473,9 +461,9 @@ public void DesignTime_Alter_ChangingChunkInterval_FromStringToString_GeneratesC OldChunkTimeInterval = "7 days" }; - string expected = @".Sql(@"" - SELECT set_chunk_time_interval('public.""""metrics""""', INTERVAL '1 day'); - "")"; + string expected = @" + SELECT set_chunk_time_interval('public.""metrics""', INTERVAL '1 day'); + "; // Act string result = GetDesignTimeCode(operation); @@ -496,9 +484,9 @@ public void DesignTime_Alter_ChangingChunkInterval_FromStringToNumeric_Generates OldChunkTimeInterval = "1 day" // String interval }; - string expected = @".Sql(@"" - SELECT set_chunk_time_interval('public.""""metrics""""', 86400000000::bigint); - "")"; + string expected = @" + SELECT set_chunk_time_interval('public.""metrics""', 86400000000::bigint); + "; // Act string result = GetDesignTimeCode(operation); @@ -522,9 +510,9 @@ public void DesignTime_Alter_AddingDimension_GeneratesCorrectCode() OldAdditionalDimensions = [] }; - string expected = @".Sql(@"" - SELECT add_dimension('public.""""expandable""""', by_hash('user_id', 4)); - "")"; + string expected = @" + SELECT add_dimension('public.""expandable""', by_hash('user_id', 4)); + "; // Act string result = GetDesignTimeCode(operation); @@ -557,6 +545,30 @@ public void DesignTime_Alter_RemovingDimension_GeneratesWarningComment() Assert.Contains("old_column", result); } + [Fact] + public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() + { + // Arrange - removing a dimension is unsupported; the generator emits a SQL comment + AlterHypertableOperation operation = new() + { + TableName = "cannot_remove", + Schema = "public", + AdditionalDimensions = [], + OldAdditionalDimensions = + [ + Dimension.CreateHash("old_column", 4) + ] + }; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert - the comment must start with the "-- WARNING:" prefix and list the dimension + Assert.Contains( + "-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: 'old_column'", + result); + } + [Fact] public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() { @@ -594,7 +606,7 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() OldEnableCompression = true }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -602,12 +614,12 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false)'; + EXECUTE 'ALTER TABLE ""public"".""decompress"" SET (timescaledb.compress = false)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -628,7 +640,7 @@ public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() OldChunkSkipColumns = ["col1"] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -637,13 +649,13 @@ public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() IF license IS NULL OR license != 'apache' THEN EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''public.""""add_skip""""'', ''col2'')'; - EXECUTE 'SELECT enable_chunk_skipping(''public.""""add_skip""""'', ''col3'')'; + EXECUTE 'SELECT enable_chunk_skipping(''public.""add_skip""'', ''col2'')'; + EXECUTE 'SELECT enable_chunk_skipping(''public.""add_skip""'', ''col3'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -664,7 +676,7 @@ public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() OldChunkSkipColumns = ["keep_this", "remove_this"] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -672,12 +684,12 @@ public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'SELECT disable_chunk_skipping(''public.""""remove_skip""""'', ''remove_this'')'; + EXECUTE 'SELECT disable_chunk_skipping(''public.""remove_skip""'', ''remove_this'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -827,9 +839,9 @@ public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesC OldAdditionalDimensions = [] }; - string expected = @".Sql(@"" - SELECT add_dimension('analytics.""""metrics""""', by_range('metric_id', 50000)); - "")"; + string expected = @" + SELECT add_dimension('analytics.""metrics""', by_range('metric_id', 50000)); + "; // Act string result = GetDesignTimeCode(operation); @@ -854,7 +866,7 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() OldCompressionSegmentBy = [] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -862,12 +874,12 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""""device_id""""'')'; + EXECUTE 'ALTER TABLE ""public"".""metrics"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""device_id""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetDesignTimeCode(operation); diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs similarity index 81% rename from tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs rename to tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs index 07d30e7..bb1d4ac 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs @@ -1,24 +1,19 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using Microsoft.EntityFrameworkCore.Infrastructure; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { - public class HypertableOperationGeneratorTests + public class HypertableSqlGeneratorTests { /// /// A helper to run the generator and capture its string output. /// private static string GetGeneratedCode(dynamic operation) { - IndentedStringBuilder builder = new(); - - HypertableOperationGenerator generator = new(true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); + List statements = HypertableSqlGenerator.Generate(operation); + return string.Join("\n", statements); } // --- Tests for CreateHypertableOperation --- @@ -34,9 +29,9 @@ public void Generate_Create_with_minimal_details_generates_correct_sql() TimeColumnName = "Timestamp" }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""MinimalTable""""', 'Timestamp'); - "")"; + string expected = @" + SELECT create_hypertable('public.""MinimalTable""', 'Timestamp'); + "; // Act string result = GetGeneratedCode(operation); @@ -63,9 +58,9 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() ] }; - string expected = @".Sql(@"" - SELECT create_hypertable('custom_schema.""""FullTable""""', 'EventTime', chunk_time_interval => INTERVAL '1 day'); - SELECT add_dimension('custom_schema.""""FullTable""""', by_hash('LocationId', 4)); + 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)); DO $$ DECLARE license TEXT; @@ -73,14 +68,14 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"custom_schema"""".""""FullTable"""" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.compress = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""FullTable""""'', ''DeviceId'')'; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""FullTable""'', ''DeviceId'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -103,7 +98,7 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso ChunkSkipColumns = ["device_id"] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -111,14 +106,14 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"custom_schema"""".""""Metrics"""" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""Metrics"" SET (timescaledb.compress = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""Metrics""""'', ''device_id'')'; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""Metrics""'', ''device_id'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -141,7 +136,7 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() OldEnableCompression = false }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -149,12 +144,12 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""SensorData"""" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""public"".""SensorData"" SET (timescaledb.compress = true)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -178,8 +173,8 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre }; // Expected: implicit compress=true, plus segmentby/orderby strings - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""CompressedTable""""', 'Timestamp'); + string expected = @" + SELECT create_hypertable('public.""CompressedTable""', 'Timestamp'); DO $$ DECLARE license TEXT; @@ -187,12 +182,12 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""CompressedTable"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""""TenantId"""", """"DeviceId""""'', timescaledb.compress_orderby = ''""""Timestamp"""" DESC, """"Value"""" ASC NULLS LAST'')'; + EXECUTE 'ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.compress_orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -214,7 +209,7 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() OldCompressionSegmentBy = [] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -222,12 +217,12 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""""DeviceId""""'')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""DeviceId""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -249,7 +244,7 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() OldCompressionOrderBy = ["Timestamp ASC"] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -257,12 +252,12 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_orderby = ''""""Timestamp"""" DESC'')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress_orderby = ''""Timestamp"" DESC'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -290,7 +285,7 @@ public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_St }; // TimescaleDB requires setting the value to '' (empty string) to clear it - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -298,12 +293,12 @@ public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_St license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_segmentby = '''', timescaledb.compress_orderby = '''')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress_segmentby = '''', timescaledb.compress_orderby = '''')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -324,7 +319,7 @@ public void Generate_Alter_when_adding_and_removing_skip_columns_generates_corre OldChunkSkipColumns = ["host", "region"] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -333,13 +328,13 @@ public void Generate_Alter_when_adding_and_removing_skip_columns_generates_corre IF license IS NULL OR license != 'apache' THEN EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''metrics_schema.""""Metrics""""'', ''service'')'; - EXECUTE 'SELECT disable_chunk_skipping(''metrics_schema.""""Metrics""""'', ''region'')'; + EXECUTE 'SELECT enable_chunk_skipping(''metrics_schema.""Metrics""'', ''service'')'; + EXECUTE 'SELECT disable_chunk_skipping(''metrics_schema.""Metrics""'', ''region'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -384,7 +379,7 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress EnableCompression = false, ChunkSkipColumns = [] }; - string expected = @".Sql(@"" + string expected = @" DO $$ DECLARE license TEXT; @@ -392,13 +387,13 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"public"""".""""Logs"""" SET (timescaledb.compress = false)'; - EXECUTE 'SELECT disable_chunk_skipping(''public.""""Logs""""'', ''trace_id'')'; + EXECUTE 'ALTER TABLE ""public"".""Logs"" SET (timescaledb.compress = false)'; + EXECUTE 'SELECT disable_chunk_skipping(''public.""Logs""'', ''trace_id'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -421,9 +416,9 @@ public void Generate_Create_When_MigrateData_Is_False_Does_Not_Include_Migrate_D MigrateData = false }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""Metrics""""', 'Timestamp'); - "")"; + string expected = @" + SELECT create_hypertable('public.""Metrics""', 'Timestamp'); + "; // Act string result = GetGeneratedCode(operation); @@ -444,9 +439,9 @@ public void Generate_Create_When_MigrateData_Is_True_Includes_Migrate_Data_Param MigrateData = true }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""Metrics""""', 'Timestamp', migrate_data => true); - "")"; + string expected = @" + SELECT create_hypertable('public.""Metrics""', 'Timestamp', migrate_data => true); + "; // Act string result = GetGeneratedCode(operation); @@ -474,9 +469,9 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com ] }; - string expected = @".Sql(@"" - 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)); + 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)); DO $$ DECLARE license TEXT; @@ -484,14 +479,14 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE """"custom_schema"""".""""CompleteTable"""" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.compress = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""CompleteTable""""'', ''DeviceId'')'; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""CompleteTable""'', ''DeviceId'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -512,9 +507,9 @@ public void Generate_Create_Default_MigrateData_Does_Not_Include_Parameter() // MigrateData not explicitly set, defaults to false }; - string expected = @".Sql(@"" - SELECT create_hypertable('public.""""DefaultTable""""', 'Timestamp'); - "")"; + string expected = @" + SELECT create_hypertable('public.""DefaultTable""', 'Timestamp'); + "; // Act string result = GetGeneratedCode(operation); diff --git a/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs b/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs new file mode 100644 index 0000000..a7cfc7d --- /dev/null +++ b/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs @@ -0,0 +1,219 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + public class PolicyJobSqlBuilderTests + { + // --- BuildJobClauses --- + + #region BuildJobClauses_AllProvided_EmitsFourClauses + + [Fact] + public void BuildJobClauses_AllProvided_EmitsFourClauses() + { + // Arrange + string scheduleInterval = "2 days"; + string maxRuntime = "1 hour"; + int maxRetries = 5; + string retryPeriod = "10 minutes"; + + // Act + List clauses = PolicyJobSqlBuilder.BuildJobClauses(scheduleInterval, maxRuntime, maxRetries, retryPeriod); + + // Assert + Assert.Equal(4, clauses.Count); + Assert.Contains("schedule_interval => INTERVAL '2 days'", clauses); + Assert.Contains("max_runtime => INTERVAL '1 hour'", clauses); + Assert.Contains("max_retries => 5", clauses); + Assert.Contains("retry_period => INTERVAL '10 minutes'", clauses); + } + + #endregion + + #region BuildJobClauses_AllNull_ReturnsEmptyList + + [Fact] + public void BuildJobClauses_AllNull_ReturnsEmptyList() + { + // Act + List clauses = PolicyJobSqlBuilder.BuildJobClauses(null, null, null, null); + + // Assert + Assert.Empty(clauses); + } + + #endregion + + #region BuildJobClauses_WhitespaceStrings_AreSkipped + + [Fact] + public void BuildJobClauses_WhitespaceStrings_AreSkipped() + { + // Arrange + string scheduleInterval = " "; + string maxRuntime = ""; + string retryPeriod = "\t"; + + // Act + List clauses = PolicyJobSqlBuilder.BuildJobClauses(scheduleInterval, maxRuntime, null, retryPeriod); + + // Assert + Assert.Empty(clauses); + } + + #endregion + + #region BuildJobClauses_MaxRetriesZero_IsEmitted + + [Fact] + public void BuildJobClauses_MaxRetriesZero_IsEmitted() + { + // Arrange — 0 is a valid value distinct from null; uses a null check, not truthiness + int maxRetries = 0; + + // Act + List clauses = PolicyJobSqlBuilder.BuildJobClauses(null, null, maxRetries, null); + + // Assert + Assert.Single(clauses); + Assert.Equal("max_retries => 0", clauses[0]); + } + + #endregion + + #region BuildJobClauses_MaxRetries_EmittedAsBareInt + + [Fact] + public void BuildJobClauses_MaxRetries_EmittedAsBareInt() + { + // Arrange + int maxRetries = 7; + + // Act + List clauses = PolicyJobSqlBuilder.BuildJobClauses(null, null, maxRetries, null); + + // Assert — bare int, not wrapped in INTERVAL or quotes + Assert.Single(clauses); + Assert.Equal("max_retries => 7", clauses[0]); + Assert.DoesNotContain("INTERVAL", clauses[0]); + } + + #endregion + + // --- BuildChangedJobClauses --- + + #region BuildChangedJobClauses_OnlyDifferingValuesProduceClauses + + [Fact] + public void BuildChangedJobClauses_OnlyDifferingValuesProduceClauses() + { + // Arrange — only scheduleInterval changes + List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + scheduleInterval: "2 days", oldScheduleInterval: "1 day", + maxRuntime: "1 hour", oldMaxRuntime: "1 hour", + maxRetries: 5, oldMaxRetries: 5, + retryPeriod: "10 minutes", oldRetryPeriod: "10 minutes"); + + // Assert + Assert.Single(clauses); + Assert.Equal("schedule_interval => INTERVAL '2 days'", clauses[0]); + } + + #endregion + + #region BuildChangedJobClauses_MaxRetriesUnchanged_NoClause + + [Fact] + public void BuildChangedJobClauses_MaxRetriesUnchanged_NoClause() + { + // Act + List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + scheduleInterval: null, oldScheduleInterval: null, + maxRuntime: null, oldMaxRuntime: null, + maxRetries: 5, oldMaxRetries: 5, + retryPeriod: null, oldRetryPeriod: null); + + // Assert + Assert.Empty(clauses); + } + + #endregion + + #region BuildChangedJobClauses_MaxRetriesChanged_EmitsClause + + [Fact] + public void BuildChangedJobClauses_MaxRetriesChanged_EmitsClause() + { + // Act + List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + scheduleInterval: null, oldScheduleInterval: null, + maxRuntime: null, oldMaxRuntime: null, + maxRetries: 5, oldMaxRetries: 3, + retryPeriod: null, oldRetryPeriod: null); + + // Assert + Assert.Single(clauses); + Assert.Equal("max_retries => 5", clauses[0]); + } + + #endregion + + #region BuildChangedJobClauses_NewValueNullWhileOldSet_NoClause + + [Fact] + public void BuildChangedJobClauses_NewValueNullWhileOldSet_NoClause() + { + // Arrange — a value cannot be cleared via alter_job: a null/whitespace new value + // while the old value is set produces no clause (documented limitation). + List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( + scheduleInterval: null, oldScheduleInterval: "1 day", + maxRuntime: " ", oldMaxRuntime: "1 hour", + maxRetries: null, oldMaxRetries: 5, + retryPeriod: "", oldRetryPeriod: "10 minutes"); + + // Assert + Assert.Empty(clauses); + } + + #endregion + + // --- BuildAlterJobSql --- + + #region BuildAlterJobSql_ProducesExpectedStatement + + [Fact] + public void BuildAlterJobSql_ProducesExpectedStatement() + { + // Arrange + List clauses = ["schedule_interval => INTERVAL '2 days'", "max_retries => 5"]; + + // Act + string sql = PolicyJobSqlBuilder.BuildAlterJobSql("TestTable", "public", "policy_retention", clauses); + + // Assert + Assert.Contains("SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_retries => 5)", sql); + Assert.Contains("FROM timescaledb_information.jobs", sql); + Assert.Contains("WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'", sql); + } + + #endregion + + #region BuildAlterJobSql_ResultIsTrimmed + + [Fact] + public void BuildAlterJobSql_ResultIsTrimmed() + { + // Arrange + List clauses = ["max_retries => 5"]; + + // Act + string sql = PolicyJobSqlBuilder.BuildAlterJobSql("TestTable", "public", "policy_retention", clauses); + + // Assert — no leading/trailing whitespace + Assert.Equal(sql, sql.Trim()); + Assert.StartsWith("SELECT alter_job", sql); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs index c31733c..339d81f 100644 --- a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs @@ -1,35 +1,26 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; -using Microsoft.EntityFrameworkCore.Infrastructure; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { /// - /// Comprehensive tests for ReorderPolicyOperationGenerator validating design-time and runtime + /// Comprehensive tests for ReorderPolicySqlGenerator validating /// SQL generation according to TimescaleDB requirements. /// public class ReorderPolicyOperationGeneratorComprehensiveTests { /// - /// Helper to run the generator and capture design-time C# code output. + /// Helper to run the generator and capture the SQL output. /// - private static string GetDesignTimeCode(dynamic operation) - { - IndentedStringBuilder builder = new(); - ReorderPolicyOperationGenerator generator = new(isDesignTime: true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); - } + private static string GetDesignTimeCode(dynamic operation) => GetRuntimeSql(operation); /// /// Helper to run the generator and capture runtime SQL output. /// private static List GetRuntimeSqlStatements(dynamic operation) { - ReorderPolicyOperationGenerator generator = new(isDesignTime: false); - return generator.Generate(operation); + return ReorderPolicySqlGenerator.Generate(operation); } /// @@ -54,9 +45,9 @@ public void DesignTime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() IndexName = "metrics_time_idx" }; - string expected = @".Sql(@"" - SELECT add_reorder_policy('public.""""metrics""""', 'metrics_time_idx'); - "")"; + string expected = @" + SELECT add_reorder_policy('public.""metrics""', 'metrics_time_idx'); + "; // Act string result = GetDesignTimeCode(operation); @@ -82,12 +73,12 @@ public void DesignTime_Add_WithAllOptions_GeneratesCorrectCode() RetryPeriod = "2 minutes" }; - string expected = $@".Sql(@"" - SELECT add_reorder_policy('analytics.""""sensor_data""""', 'sensor_data_time_device_idx', initial_start => '{initialStart:yyyy-MM-ddTHH:mm:ss.fffffffZ}'); + string expected = $@" + SELECT add_reorder_policy('analytics.""sensor_data""', 'sensor_data_time_device_idx', initial_start => '{initialStart:yyyy-MM-ddTHH:mm:ss.fffffffZ}'); SELECT alter_job(job_id, schedule_interval => INTERVAL '6 hours', max_runtime => INTERVAL '30 minutes', max_retries => 5, retry_period => INTERVAL '2 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'analytics' AND hypertable_name = 'sensor_data'; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -237,11 +228,11 @@ public void DesignTime_Alter_ScheduleInterval_GeneratesCorrectCode() OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, schedule_interval => INTERVAL '12 hours') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'metrics'; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -262,11 +253,11 @@ public void DesignTime_Alter_MaxRuntime_GeneratesCorrectCode() OldMaxRuntime = "30 minutes" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, max_runtime => INTERVAL '1 hour') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'data'; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -287,11 +278,11 @@ public void DesignTime_Alter_MaxRetries_GeneratesCorrectCode() OldMaxRetries = -1 }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, max_retries => 3) FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'test'; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -312,11 +303,11 @@ public void DesignTime_Alter_RetryPeriod_GeneratesCorrectCode() OldRetryPeriod = "5 minutes" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'test'; - "")"; + "; // Act string result = GetDesignTimeCode(operation); @@ -428,9 +419,9 @@ public void DesignTime_Drop_GeneratesCorrectCode() Schema = "public" }; - string expected = @".Sql(@"" - SELECT remove_reorder_policy('public.""""old_table""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_reorder_policy('public.""old_table""', if_exists => true); + "; // Act string result = GetDesignTimeCode(operation); @@ -449,9 +440,9 @@ public void DesignTime_Drop_WithCustomSchema_GeneratesCorrectCode() Schema = "analytics" }; - string expected = @".Sql(@"" - SELECT remove_reorder_policy('analytics.""""analytics_data""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_reorder_policy('analytics.""analytics_data""', if_exists => true); + "; // Act string result = GetDesignTimeCode(operation); diff --git a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs index 653b750..ebc3c36 100644 --- a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs @@ -1,22 +1,18 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; -using Microsoft.EntityFrameworkCore.Infrastructure; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { public class ReorderPolicyOperationGeneratorTests { /// - /// A helper to run the generator and capture its string output. + /// A helper to run the generator and capture its SQL output. /// private static string GetGeneratedCode(dynamic operation) { - IndentedStringBuilder builder = new(); - ReorderPolicyOperationGenerator generator = new(true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); + List statements = ReorderPolicySqlGenerator.Generate(operation); + return string.Join("\n", statements); } [Fact] @@ -30,9 +26,9 @@ public void Generate_Add_with_minimal_details_creates_only_add_policy_sql() IndexName = "IX_TestTable_Time" }; - string expected = @".Sql(@"" - SELECT add_reorder_policy('public.""""TestTable""""', 'IX_TestTable_Time'); - "")"; + string expected = @" + SELECT add_reorder_policy('public.""TestTable""', 'IX_TestTable_Time'); + "; // Act string result = GetGeneratedCode(operation); @@ -58,12 +54,12 @@ public void Generate_Add_with_non_default_schedule_creates_add_and_alter_sql() RetryPeriod = "10 minutes" }; - string expected = @".Sql(@"" - SELECT add_reorder_policy('custom.""""TestTable""""', 'IX_TestTable_Time', initial_start => '2025-10-20T12:30:00.0000000Z'); + string expected = @" + SELECT add_reorder_policy('custom.""TestTable""', 'IX_TestTable_Time', initial_start => '2025-10-20T12:30:00.0000000Z'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_runtime => INTERVAL '1 hour', max_retries => 5, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'custom' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -84,9 +80,9 @@ public void Generate_Drop_creates_correct_remove_policy_sql() TableName = "TestTable" }; - string expected = @".Sql(@"" - SELECT remove_reorder_policy('public.""""TestTable""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_reorder_policy('public.""TestTable""', if_exists => true); + "; // Act string result = GetGeneratedCode(operation); @@ -115,11 +111,11 @@ public void Generate_Alter_when_only_job_settings_change_creates_only_alter_job_ OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'metrics' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -142,13 +138,13 @@ public void Generate_Alter_when_fundamental_property_changes_creates_drop_and_ad OldScheduleInterval = "2 days" }; - string expected = @".Sql(@"" - SELECT remove_reorder_policy('logs.""""TestTable""""', if_exists => true); - SELECT add_reorder_policy('logs.""""TestTable""""', 'IX_New_Name'); + string expected = @" + SELECT remove_reorder_policy('logs.""TestTable""', if_exists => true); + SELECT add_reorder_policy('logs.""TestTable""', 'IX_New_Name'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'logs' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -175,13 +171,13 @@ public void Generate_Alter_when_both_fundamental_and_job_settings_change_creates OldRetryPeriod = "10 minutes" }; - string expected = @".Sql(@"" - SELECT remove_reorder_policy('public.""""TestTable""""', if_exists => true); - SELECT add_reorder_policy('public.""""TestTable""""', 'IX_New_Name'); + string expected = @" + SELECT remove_reorder_policy('public.""TestTable""', if_exists => true); + SELECT add_reorder_policy('public.""TestTable""', 'IX_New_Name'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_retries => 5, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_reorder' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); diff --git a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs index 90815ac..0ba4fd6 100644 --- a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs @@ -1,22 +1,18 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; -using Microsoft.EntityFrameworkCore.Infrastructure; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { public class RetentionPolicyOperationGeneratorTests { /// - /// A helper to run the generator and capture its string output. + /// A helper to run the generator and capture its SQL output. /// private static string GetGeneratedCode(dynamic operation) { - IndentedStringBuilder builder = new(); - RetentionPolicyOperationGenerator generator = new(true); - List statements = generator.Generate(operation); - SqlBuilderHelper.BuildQueryString(statements, builder); - return builder.ToString(); + List statements = RetentionPolicySqlGenerator.Generate(operation); + return string.Join("\n", statements); } // --- Tests for AddRetentionPolicyOperation --- @@ -34,9 +30,9 @@ public void Generate_Add_DropAfter_with_minimal_config_creates_only_add_policy_s DropAfter = "7 days" }; - string expected = @".Sql(@"" - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days'); - "")"; + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '7 days'); + "; // Act string result = GetGeneratedCode(operation); @@ -62,12 +58,12 @@ public void Generate_Add_DropCreatedBefore_with_job_settings_creates_add_and_alt MaxRetries = 5 }; - string expected = @".Sql(@"" - SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '30 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day', max_retries => 5) FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -93,9 +89,9 @@ public void Generate_Add_with_InitialStart_includes_iso_8601_timestamp() InitialStart = testDate }; - string expected = @".Sql(@"" - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days', initial_start => '2025-10-20T12:30:00.0000000Z'); - "")"; + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '7 days', initial_start => '2025-10-20T12:30:00.0000000Z'); + "; // Act string result = GetGeneratedCode(operation); @@ -123,12 +119,12 @@ public void Generate_Add_DropAfter_with_all_job_settings_creates_add_and_alter_j RetryPeriod = "10 minutes" }; - string expected = @".Sql(@"" - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days'); + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '7 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_runtime => INTERVAL '1 hour', max_retries => 5, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -156,12 +152,12 @@ public void Generate_Add_DropCreatedBefore_with_all_job_settings_creates_add_and RetryPeriod = "10 minutes" }; - string expected = @".Sql(@"" - SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '30 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_runtime => INTERVAL '1 hour', max_retries => 5, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -194,11 +190,11 @@ public void Generate_Alter_when_only_job_settings_change_creates_only_alter_job_ OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'metrics' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -225,13 +221,13 @@ public void Generate_Alter_when_DropAfter_changes_creates_remove_add_and_alter_j OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" - SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '14 days'); + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '14 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -261,13 +257,13 @@ public void Generate_Alter_changed_to_DropCreatedBefore_creates_remove_add_and_a }; // During recreation, alter_job is emitted to reapply the final-state job settings - string expected = @".Sql(@"" - SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); - SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '30 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -298,13 +294,13 @@ public void Generate_Alter_when_InitialStart_changes_creates_remove_add_and_alte OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" - SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days', initial_start => '2025-06-15T12:00:00.0000000Z'); + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '7 days', initial_start => '2025-06-15T12:00:00.0000000Z'); SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -329,9 +325,9 @@ public void Generate_Drop_creates_correct_remove_policy_sql() TableName = "TestTable" }; - string expected = @".Sql(@"" - SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + "; // Act string result = GetGeneratedCode(operation); @@ -354,9 +350,9 @@ public void Generate_Drop_with_custom_schema_uses_correct_regclass_quoting() TableName = "EventLogs" }; - string expected = @".Sql(@"" - SELECT remove_retention_policy('analytics.""""EventLogs""""', if_exists => true); - "")"; + string expected = @" + SELECT remove_retention_policy('analytics.""EventLogs""', if_exists => true); + "; // Act string result = GetGeneratedCode(operation); @@ -387,13 +383,13 @@ public void Generate_Alter_when_both_fundamental_and_job_settings_change_creates OldRetryPeriod = "10 minutes" }; - string expected = @".Sql(@"" - SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); - SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '14 days'); + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + SELECT add_retention_policy('public.""TestTable""', drop_after => INTERVAL '14 days'); SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_retries => 5, retry_period => INTERVAL '10 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -424,11 +420,11 @@ public void Generate_Alter_DropCreatedBefore_only_job_settings_change_emits_alte OldScheduleInterval = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -465,11 +461,11 @@ public void Generate_Alter_MaxRuntime_change_emits_alter_job() OldRetryPeriod = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, max_runtime => INTERVAL '2 hours') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -506,11 +502,11 @@ public void Generate_Alter_RetryPeriod_change_emits_alter_job() OldRetryPeriod = "1 day" }; - string expected = @".Sql(@"" + string expected = @" SELECT alter_job(job_id, retry_period => INTERVAL '30 minutes') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; - "")"; + "; // Act string result = GetGeneratedCode(operation); @@ -521,12 +517,11 @@ FROM timescaledb_information.jobs #endregion - // --- Tests for runtime quoting (isDesignTime=false) --- + // --- Tests for runtime quoting --- private static List GetRuntimeStatements(dynamic operation) { - RetentionPolicyOperationGenerator generator = new(isDesignTime: false); - return generator.Generate(operation); + return RetentionPolicySqlGenerator.Generate(operation); } #region Generate_Add_DropAfter_with_runtime_quoting_uses_single_quotes @@ -576,6 +571,54 @@ public void Generate_Drop_with_runtime_quoting_uses_single_quotes() // --- Tests for alter no-change path --- + #region Generate_Add_DropAfter_uses_drop_after_interval_argument + + [Fact] + public void Generate_Add_DropAfter_uses_drop_after_interval_argument() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days" + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation); + + // Assert — DropAfter maps to the drop_after => INTERVAL argument + Assert.Single(statements); + Assert.Contains("drop_after => INTERVAL '7 days'", statements[0]); + Assert.DoesNotContain("drop_created_before", statements[0]); + } + + #endregion + + #region Generate_Add_DropCreatedBefore_uses_drop_created_before_interval_argument + + [Fact] + public void Generate_Add_DropCreatedBefore_uses_drop_created_before_interval_argument() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropCreatedBefore = "30 days" + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation); + + // Assert — DropCreatedBefore maps to the drop_created_before => INTERVAL argument + Assert.Single(statements); + Assert.Contains("drop_created_before => INTERVAL '30 days'", statements[0]); + Assert.DoesNotContain("drop_after", statements[0]); + } + + #endregion + #region Generate_Alter_when_no_changes_returns_empty_list [Fact] @@ -603,8 +646,7 @@ public void Generate_Alter_when_no_changes_returns_empty_list() }; // Act - RetentionPolicyOperationGenerator generator = new(true); - List result = generator.Generate(operation); + List result = RetentionPolicySqlGenerator.Generate(operation); // Assert Assert.Empty(result); diff --git a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs index 15ef44d..54d59b1 100644 --- a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs +++ b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs @@ -17,12 +17,11 @@ public class SqlBuilderHelperTests public void Regclass_Runtime_ReturnsCorrectlyQuotedString() { // Arrange - SqlBuilderHelper helper = new(quoteString: "\""); string tableName = "MyTable"; string expected = "'public.\"MyTable\"'"; // Act - string result = helper.Regclass(tableName); + string result = SqlBuilderHelper.Regclass(tableName); // Assert Assert.Equal(expected, result); @@ -32,42 +31,11 @@ public void Regclass_Runtime_ReturnsCorrectlyQuotedString() public void QualifiedIdentifier_Runtime_ReturnsCorrectlyQuotedString() { // Arrange - SqlBuilderHelper helper = new(quoteString: "\""); string tableName = "MyTable"; string expected = "\"public\".\"MyTable\""; // Act - string result = helper.QualifiedIdentifier(tableName); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void Regclass_DesignTime_ReturnsCorrectlyEscapedQuotedString() - { - // Arrange - SqlBuilderHelper helper = new(quoteString: "\"\""); - string tableName = "MyTable"; - string expected = "'public.\"\"MyTable\"\"'"; - - // Act - string result = helper.Regclass(tableName); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void QualifiedIdentifier_DesignTime_ReturnsCorrectlyEscapedQuotedString() - { - // Arrange - SqlBuilderHelper helper = new(quoteString: "\"\""); - string tableName = "MyTable"; - string expected = "\"\"public\"\".\"\"MyTable\"\""; - - // Act - string result = helper.QualifiedIdentifier(tableName); + string result = SqlBuilderHelper.QualifiedIdentifier(tableName); // Assert Assert.Equal(expected, result); @@ -323,6 +291,83 @@ public void BuildQueryString_MigrationCommandListBuilder_UsePerform_False_Preser } #endregion + + #region BuildQueryString_MigrationCommandListBuilder_Grouping + + private static Mock CreateMockBuilder() + { + MigrationsSqlGeneratorDependencies dependencies = new( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of>() + ); + + Mock mockBuilder = new(dependencies); + mockBuilder.Setup(b => b.Append(It.IsAny())).Returns(mockBuilder.Object); + mockBuilder.Setup(b => b.EndCommand(It.IsAny())).Returns(mockBuilder.Object); + return mockBuilder; + } + + [Fact] + public void BuildQueryString_MigrationCommandListBuilder_GroupsNonSemicolonLinesIntoSingleCommand() + { + // Arrange + List statements = + [ + "DO $$", + "BEGIN", + " EXECUTE 'SELECT 1';", + "END $$;" + ]; + Mock mockBuilder = CreateMockBuilder(); + + // Act + SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object); + + // Assert + mockBuilder.Verify(b => b.EndCommand(It.IsAny()), Times.Once); + mockBuilder.Verify( + b => b.Append(It.Is(s => s.Contains("DO $$") && s.Contains("END $$;"))), + Times.Once); + } + + [Fact] + public void BuildQueryString_MigrationCommandListBuilder_SuppressTransaction_IsForwarded() + { + // Arrange + List statements = ["SELECT 1;", "SELECT 2;"]; + Mock mockBuilder = CreateMockBuilder(); + + // Act + SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object, suppressTransaction: true); + + // Assert + mockBuilder.Verify(b => b.EndCommand(true), Times.Exactly(2)); + mockBuilder.Verify(b => b.EndCommand(false), Times.Never); + } + + [Fact] + public void BuildQueryString_MigrationCommandListBuilder_UsePerform_And_SuppressTransaction_Combined() + { + // Arrange + List statements = ["SELECT create_hypertable('public.\"Events\"', 'Time');"]; + Mock mockBuilder = CreateMockBuilder(); + + // Act + SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object, suppressTransaction: true, usePerform: true); + + // Assert + mockBuilder.Verify(b => b.Append(It.Is(s => s.StartsWith("PERFORM"))), Times.Once); + mockBuilder.Verify(b => b.EndCommand(true), Times.Once); + } + + #endregion } #pragma warning restore EF1001 // Internal EF Core API usage. } \ No newline at end of file diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index 7b90529..6903b82 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -33,17 +33,16 @@ public void Generate_CreateHypertable_WithValidOperation_GeneratesValidCSharp() }; // Act - // Note: We call the protected method via the public interface indirectly - // by using reflection or by testing via the public Generate method generator.Generate("migrationBuilder", [operation], builder); // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("create_hypertable", result); - Assert.Contains(";", result); - Assert.DoesNotContain("migrationBuilder;", result); // This would be invalid C# + Assert.Contains(".CreateHypertable(", result); + Assert.Contains("tableName:", result); + Assert.Contains("timeColumnName:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); } [Fact] @@ -68,7 +67,8 @@ public void Generate_CreateHypertable_WithMigrateData_GeneratesValidCSharp() // Assert string result = builder.ToString(); - Assert.Contains("migrate_data => true", result); + Assert.Contains(".CreateHypertable(", result); + Assert.Contains("migrateData:", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -80,7 +80,6 @@ public void Generate_AlterHypertable_WithNoChanges_GeneratesValidCSharpOrNoOp() TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); IndentedStringBuilder builder = new(); - // An alter operation with no actual changes should still generate valid C# AlterHypertableOperation operation = new() { TableName = "sensor_data", @@ -93,15 +92,13 @@ public void Generate_AlterHypertable_WithNoChanges_GeneratesValidCSharpOrNoOp() // Assert string result = builder.ToString(); - // The result should either be empty (no operation generated) or contain valid C# - // It should NEVER contain just "migrationBuilder;" without a method call if (!string.IsNullOrWhiteSpace(result)) { Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); - // If there's content, it should have a proper method call if (result.Contains("migrationBuilder")) { - Assert.Contains(".Sql(@\"", result); + Assert.Contains(".AlterHypertable(", result); + Assert.DoesNotContain(".Sql(", result); } } } @@ -127,8 +124,10 @@ public void Generate_AddReorderPolicy_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_reorder_policy", result); + Assert.Contains(".AddReorderPolicy(", result); + Assert.Contains("tableName:", result); + Assert.Contains("indexName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -152,8 +151,9 @@ public void Generate_DropReorderPolicy_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("remove_reorder_policy", result); + Assert.Contains(".DropReorderPolicy(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -173,7 +173,7 @@ public void Generate_CreateContinuousAggregate_GeneratesValidCSharp() TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "timestamp", TimeBucketGroupBy = true, - AggregateFunctions = ["COUNT(*)"] + AggregateFunctions = ["total_count:Count:id"] }; // Act @@ -182,8 +182,15 @@ public void Generate_CreateContinuousAggregate_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("CREATE MATERIALIZED VIEW", result); + Assert.Contains(".CreateContinuousAggregate(", result); + Assert.Contains("materializedViewName:", result); + Assert.Contains("parentName:", result); + Assert.Contains("timeBucketWidth:", result); + Assert.Contains("aggregateFunctions:", result); + // Aggregate functions emit as typed entries showing the enum, not magic strings. + Assert.Contains("ContinuousAggregateFunction(", result); + Assert.Contains("EAggregateFunction.Count", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -207,8 +214,9 @@ public void Generate_DropContinuousAggregate_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("DROP MATERIALIZED VIEW", result); + Assert.Contains(".DropContinuousAggregate(", result); + Assert.Contains("materializedViewName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -236,10 +244,10 @@ public void Generate_AlterReorderPolicy_WithIndexChange_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - // When index changes, policy is dropped and recreated - Assert.Contains("remove_reorder_policy", result); - Assert.Contains("add_reorder_policy", result); + Assert.Contains(".AlterReorderPolicy(", result); + Assert.Contains("indexName:", result); + Assert.Contains("oldIndexName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -269,13 +277,10 @@ public void Generate_AlterReorderPolicy_WithScheduleIntervalChange_GeneratesVali // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - // When only schedule changes, uses alter_job - Assert.Contains("alter_job", result); - Assert.Contains("schedule_interval", result); - // Should not drop and recreate - Assert.DoesNotContain("remove_reorder_policy", result); - Assert.DoesNotContain("add_reorder_policy", result); + Assert.Contains(".AlterReorderPolicy(", result); + Assert.Contains("scheduleInterval:", result); + Assert.Contains("oldScheduleInterval:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -311,18 +316,10 @@ public void Generate_AlterReorderPolicy_WithNoChanges_GeneratesValidCSharpOrNoOp // Assert string result = builder.ToString(); - - // The result should either be empty (no operation generated) or contain valid C# - // It should NEVER contain just "migrationBuilder;" without a method call - if (!string.IsNullOrWhiteSpace(result)) - { - Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); - // If there's content, it should have a proper method call - if (result.Contains("migrationBuilder")) - { - Assert.Contains(".Sql(@\"", result); - } - } + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterReorderPolicy(", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); } [Fact] @@ -351,10 +348,10 @@ public void Generate_AlterContinuousAggregate_WithChunkIntervalChange_GeneratesV // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("ALTER MATERIALIZED VIEW", result); - Assert.Contains("SET", result); - Assert.Contains("timescaledb.chunk_interval", result); + Assert.Contains(".AlterContinuousAggregate(", result); + Assert.Contains("chunkInterval:", result); + Assert.Contains("oldChunkInterval:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -384,10 +381,9 @@ public void Generate_AlterContinuousAggregate_WithMaterializedOnlyChange_Generat // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("ALTER MATERIALIZED VIEW", result); - Assert.Contains("SET", result); - Assert.Contains("timescaledb.materialized_only", result); + Assert.Contains(".AlterContinuousAggregate(", result); + Assert.Contains("materializedOnly:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -417,10 +413,9 @@ public void Generate_AlterContinuousAggregate_WithCreateGroupIndexesChange_Gener // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("ALTER MATERIALIZED VIEW", result); - Assert.Contains("SET", result); - Assert.Contains("timescaledb.create_group_indexes", result); + Assert.Contains(".AlterContinuousAggregate(", result); + Assert.Contains("oldCreateGroupIndexes:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -450,18 +445,10 @@ public void Generate_AlterContinuousAggregate_WithNoChanges_GeneratesValidCSharp // Assert string result = builder.ToString(); - - // The result should either be empty (no operation generated) or contain valid C# - // It should NEVER contain just "migrationBuilder;" without a method call - if (!string.IsNullOrWhiteSpace(result)) - { - Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); - // If there's content, it should have a proper method call - if (result.Contains("migrationBuilder")) - { - Assert.Contains(".Sql(@\"", result); - } - } + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterContinuousAggregate(", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result.Replace(" ", "").Replace("\n", "").Replace("\r", "")); } #endregion @@ -497,8 +484,11 @@ public void Generate_AddContinuousAggregatePolicy_WithAllParameters_GeneratesVal // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_continuous_aggregate_policy", result); + Assert.Contains(".AddContinuousAggregatePolicy(", result); + Assert.Contains("materializedViewName:", result); + Assert.Contains("startOffset:", result); + Assert.Contains("bucketsPerBatch:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -524,8 +514,11 @@ public void Generate_AddContinuousAggregatePolicy_WithMinimalParameters_Generate // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_continuous_aggregate_policy", result); + Assert.Contains(".AddContinuousAggregatePolicy(", result); + Assert.Contains("materializedViewName:", result); + Assert.Contains("startOffset:", result); + Assert.Contains("endOffset:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -551,10 +544,11 @@ public void Generate_AddContinuousAggregatePolicy_WithNullOffsets_GeneratesValid // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_continuous_aggregate_policy", result); - Assert.Contains("start_offset => NULL", result); - Assert.Contains("end_offset => NULL", result); + Assert.Contains(".AddContinuousAggregatePolicy(", result); + Assert.Contains("materializedViewName:", result); + Assert.DoesNotContain("startOffset:", result); + Assert.DoesNotContain("endOffset:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -580,10 +574,10 @@ public void Generate_AddContinuousAggregatePolicy_WithIntegerOffsets_GeneratesVa // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_continuous_aggregate_policy", result); - Assert.Contains("start_offset => 1000", result); - Assert.Contains("end_offset => 100", result); + Assert.Contains(".AddContinuousAggregatePolicy(", result); + Assert.Contains("startOffset:", result); + Assert.Contains("endOffset:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -607,8 +601,9 @@ public void Generate_RemoveContinuousAggregatePolicy_BasicRemoval_GeneratesValid // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("remove_continuous_aggregate_policy", result); + Assert.Contains(".RemoveContinuousAggregatePolicy(", result); + Assert.Contains("materializedViewName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -633,9 +628,9 @@ public void Generate_RemoveContinuousAggregatePolicy_WithIfExists_GeneratesValid // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("remove_continuous_aggregate_policy", result); - Assert.Contains("if_exists => true", result); + Assert.Contains(".RemoveContinuousAggregatePolicy(", result); + Assert.Contains("ifExists:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -664,8 +659,10 @@ public void Generate_AddRetentionPolicy_WithDropAfter_GeneratesValidCSharp() // Assert string result = builder.ToString(); Assert.Contains("migrationBuilder", result); - Assert.Contains(".Sql(@\"", result); - Assert.Contains("add_retention_policy", result); + Assert.Contains(".AddRetentionPolicy(", result); + Assert.Contains("tableName:", result); + Assert.Contains("dropAfter:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -689,8 +686,11 @@ public void Generate_AddRetentionPolicy_WithDropCreatedBefore_GeneratesValidCSha // Assert string result = builder.ToString(); - Assert.Contains("add_retention_policy", result); - Assert.DoesNotContain("alter_job", result); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AddRetentionPolicy(", result); + Assert.Contains("dropCreatedBefore:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); } [Fact] @@ -714,8 +714,11 @@ public void Generate_AlterRetentionPolicy_WithDropAfterChange_GeneratesValidCSha // Assert string result = builder.ToString(); - Assert.Contains("remove_retention_policy", result); - Assert.Contains("add_retention_policy", result); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterRetentionPolicy(", result); + Assert.Contains("dropAfter:", result); + Assert.Contains("oldDropAfter:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -742,8 +745,11 @@ public void Generate_AlterRetentionPolicy_WithScheduleIntervalChange_GeneratesVa // Assert string result = builder.ToString(); - Assert.Contains("alter_job", result); - Assert.DoesNotContain("remove_retention_policy", result); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterRetentionPolicy(", result); + Assert.Contains("scheduleInterval:", result); + Assert.Contains("oldScheduleInterval:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } @@ -766,8 +772,10 @@ public void Generate_DropRetentionPolicy_GeneratesValidCSharp() // Assert string result = builder.ToString(); - Assert.Contains("remove_retention_policy", result); - Assert.Contains("if_exists", result); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".DropRetentionPolicy(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain(".Sql(", result); Assert.DoesNotContain("migrationBuilder;", result); } diff --git a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs index 9876942..dd1f351 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs @@ -29,6 +29,18 @@ private static string GenerateSql( return string.Join("\n", commands.Select(c => c.CommandText)); } + private static IReadOnlyList GenerateCommands( + List operations, + MigrationsSqlGenerationOptions? options = null) + { + using TestContext context = new(); + IMigrationsSqlGenerator sqlGenerator = context.GetService(); + + return options.HasValue + ? sqlGenerator.Generate(operations, context.Model, options.Value) + : sqlGenerator.Generate(operations, context.Model); + } + #region Should_Use_Perform_For_CreateHypertable_In_Idempotent_Mode [Fact] @@ -142,4 +154,55 @@ public void Should_Use_Select_For_SqlOperation_In_NonIdempotent_Mode() } #endregion + + #region Should_Suppress_Transaction_For_CreateContinuousAggregate + + [Fact] + public void Should_Suppress_Transaction_For_CreateContinuousAggregate() + { + // Arrange — CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) cannot run + // inside a transaction, so the generator must mark the command as transaction-suppressed. + CreateContinuousAggregateOperation operation = new() + { + Schema = "public", + MaterializedViewName = "daily_avg", + ParentName = "measurements", + TimeBucketWidth = "1 day", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_t:Avg:temp"] + }; + List operations = [operation]; + + // Act + IReadOnlyList commands = GenerateCommands(operations); + + // Assert + Assert.All(commands, c => Assert.True(c.TransactionSuppressed)); + } + + #endregion + + #region Should_Not_Suppress_Transaction_For_CreateHypertable + + [Fact] + public void Should_Not_Suppress_Transaction_For_CreateHypertable() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "events", + Schema = "public", + TimeColumnName = "time" + }; + List operations = [operation]; + + // Act + IReadOnlyList commands = GenerateCommands(operations); + + // Assert — only continuous aggregate creation suppresses the transaction + Assert.All(commands, c => Assert.False(c.TransactionSuppressed)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs b/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs new file mode 100644 index 0000000..3a7ed96 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs @@ -0,0 +1,388 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Verifies that adding a naming convention (e.g. snake_case) on top of an existing migration produces a correct +/// diff: renamed objects are recognized as renames rather than drop-and-create, and policies that cascade away +/// when a continuous aggregate is recreated are re-added. These exercise the full orchestrator (the registered +/// IMigrationsModelDiffer), including EF Core's rename detection. +/// +public class MigrationDifferRenameTests : MigrationTestBase +{ + #region Should_Not_Recreate_Hypertable_When_Table_Renamed + + private class HtReading1 + { + public DateTime Time { get; set; } + public double Temperature { get; set; } + } + + private class HtInitialContext1 : DbContext + { + public DbSet WeatherReadings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + }); + } + + private class HtModifiedContext1 : DbContext + { + public DbSet WeatherReadings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + }); + } + + [Fact] + public void Should_Not_Recreate_Hypertable_When_Table_Renamed() + { + // Arrange + using HtInitialContext1 initial = new(); + using HtModifiedContext1 modified = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(initial, modified); + + // Assert + Assert.Contains(operations.OfType(), o => o.NewName == "weather_readings"); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Not_Alter_Compressed_Hypertable_When_Only_Renamed + + private class CompressedReading5 + { + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class CompressedInitialContext5 : DbContext + { + public DbSet DeviceMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time) + .EnableCompression() + .WithChunkSkipping(x => x.Time) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Time)]); + }); + } + + private class CompressedModifiedContext5 : DbContext + { + public DbSet DeviceMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time) + .EnableCompression() + .WithChunkSkipping(x => x.Time) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Time)]); + }); + } + + [Fact] + public void Should_Not_Alter_Compressed_Hypertable_When_Only_Renamed() + { + // Arrange + using CompressedInitialContext5 initial = new(); + using CompressedModifiedContext5 modified = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(initial, modified); + + // Assert + Assert.Contains(operations.OfType(), o => o.NewName == "device_metrics"); + Assert.Contains(operations.OfType(), o => o.NewName == "device_id"); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Not_Readd_ReorderPolicy_When_Table_Renamed + + private class ReorderReading2 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ReorderInitialContext2 : DbContext + { + public DbSet DeviceReadings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + entity.HasIndex(x => x.Time).HasDatabaseName("reorder_rename_idx"); + entity.WithReorderPolicy("reorder_rename_idx", null, "1 day", "00:00:00", 3, "00:05:00"); + }); + } + + private class ReorderModifiedContext2 : DbContext + { + public DbSet DeviceReadings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + entity.HasIndex(x => x.Time).HasDatabaseName("reorder_rename_idx"); + entity.WithReorderPolicy("reorder_rename_idx", null, "1 day", "00:00:00", 3, "00:05:00"); + }); + } + + [Fact] + public void Should_Not_Readd_ReorderPolicy_When_Table_Renamed() + { + // Arrange + using ReorderInitialContext2 initial = new(); + using ReorderModifiedContext2 modified = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(initial, modified); + + // Assert + Assert.Contains(operations.OfType(), o => o.NewName == "device_readings"); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Not_Churn_RetentionPolicy_When_Table_Renamed + + private class RetentionReading3 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class RetentionInitialContext3 : DbContext + { + public DbSet ApplicationLogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + entity.WithRetentionPolicy(dropAfter: "30 days", scheduleInterval: "1 day", maxRetries: 3, retryPeriod: "5 minutes"); + }); + } + + private class RetentionModifiedContext3 : DbContext + { + public DbSet ApplicationLogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + entity.WithRetentionPolicy(dropAfter: "30 days", scheduleInterval: "1 day", maxRetries: 3, retryPeriod: "5 minutes"); + }); + } + + [Fact] + public void Should_Not_Churn_RetentionPolicy_When_Table_Renamed() + { + // Arrange + using RetentionInitialContext3 initial = new(); + using RetentionModifiedContext3 modified = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(initial, modified); + + // Assert + Assert.Contains(operations.OfType(), o => o.NewName == "application_logs"); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Readd_Policies_When_ContinuousAggregate_Recreated + + private class CaggMetric4 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CaggAggregate4 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggInitialContext4 : DbContext + { + public DbSet SensorMetrics => Set(); + public DbSet SensorAggregates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sensor_hourly", "1 hour", x => x.Time) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "2 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.WithRetentionPolicy(dropAfter: "90 days", scheduleInterval: "1 day", maxRetries: 3, retryPeriod: "15 minutes"); + }); + } + } + + private class CaggModifiedContext4 : DbContext + { + public DbSet SensorMetrics => Set(); + public DbSet SensorAggregates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsHypertable(x => x.Time); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sensor_hourly", "1 hour", x => x.Time) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "2 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.WithRetentionPolicy(dropAfter: "90 days", scheduleInterval: "1 day", maxRetries: 3, retryPeriod: "15 minutes"); + }); + } + } + + [Fact] + public void Should_Readd_Policies_When_ContinuousAggregate_Recreated() + { + // Arrange + using CaggInitialContext4 initial = new(); + using CaggModifiedContext4 modified = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(initial, modified); + + // Assert + Assert.Contains(operations.OfType(), o => o.MaterializedViewName == "sensor_hourly"); + Assert.Contains(operations.OfType(), o => o.MaterializedViewName == "sensor_hourly"); + + AddContinuousAggregatePolicyOperation refresh = Assert.Single(operations.OfType()); + Assert.Equal("sensor_hourly", refresh.MaterializedViewName); + + AddRetentionPolicyOperation retention = Assert.Single(operations.OfType()); + Assert.Equal("sensor_hourly", retention.TableName); + + // The cascade already removed these, so no explicit remove/drop should be emitted for the view. + Assert.Empty(operations.OfType()); + Assert.DoesNotContain(operations.OfType(), o => o.TableName == "sensor_hourly"); + + // Re-adds must be ordered after the recreate. + int createIndex = IndexOf(operations); + Assert.True(createIndex < IndexOf(operations)); + Assert.True(createIndex < IndexOf(operations)); + } + + private static int IndexOf(IReadOnlyList operations) where T : MigrationOperation + { + for (int i = 0; i < operations.Count; i++) + { + if (operations[i] is T) + { + return i; + } + } + + return -1; + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs new file mode 100644 index 0000000..f32a6c8 --- /dev/null +++ b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs @@ -0,0 +1,223 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +/// +/// Pure unit tests for : the rename-resolution helpers and the +/// identity behaviour. +/// +public class FeatureDiffContextTests +{ + #region ResolveTable + + [Fact] + public void ResolveTable_Returns_Mapped_Value_When_Rename_Exists() + { + // Arrange + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [("public", "old_metrics")] = ("public", "new_metrics"), + }, + }; + + // Act + (string Schema, string Name) result = context.ResolveTable("public", "old_metrics"); + + // Assert + Assert.Equal("public", result.Schema); + Assert.Equal("new_metrics", result.Name); + } + + [Fact] + public void ResolveTable_Returns_Identity_When_No_Rename() + { + // Arrange + FeatureDiffContext context = new(); + + // Act + (string Schema, string Name) result = context.ResolveTable("public", "metrics"); + + // Assert + Assert.Equal("public", result.Schema); + Assert.Equal("metrics", result.Name); + } + + [Fact] + public void ResolveTable_Can_Map_To_Different_Schema() + { + // Arrange + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [("public", "metrics")] = ("analytics", "metrics"), + }, + }; + + // Act + (string Schema, string Name) result = context.ResolveTable("public", "metrics"); + + // Assert + Assert.Equal("analytics", result.Schema); + Assert.Equal("metrics", result.Name); + } + + #endregion + + #region ResolveIndex + + [Fact] + public void ResolveIndex_Returns_Mapped_Value_When_Rename_Exists() + { + // Arrange + FeatureDiffContext context = new() + { + IndexRenames = new Dictionary<(string, string), (string, string)> + { + [("public", "old_idx")] = ("public", "new_idx"), + }, + }; + + // Act + (string Schema, string Name) result = context.ResolveIndex("public", "old_idx"); + + // Assert + Assert.Equal("public", result.Schema); + Assert.Equal("new_idx", result.Name); + } + + [Fact] + public void ResolveIndex_Returns_Identity_When_No_Rename() + { + // Arrange + FeatureDiffContext context = new(); + + // Act + (string Schema, string Name) result = context.ResolveIndex("public", "metrics_idx"); + + // Assert + Assert.Equal("public", result.Schema); + Assert.Equal("metrics_idx", result.Name); + } + + #endregion + + #region ResolveColumn + + [Fact] + public void ResolveColumn_Returns_Mapped_Name_When_Rename_Exists() + { + // Arrange + FeatureDiffContext context = new() + { + ColumnRenames = new Dictionary<(string, string, string), string> + { + [("public", "metrics", "old_value")] = "new_value", + }, + }; + + // Act + string result = context.ResolveColumn("public", "metrics", "old_value"); + + // Assert + Assert.Equal("new_value", result); + } + + [Fact] + public void ResolveColumn_Returns_Identity_When_No_Rename() + { + // Arrange + FeatureDiffContext context = new(); + + // Act + string result = context.ResolveColumn("public", "metrics", "value"); + + // Assert + Assert.Equal("value", result); + } + + [Fact] + public void ResolveColumn_Keys_On_Post_Rename_Table_Name() + { + // Per the XML remark, the column-rename map is keyed by the POST-rename table name because EF Core + // emits RenameColumnOperation against the already-renamed table. Resolving with the new table name + // succeeds; resolving with the old table name does not. + + // Arrange + FeatureDiffContext context = new() + { + ColumnRenames = new Dictionary<(string, string, string), string> + { + [("public", "new_metrics", "old_value")] = "new_value", + }, + }; + + // Act + string resolvedWithNewTable = context.ResolveColumn("public", "new_metrics", "old_value"); + string resolvedWithOldTable = context.ResolveColumn("public", "old_metrics", "old_value"); + + // Assert + Assert.Equal("new_value", resolvedWithNewTable); + Assert.Equal("old_value", resolvedWithOldTable); // identity: old table name is not a key + } + + #endregion + + #region Empty + + [Fact] + public void Empty_Resolves_Every_Input_To_Itself() + { + // Arrange + FeatureDiffContext context = FeatureDiffContext.Empty; + + // Act & Assert + Assert.Equal(("public", "metrics"), context.ResolveTable("public", "metrics")); + Assert.Equal(("public", "metrics_idx"), context.ResolveIndex("public", "metrics_idx")); + Assert.Equal("value", context.ResolveColumn("public", "metrics", "value")); + } + + [Fact] + public void Empty_Has_No_Recreated_Aggregates() + { + // Arrange + FeatureDiffContext context = FeatureDiffContext.Empty; + + // Act & Assert + Assert.Empty(context.RecreatedAggregates); + } + + #endregion + + #region Schema normalization + + [Fact] + public void ResolveTable_Requires_Normalized_Schema_Key() + { + // The maps store concrete (normalized) schemas. Callers must normalize a missing schema to + // DefaultValues.DefaultSchema before building/querying. This test documents that resolving with the + // normalized schema key works, while a null/blank key misses the entry and resolves to identity. + + // Arrange + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [(DefaultValues.DefaultSchema, "old_metrics")] = (DefaultValues.DefaultSchema, "new_metrics"), + }, + }; + + // Act + (string Schema, string Name) resolvedWithNormalizedKey = context.ResolveTable(DefaultValues.DefaultSchema, "old_metrics"); + (string Schema, string Name) resolvedWithBlankKey = context.ResolveTable("", "old_metrics"); + + // Assert + Assert.Equal((DefaultValues.DefaultSchema, "new_metrics"), resolvedWithNormalizedKey); + Assert.Equal(("", "old_metrics"), resolvedWithBlankKey); // blank schema is not the stored key -> identity + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs new file mode 100644 index 0000000..723c1a4 --- /dev/null +++ b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs @@ -0,0 +1,262 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +/// +/// In-memory ordering tests that drive the real orchestrator (TimescaleMigrationsModelDiffer via +/// ) and assert relative ordering of returned operations for cases not +/// already covered by MigrationOperationOrderingTests. +/// +public class OperationOrderingTests +{ + private static IReadOnlyList GenerateMigrationOperations(DbContext? sourceContext, DbContext targetContext) + { + IMigrationsModelDiffer differ = targetContext.GetService(); + IRelationalModel? sourceModel = sourceContext?.GetService().Model.GetRelationalModel(); + IRelationalModel targetModel = targetContext.GetService().Model.GetRelationalModel(); + return differ.GetDifferences(sourceModel, targetModel); + } + + #region Should_Order_CreateHypertable_Then_CreateContinuousAggregate_Then_AddPolicy + + private class OrderMetricA + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderAggregateA + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CreateOrderContextA : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("order_metrics_a"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_view_a", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + }); + } + } + + [Fact] + public void Should_Order_CreateHypertable_Then_CreateContinuousAggregate_Then_AddPolicy() + { + // Arrange + using CreateOrderContextA targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(null, targetContext)]; + + // Assert + int hypertableIndex = operations.FindIndex(op => op is CreateHypertableOperation); + int createAggregateIndex = operations.FindIndex(op => op is CreateContinuousAggregateOperation); + int addPolicyIndex = operations.FindIndex(op => op is AddContinuousAggregatePolicyOperation); + + Assert.NotEqual(-1, hypertableIndex); + Assert.NotEqual(-1, createAggregateIndex); + Assert.NotEqual(-1, addPolicyIndex); + + Assert.True(hypertableIndex < createAggregateIndex, + $"CreateHypertable ({hypertableIndex}) should precede CreateContinuousAggregate ({createAggregateIndex})"); + Assert.True(createAggregateIndex < addPolicyIndex, + $"CreateContinuousAggregate ({createAggregateIndex}) should precede AddContinuousAggregatePolicy ({addPolicyIndex})"); + } + + #endregion + + #region Should_Order_DropContinuousAggregate_Before_DropHypertable + + private class OrderMetricB + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderAggregateB + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DropOrderSourceContextB : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("order_metrics_b"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_view_b", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class DropOrderTargetContextB : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + } + + [Fact] + public void Should_Order_DropContinuousAggregate_Before_DropHypertable() + { + // Arrange — source has a hypertable + a dependent continuous aggregate; target is empty. + using DropOrderSourceContextB sourceContext = new(); + using DropOrderTargetContextB targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert — the aggregate (depends on the hypertable) must be dropped before the parent table. + int dropAggregateIndex = operations.FindIndex(op => op is DropContinuousAggregateOperation); + int dropTableIndex = operations.FindIndex(op => + op is DropTableOperation dropTable && dropTable.Name == "order_metrics_b"); + + Assert.NotEqual(-1, dropAggregateIndex); + Assert.NotEqual(-1, dropTableIndex); + Assert.True(dropAggregateIndex < dropTableIndex, + $"DropContinuousAggregate ({dropAggregateIndex}) should precede DropTable ({dropTableIndex})"); + } + + #endregion + + #region Should_Order_Drop_Policies_Before_DropContinuousAggregate + + private class OrderMetricC + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderAggregateC + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DropPoliciesSourceContextC : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("order_metrics_c"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_view_c", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + private class DropPoliciesTargetContextC : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + } + + [Fact] + public void Should_Order_Drop_Policies_Before_DropContinuousAggregate() + { + // The retention and refresh policies depend on the continuous aggregate, so both drops must precede + // the DropContinuousAggregate. Per GetOperationPriority: DropRetentionPolicy (-60) and + // RemoveContinuousAggregatePolicy (-50) both come before DropContinuousAggregate (-40). + + // Arrange + using DropPoliciesSourceContextC sourceContext = new(); + using DropPoliciesTargetContextC targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + int dropRetentionIndex = operations.FindIndex(op => op is DropRetentionPolicyOperation); + int removeCaPolicyIndex = operations.FindIndex(op => op is RemoveContinuousAggregatePolicyOperation); + int dropAggregateIndex = operations.FindIndex(op => op is DropContinuousAggregateOperation); + + Assert.NotEqual(-1, dropRetentionIndex); + Assert.NotEqual(-1, removeCaPolicyIndex); + Assert.NotEqual(-1, dropAggregateIndex); + + Assert.True(dropRetentionIndex < dropAggregateIndex, + $"DropRetentionPolicy ({dropRetentionIndex}) should precede DropContinuousAggregate ({dropAggregateIndex})"); + Assert.True(removeCaPolicyIndex < dropAggregateIndex, + $"RemoveContinuousAggregatePolicy ({removeCaPolicyIndex}) should precede DropContinuousAggregate ({dropAggregateIndex})"); + + // The two policy drops are ordered relative to each other: retention (-60) before CA policy (-50). + Assert.True(dropRetentionIndex < removeCaPolicyIndex, + $"DropRetentionPolicy ({dropRetentionIndex}) should precede RemoveContinuousAggregatePolicy ({removeCaPolicyIndex})"); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs new file mode 100644 index 0000000..855282a --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs @@ -0,0 +1,141 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions +{ + /// + /// Unit tests for the typed continuous aggregate migration builder extensions. + /// + public class ContinuousAggregateMigrationExtensionsTests + { + #region CreateContinuousAggregate_MapsFunctionsToAnnotationStrings + + [Fact] + public void CreateContinuousAggregate_MapsFunctionsToAnnotationStrings() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + List functions = + [ + new("avg_t", EAggregateFunction.Avg, "temp"), + new("max_t", EAggregateFunction.Max, "temp"), + ]; + + // Act + mb.CreateContinuousAggregate( + materializedViewName: "hourly", + parentName: "sensor_data", + schema: "public", + chunkInterval: "7 days", + withNoData: true, + createGroupIndexes: true, + materializedOnly: true, + timeBucketWidth: "1 hour", + timeBucketSourceColumn: "ts", + timeBucketGroupBy: false, + aggregateFunctions: functions, + groupByColumns: ["device_id"], + whereClause: "temp > 0", + viewDefinition: "SELECT 1"); + + // Assert + CreateContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("hourly", op.MaterializedViewName); + Assert.Equal("sensor_data", op.ParentName); + Assert.Equal("public", op.Schema); + Assert.Equal("7 days", op.ChunkInterval); + Assert.True(op.WithNoData); + Assert.True(op.CreateGroupIndexes); + Assert.True(op.MaterializedOnly); + Assert.Equal("1 hour", op.TimeBucketWidth); + Assert.Equal("ts", op.TimeBucketSourceColumn); + Assert.False(op.TimeBucketGroupBy); + Assert.Equal(["avg_t:Avg:temp", "max_t:Max:temp"], op.AggregateFunctions); + Assert.Equal(["device_id"], op.GroupByColumns); + Assert.Equal("temp > 0", op.WhereClause); + Assert.Equal("SELECT 1", op.ViewDefinition); + } + + #endregion + + #region CreateContinuousAggregate_NullCollections_CoalesceToEmptyLists + + [Fact] + public void CreateContinuousAggregate_NullCollections_CoalesceToEmptyLists() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.CreateContinuousAggregate( + materializedViewName: "hourly", + parentName: "sensor_data", + aggregateFunctions: null, + groupByColumns: null); + + // Assert + CreateContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Empty(op.AggregateFunctions); + Assert.Empty(op.GroupByColumns); + Assert.Equal(string.Empty, op.Schema); + Assert.Equal(string.Empty, op.TimeBucketWidth); + Assert.Equal(string.Empty, op.TimeBucketSourceColumn); + // timeBucketGroupBy defaults to true. + Assert.True(op.TimeBucketGroupBy); + } + + #endregion + + #region AlterContinuousAggregate_MapsCurrentAndOldArguments + + [Fact] + public void AlterContinuousAggregate_MapsCurrentAndOldArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterContinuousAggregate( + materializedViewName: "hourly", + schema: "public", + chunkInterval: "7 days", + createGroupIndexes: true, + materializedOnly: true, + oldChunkInterval: "1 day", + oldCreateGroupIndexes: true, + oldMaterializedOnly: false); + + // Assert + AlterContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("hourly", op.MaterializedViewName); + Assert.Equal("7 days", op.ChunkInterval); + Assert.True(op.CreateGroupIndexes); + Assert.True(op.MaterializedOnly); + Assert.Equal("1 day", op.OldChunkInterval); + Assert.True(op.OldCreateGroupIndexes); + Assert.False(op.OldMaterializedOnly); + } + + #endregion + + #region DropContinuousAggregate_MapsArguments + + [Fact] + public void DropContinuousAggregate_MapsArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropContinuousAggregate(materializedViewName: "hourly", schema: "public"); + + // Assert + DropContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("hourly", op.MaterializedViewName); + Assert.Equal("public", op.Schema); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs new file mode 100644 index 0000000..b41714e --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs @@ -0,0 +1,117 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions +{ + /// + /// Unit tests for the typed continuous aggregate policy migration builder extensions. + /// + public class ContinuousAggregatePolicyMigrationExtensionsTests + { + #region AddContinuousAggregatePolicy_DefaultValues_MapExactly + + [Fact] + public void AddContinuousAggregatePolicy_DefaultValues_MapExactly() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act — only required argument; everything else falls to the signature defaults. + mb.AddContinuousAggregatePolicy(materializedViewName: "hourly"); + + // Assert + AddContinuousAggregatePolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("hourly", op.MaterializedViewName); + Assert.Equal(string.Empty, op.Schema); + Assert.Null(op.StartOffset); + Assert.Null(op.EndOffset); + Assert.Null(op.ScheduleInterval); + Assert.Null(op.InitialStart); + Assert.False(op.IfNotExists); + Assert.Null(op.IncludeTieredData); + Assert.Equal(1, op.BucketsPerBatch); + Assert.Equal(0, op.MaxBatchesPerExecution); + Assert.True(op.RefreshNewestFirst); + } + + #endregion + + #region AddContinuousAggregatePolicy_MapsAllArguments + + [Fact] + public void AddContinuousAggregatePolicy_MapsAllArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime start = new(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc); + + // Act + mb.AddContinuousAggregatePolicy( + materializedViewName: "hourly", + schema: "public", + startOffset: "1 month", + endOffset: "1 hour", + scheduleInterval: "1 hour", + initialStart: start, + ifNotExists: true, + includeTieredData: true, + bucketsPerBatch: 5, + maxBatchesPerExecution: 10, + refreshNewestFirst: false); + + // Assert + AddContinuousAggregatePolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("public", op.Schema); + Assert.Equal("1 month", op.StartOffset); + Assert.Equal("1 hour", op.EndOffset); + Assert.Equal("1 hour", op.ScheduleInterval); + Assert.Equal(start, op.InitialStart); + Assert.True(op.IfNotExists); + Assert.Equal(true, op.IncludeTieredData); + Assert.Equal(5, op.BucketsPerBatch); + Assert.Equal(10, op.MaxBatchesPerExecution); + Assert.False(op.RefreshNewestFirst); + } + + #endregion + + #region RemoveContinuousAggregatePolicy_MapsViewSchemaAndIfExists + + [Fact] + public void RemoveContinuousAggregatePolicy_MapsViewSchemaAndIfExists() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.RemoveContinuousAggregatePolicy(materializedViewName: "hourly", schema: "public", ifExists: true); + + // Assert + RemoveContinuousAggregatePolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("hourly", op.MaterializedViewName); + Assert.Equal("public", op.Schema); + Assert.True(op.IfExists); + } + + #endregion + + #region RemoveContinuousAggregatePolicy_Defaults + + [Fact] + public void RemoveContinuousAggregatePolicy_Defaults() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.RemoveContinuousAggregatePolicy(materializedViewName: "hourly"); + + // Assert + RemoveContinuousAggregatePolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + Assert.False(op.IfExists); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs new file mode 100644 index 0000000..5fd8dea --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/HypertableMigrationExtensionsTests.cs @@ -0,0 +1,153 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions +{ + /// + /// Unit tests for the typed CreateHypertable/AlterHypertable migration + /// builder extensions. Each test verifies argument-to-operation mapping and null + /// coalescing behavior. + /// + public class HypertableMigrationExtensionsTests + { + #region CreateHypertable_MapsAllArguments + + [Fact] + public void CreateHypertable_MapsAllArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + List dims = [Dimension.CreateRange("col", "1 day")]; + + // Act + OperationBuilder result = mb.CreateHypertable( + tableName: "sensor_data", + timeColumnName: "ts", + schema: "public", + chunkTimeInterval: "1 day", + enableCompression: true, + migrateData: true, + chunkSkipColumns: ["a", "b"], + additionalDimensions: dims, + compressionSegmentBy: ["device_id"], + compressionOrderBy: ["ts"]); + + // Assert + CreateHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.NotNull(result); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("ts", op.TimeColumnName); + Assert.Equal("public", op.Schema); + Assert.Equal("1 day", op.ChunkTimeInterval); + Assert.True(op.EnableCompression); + Assert.True(op.MigrateData); + Assert.Equal(["a", "b"], op.ChunkSkipColumns); + Assert.Same(dims, op.AdditionalDimensions); + Assert.Equal(["device_id"], op.CompressionSegmentBy); + Assert.Equal(["ts"], op.CompressionOrderBy); + } + + #endregion + + #region CreateHypertable_NullSchemaAndInterval_CoalesceToEmpty + + [Fact] + public void CreateHypertable_NullSchemaAndInterval_CoalesceToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.CreateHypertable(tableName: "sensor_data", timeColumnName: "ts", schema: null, chunkTimeInterval: null); + + // Assert + CreateHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + Assert.Equal(string.Empty, op.ChunkTimeInterval); + Assert.False(op.EnableCompression); + Assert.False(op.MigrateData); + Assert.Null(op.ChunkSkipColumns); + Assert.Null(op.AdditionalDimensions); + } + + #endregion + + #region CreateHypertable_ReturnsOperationBuilderForSameOperation + + [Fact] + public void CreateHypertable_ReturnsOperationBuilderForSameOperation() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + OperationBuilder result = mb.CreateHypertable(tableName: "t", timeColumnName: "ts"); + + // Assert + Assert.IsType>(result); + Assert.IsType(Assert.Single(mb.Operations)); + } + + #endregion + + #region AlterHypertable_MapsOldArguments + + [Fact] + public void AlterHypertable_MapsOldArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + List oldDims = [Dimension.CreateHash("col", 4)]; + + // Act + mb.AlterHypertable( + tableName: "sensor_data", + schema: "public", + chunkTimeInterval: "2 days", + enableCompression: true, + chunkSkipColumns: ["x"], + compressionSegmentBy: ["dev"], + compressionOrderBy: ["ts"], + oldChunkTimeInterval: "1 day", + oldEnableCompression: true, + oldChunkSkipColumns: ["y"], + oldAdditionalDimensions: oldDims, + oldCompressionSegmentBy: ["olddev"], + oldCompressionOrderBy: ["oldts"]); + + // Assert + AlterHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("2 days", op.ChunkTimeInterval); + Assert.True(op.EnableCompression); + Assert.Equal("1 day", op.OldChunkTimeInterval); + Assert.True(op.OldEnableCompression); + Assert.Equal(["y"], op.OldChunkSkipColumns); + Assert.Same(oldDims, op.OldAdditionalDimensions); + Assert.Equal(["olddev"], op.OldCompressionSegmentBy); + Assert.Equal(["oldts"], op.OldCompressionOrderBy); + } + + #endregion + + #region AlterHypertable_NullOldChunkTimeInterval_CoalescesToEmpty + + [Fact] + public void AlterHypertable_NullOldChunkTimeInterval_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterHypertable(tableName: "sensor_data", oldChunkTimeInterval: null); + + // Assert + AlterHypertableOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.OldChunkTimeInterval); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ReorderPolicyMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ReorderPolicyMigrationExtensionsTests.cs new file mode 100644 index 0000000..549b314 --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/ReorderPolicyMigrationExtensionsTests.cs @@ -0,0 +1,154 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions +{ + /// + /// Unit tests for the typed reorder policy migration builder extensions. + /// + public class ReorderPolicyMigrationExtensionsTests + { + #region AddReorderPolicy_MapsJobTuningArguments + + [Fact] + public void AddReorderPolicy_MapsJobTuningArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + mb.AddReorderPolicy( + tableName: "sensor_data", + indexName: "ix_ts", + schema: "public", + initialStart: start, + scheduleInterval: "1 day", + maxRuntime: "1 hour", + maxRetries: 3, + retryPeriod: "10 minutes"); + + // Assert + AddReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("ix_ts", op.IndexName); + Assert.Equal("public", op.Schema); + Assert.Equal(start, op.InitialStart); + Assert.Equal("1 day", op.ScheduleInterval); + Assert.Equal("1 hour", op.MaxRuntime); + Assert.Equal(3, op.MaxRetries); + Assert.Equal("10 minutes", op.RetryPeriod); + } + + #endregion + + #region AddReorderPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void AddReorderPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AddReorderPolicy(tableName: "t", indexName: "ix", schema: null); + + // Assert + AddReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + Assert.Null(op.InitialStart); + Assert.Null(op.ScheduleInterval); + Assert.Null(op.MaxRetries); + } + + #endregion + + #region AlterReorderPolicy_NullOldIndexName_CoalescesToEmpty + + [Fact] + public void AlterReorderPolicy_NullOldIndexName_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterReorderPolicy(tableName: "t", indexName: "new_ix", oldIndexName: null); + + // Assert + AlterReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("new_ix", op.IndexName); + Assert.Equal(string.Empty, op.OldIndexName); + } + + #endregion + + #region AlterReorderPolicy_MapsOldArguments + + [Fact] + public void AlterReorderPolicy_MapsOldArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime oldStart = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + mb.AlterReorderPolicy( + tableName: "t", + indexName: "new_ix", + oldIndexName: "old_ix", + oldInitialStart: oldStart, + oldScheduleInterval: "4 days", + oldMaxRuntime: "2 hours", + oldMaxRetries: 7, + oldRetryPeriod: "5 minutes"); + + // Assert + AlterReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("old_ix", op.OldIndexName); + Assert.Equal(oldStart, op.OldInitialStart); + Assert.Equal("4 days", op.OldScheduleInterval); + Assert.Equal("2 hours", op.OldMaxRuntime); + Assert.Equal(7, op.OldMaxRetries); + Assert.Equal("5 minutes", op.OldRetryPeriod); + } + + #endregion + + #region DropReorderPolicy_MapsTableAndSchema + + [Fact] + public void DropReorderPolicy_MapsTableAndSchema() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropReorderPolicy(tableName: "sensor_data", schema: "public"); + + // Assert + DropReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + } + + #endregion + + #region DropReorderPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void DropReorderPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropReorderPolicy(tableName: "sensor_data", schema: null); + + // Assert + DropReorderPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs new file mode 100644 index 0000000..cfa420a --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs @@ -0,0 +1,138 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions +{ + /// + /// Unit tests for the typed retention policy migration builder extensions. + /// + public class RetentionPolicyMigrationExtensionsTests + { + #region AddRetentionPolicy_WithDropAfter_MapsArguments + + [Fact] + public void AddRetentionPolicy_WithDropAfter_MapsArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime start = new(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + mb.AddRetentionPolicy( + tableName: "sensor_data", + schema: "public", + dropAfter: "30 days", + initialStart: start, + scheduleInterval: "1 day", + maxRuntime: "1 hour", + maxRetries: 2, + retryPeriod: "5 minutes"); + + // Assert + AddRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + Assert.Equal("30 days", op.DropAfter); + Assert.Null(op.DropCreatedBefore); + Assert.Equal(start, op.InitialStart); + Assert.Equal("1 day", op.ScheduleInterval); + Assert.Equal("1 hour", op.MaxRuntime); + Assert.Equal(2, op.MaxRetries); + Assert.Equal("5 minutes", op.RetryPeriod); + } + + #endregion + + #region AddRetentionPolicy_WithDropCreatedBefore_MapsArguments + + [Fact] + public void AddRetentionPolicy_WithDropCreatedBefore_MapsArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AddRetentionPolicy(tableName: "sensor_data", dropCreatedBefore: "60 days"); + + // Assert + AddRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("60 days", op.DropCreatedBefore); + Assert.Null(op.DropAfter); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + + #region AlterRetentionPolicy_MapsCurrentAndOldArguments + + [Fact] + public void AlterRetentionPolicy_MapsCurrentAndOldArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterRetentionPolicy( + tableName: "sensor_data", + schema: "public", + dropAfter: "60 days", + dropCreatedBefore: "90 days", + scheduleInterval: "1 day", + maxRetries: 4, + oldDropAfter: "30 days", + oldDropCreatedBefore: "45 days", + oldScheduleInterval: "4 days", + oldMaxRetries: 1); + + // Assert + AlterRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("60 days", op.DropAfter); + Assert.Equal("90 days", op.DropCreatedBefore); + Assert.Equal("1 day", op.ScheduleInterval); + Assert.Equal(4, op.MaxRetries); + Assert.Equal("30 days", op.OldDropAfter); + Assert.Equal("45 days", op.OldDropCreatedBefore); + Assert.Equal("4 days", op.OldScheduleInterval); + Assert.Equal(1, op.OldMaxRetries); + } + + #endregion + + #region DropRetentionPolicy_MapsTableAndSchema + + [Fact] + public void DropRetentionPolicy_MapsTableAndSchema() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropRetentionPolicy(tableName: "sensor_data", schema: "public"); + + // Assert + DropRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + } + + #endregion + + #region DropRetentionPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void DropRetentionPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropRetentionPolicy(tableName: "sensor_data", schema: null); + + // Assert + DropRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Utils/DesignTimeHelper.cs b/tests/Eftdb.Tests/Utils/DesignTimeHelper.cs new file mode 100644 index 0000000..c2ee334 --- /dev/null +++ b/tests/Eftdb.Tests/Utils/DesignTimeHelper.cs @@ -0,0 +1,19 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils +{ + internal static class DesignTimeHelper + { + public static ICSharpHelper CreateRealCSharpHelper() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + + ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + } +} From 71b7910398aba92d94675e89595c123807b52043 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 9 Jun 2026 13:14:15 +0200 Subject: [PATCH 3/5] docs: update architecture reference and agent definitions --- .claude/CLAUDE.md | 4 +- .claude/agents/eftdb-bug-fixer.md | 33 +++-- .claude/agents/eftdb-feature-implementer.md | 131 +++++++------------- .claude/agents/eftdb-feature-initializer.md | 2 +- .claude/agents/eftdb-scaffold-support.md | 2 +- .claude/agents/pr-code-reviewer.md | 6 +- .claude/agents/test-coverage-planner.md | 8 +- .claude/agents/test-writer.md | 7 +- .claude/reference/architecture.md | 98 +++++++++++---- .claude/reference/file-organization.md | 56 +++++++-- .claude/reference/patterns.md | 86 ++++++------- 11 files changed, 241 insertions(+), 192 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 04ca03f..145b68f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -86,8 +86,8 @@ private class TestContext(string connectionString) : DbContext { } | Service Registration | `UseTimescaleDb()` configures all services | `reference/patterns.md` | | Convention System | `IEntityTypeAddedConvention` processes attributes | `reference/patterns.md` | | Dual Configuration | Annotations + Fluent API → same annotations | `reference/patterns.md` | -| IFeatureDiffer | Per-feature differ with model extractor | `reference/patterns.md` | -| Runtime vs Design-Time | `isDesignTime` parameter changes quote escaping | `reference/patterns.md` | +| IFeatureDiffer | Per-feature differ with model extractor + `FeatureDiffContext` | `reference/patterns.md` | +| Runtime vs Design-Time | `*SqlGenerator` (SQL) vs `*CSharpGenerator` (typed migration calls) | `reference/patterns.md` | | Column Name Resolution | Always use `StoreObjectIdentifier` + `GetColumnName()` | `reference/patterns.md` | ## Agent Workflow diff --git a/.claude/agents/eftdb-bug-fixer.md b/.claude/agents/eftdb-bug-fixer.md index 2a37b13..c0fe457 100644 --- a/.claude/agents/eftdb-bug-fixer.md +++ b/.claude/agents/eftdb-bug-fixer.md @@ -1,6 +1,6 @@ --- name: eftdb-bug-fixer -description: Use this agent when bugs are discovered in existing runtime or design-time code within the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes:\n\n\nContext: User discovers a bug in the HypertableDiffer.\nuser: "The HypertableDiffer is not detecting changes to chunk time interval"\nassistant: "I'll use the eftdb-bug-fixer agent to analyze and fix the HypertableDiffer issue."\n\n\n\n\nContext: SQL generation is incorrect for reorder policies.\nuser: "The ReorderPolicyOperationGenerator is generating invalid SQL with wrong schema qualification"\nassistant: "I'll launch the eftdb-bug-fixer agent to fix the SQL generation bug in ReorderPolicyOperationGenerator."\n\n\n\n\nContext: Scaffolding extractor query is failing.\nuser: "The ContinuousAggregateScaffoldingExtractor is throwing NullReferenceException when extracting aggregate functions"\nassistant: "Let me use the eftdb-bug-fixer agent to debug and fix the scaffolding extractor."\n\n\n\n\nContext: Another agent reports a bug during its work.\nuser: "The eftdb-scaffold-support agent reported a mismatch between runtime annotations and scaffolding expectations"\nassistant: "I'll use the eftdb-bug-fixer agent to resolve the annotation mismatch issue reported by the scaffolding agent."\n\n +description: Use this agent when bugs are discovered in existing runtime or design-time code within the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes:\n\n\nContext: User discovers a bug in the HypertableDiffer.\nuser: "The HypertableDiffer is not detecting changes to chunk time interval"\nassistant: "I'll use the eftdb-bug-fixer agent to analyze and fix the HypertableDiffer issue."\n\n\n\n\nContext: SQL generation is incorrect for reorder policies.\nuser: "The ReorderPolicySqlGenerator is generating invalid SQL with wrong schema qualification"\nassistant: "I'll launch the eftdb-bug-fixer agent to fix the SQL generation bug in ReorderPolicySqlGenerator."\n\n\n\n\nContext: Scaffolding extractor query is failing.\nuser: "The ContinuousAggregateScaffoldingExtractor is throwing NullReferenceException when extracting aggregate functions"\nassistant: "Let me use the eftdb-bug-fixer agent to debug and fix the scaffolding extractor."\n\n\n\n\nContext: Another agent reports a bug during its work.\nuser: "The eftdb-scaffold-support agent reported a mismatch between runtime annotations and scaffolding expectations"\nassistant: "I'll use the eftdb-bug-fixer agent to resolve the annotation mismatch issue reported by the scaffolding agent."\n\n model: sonnet color: red --- @@ -38,7 +38,9 @@ You are an elite debugging and code quality specialist for the CmdScale.EntityFr - Identify which component is affected: - Model Extractor (reads annotations from EF model) - Differ (compares models and generates operations) - - Operation Generator (generates SQL/C# code) + - SQL Generator (`Generators/[Feature]SqlGenerator.cs` — runtime SQL) + - C# Generator (`Design/Generators/[Feature]CSharpGenerator.cs` — typed migration calls) + - Migration Extensions (`MigrationExtensions/[Feature]MigrationExtensions.cs`) - Scaffolding Extractor (queries TimescaleDB catalog) - Scaffolding Applier (applies annotations to scaffolded model) - Convention (converts attributes to annotations) @@ -72,10 +74,10 @@ Before fixing, understand WHY the bug exists: - Hard-coded column names instead of convention-aware resolution 3. **SQL Generation Bugs:** - - Quote string not respected (`isDesignTime` parameter ignored) + - Identifiers not quoted via `SqlBuilderHelper` (`Regclass`/`QualifiedIdentifier`/`QuoteIdentifier`) - Schema qualification missing or incorrect - SQL syntax errors for specific TimescaleDB functions - - Parameter escaping issues + - Missing `suppressTransaction` for DDL that cannot run in a transaction (continuous aggregates) 4. **Null Reference Issues:** - Missing null checks for optional properties @@ -88,9 +90,9 @@ Before fixing, understand WHY the bug exists: - Type conversion issues (string vs long for intervals) 6. **Design-Time vs Runtime Confusion:** - - Generator not handling `isDesignTime` parameter correctly - - Quote escaping wrong for C# string generation - - Operation registered in runtime but not in design-time generator + - Operation registered in the runtime `TimescaleDbMigrationsSqlGenerator` switch but not in the design-time `TimescaleCSharpMigrationOperationGenerator` switch (or vice versa) + - Missing `MigrationExtensions` method so generated migrations cannot call the operation + - Runtime SQL and design-time typed call producing inconsistent results ### Phase 3: Fix Implementation @@ -160,12 +162,12 @@ string columnName = property.GetColumnName(storeIdentifier); ``` ```csharp -// Bug: Quote string not used in SQL generation +// Bug: identifier not quoted via the helper // INCORRECT FIX - Hard-coded quotes string sql = $"SELECT * FROM \"{schema}\".\"{table}\""; -// CORRECT FIX - Use quote string field and SqlBuilderHelper -string qualifiedName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString); +// CORRECT FIX - Use SqlBuilderHelper +string qualifiedName = SqlBuilderHelper.QualifiedIdentifier(table, schema); string sql = $"SELECT * FROM {qualifiedName}"; ``` @@ -212,14 +214,11 @@ if (annotation.Value is not string expectedValue) ### For SQL Generation Issues: ```csharp -// Verify quote string usage -System.Diagnostics.Debug.WriteLine($"Quote string: '{_quoteString}'"); -System.Diagnostics.Debug.WriteLine($"Generated SQL: {sql}"); - -// Check if isDesignTime is propagated correctly -if (isDesignTime && !sql.Contains("\"\"")) +// Inspect the statements returned by the feature SqlGenerator +List statements = HypertableSqlGenerator.Generate(operation); +foreach (string statement in statements) { - // Likely bug: design-time should have doubled quotes + System.Diagnostics.Debug.WriteLine($"Generated SQL: {statement}"); } ``` diff --git a/.claude/agents/eftdb-feature-implementer.md b/.claude/agents/eftdb-feature-implementer.md index e0cf6c9..46a651b 100644 --- a/.claude/agents/eftdb-feature-implementer.md +++ b/.claude/agents/eftdb-feature-implementer.md @@ -11,7 +11,7 @@ You are an elite Entity Framework Core migrations architect specializing in the **PROJECT SCOPE RESTRICTION**: You MUST NOT modify code in any project except: - CmdScale.EntityFrameworkCore.TimescaleDB (primary work area) -- CmdScale.EntityFrameworkCore.TimescaleDB.Design (ONLY the TimescaleCSharpMigrationOperationGenerator.cs file) +- CmdScale.EntityFrameworkCore.TimescaleDB.Design (the `Generators/[Feature]CSharpGenerator.cs` file and `TimescaleCSharpMigrationOperationGenerator.cs`) Any attempt to modify other projects should result in immediate rejection with explanation. @@ -48,105 +48,58 @@ Implement the following components in this exact order: #### 2. Feature Differ (Internals/Features/[Feature]Differ.cs) -- Implement `IFeatureDiffer` interface -- Use the extractor to compare source and target models -- Generate appropriate operations (Create, Alter, Drop) based on differences -- Return `IEnumerable` with proper priority values: - - Priority 0: Standard EF operations - - Priority 10: CreateHypertableOperation - - Priority 20: Reorder policies - - Priority 30: Create continuous aggregates - - Priority 40: Alter/Drop continuous aggregates - - Choose appropriate priority for your feature based on dependencies -- Follow existing patterns from HypertableDiffer, ReorderPolicyDiffer, or ContinuousAggregateDiffer +- Implement `IFeatureDiffer`: `IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)` +- Normalize `context ??= FeatureDiffContext.Empty;` and use it to resolve renames (`ResolveTable`, `ResolveColumn`, `ResolveIndex`) so a rename is not treated as drop-and-create +- Use the extractor to compare source and target models, generating Create/Alter/Drop operations +- Operation ordering is handled centrally by `GetOperationPriority()` (see step 3) — the differ does not set priorities itself +- Follow existing patterns from HypertableDiffer, ReorderPolicyDiffer, RetentionPolicyDiffer, or ContinuousAggregateDiffer #### 3. Update TimescaleMigrationsModelDiffer (Internals/TimescaleMigrationsModelDiffer.cs) -- Register your new differ in the constructor's `_featureDiffers` list -- Ensure it's positioned correctly based on dependency order -- No other changes needed to this file +- Invoke your new differ in `GetDifferences()`, passing the shared `FeatureDiffContext` +- Add a `case` for each new operation type in `GetOperationPriority()` (drops negative, adds/alters positive; pick values matching the feature's dependency order — see the priority table in `reference/architecture.md`) -#### 4. Operation Generator (Generators/[Feature]OperationGenerator.cs) +#### 4. Runtime SQL Generator (Generators/[Feature]SqlGenerator.cs) -- Create a class that handles both SQL generation and C# code generation -- **CRITICAL**: Constructor MUST have `isDesignTime` parameter with default value `false`: - ```csharp - public FeatureOperationGenerator(bool isDesignTime = false) - { - _quoteString = isDesignTime ? "\"\"" : "\""; - } - ``` -- Use `_quoteString` for all string literals in SQL generation -- Implement these methods for each operation type: - - `Generate([Operation] operation, IModel? model, MigrationCommandListBuilder builder, bool isDesignTime)` - Runtime SQL - - `Generate([Operation] operation, CSharpMigrationOperationBuilder builder)` - Design-time C# code -- Use `SqlBuilderHelper` static methods for table names, schema handling, and identifier quoting: - - `GetQualifiedTableName(schema, table, quoteString)` for fully qualified names - - `GetSchemaPrefix(schema, quoteString)` for schema prefixing - - Always pass your `_quoteString` to these methods -- Follow SQL generation patterns from existing generators (HypertableOperationGenerator, ReorderPolicyOperationGenerator) - -#### 5. Update TimescaleDbMigrationsSqlGenerator (TimescaleDbMigrationsSqlGenerator.cs) - -- Add method to handle your operation type: - ```csharp - protected virtual void Generate([YourOperation] operation, IModel? model, MigrationCommandListBuilder builder) - { - var generator = new YourOperationGenerator(_isDesignTime); - generator.Generate(operation, model, builder, _isDesignTime); - } - ``` -- Store `isDesignTime` parameter in a field: `private readonly bool _isDesignTime;` -- Pass it through to generators +- Static class exposing `static List Generate(XxxOperation operation)` per operation type, returning TimescaleDB SQL statements +- Build identifiers with `SqlBuilderHelper.Regclass()`, `SqlBuilderHelper.QualifiedIdentifier()`, `SqlBuilderHelper.QuoteIdentifier()` +- For policy scheduling SQL (`alter_job` clauses), reuse `PolicyJobSqlBuilder` +- Follow existing generators (HypertableSqlGenerator, RetentionPolicySqlGenerator) -#### 6. Update TimescaleCSharpMigrationOperationGenerator (Design Project - ONLY FILE ALLOWED) +#### 5. Typed Migration Extensions (MigrationExtensions/[Feature]MigrationExtensions.cs) -- Add C# code generation method: - ```csharp - protected virtual void Generate([YourOperation] operation, CSharpMigrationOperationBuilder builder) - { - var generator = new YourOperationGenerator(isDesignTime: true); - generator.Generate(operation, builder); - } - ``` -- This is the ONLY file in the Design project you may modify +- Add extension methods on `MigrationBuilder` (declared in namespace `Microsoft.EntityFrameworkCore.Migrations`) that construct the operation and `migrationBuilder.Operations.Add(operation)` +- Return an `OperationBuilder` +- These are the methods generated migrations call (e.g. `migrationBuilder.CreateHypertable(...)`) + +#### 6. Register in TimescaleDbMigrationsSqlGenerator (TimescaleDbMigrationsSqlGenerator.cs) + +- Add a `case XxxOperation op:` to the `Generate` switch that calls `[Feature]SqlGenerator.Generate(op)` and assigns `statements` +- Set `suppressTransaction = true` for operations whose DDL cannot run in a transaction (e.g. continuous-aggregate creation) + +#### 7. Design-Time C# Generator (Design/Generators/[Feature]CSharpGenerator.cs + register) + +- `Generate(XxxOperation operation, IndentedStringBuilder builder)` emits the typed `migrationBuilder.[Method](...)` call using `MigrationCallWriter` and `CSharpGeneratorHelper` +- Emit a named `call.Arg("argName", code.Literal(...))` for each value, skipping defaults/empties +- Register the operation type in the `switch` in `TimescaleCSharpMigrationOperationGenerator.cs` ## Critical Technical Requirements -### Quote String Handling +### Runtime vs Design-Time Split -**This is ABSOLUTELY CRITICAL for runtime vs design-time duality:** +The two paths are independent and consume the same operation types: -- **Runtime Migrations** (`dotnet ef database update`): - - Quote string: `"` (single quote) - - Generates raw SQL that executes against database - -- **Design-Time Migrations** (`dotnet ef migrations add`): - - Quote string: `""` (doubled quotes) - - Generates C# code with escaped strings for migration files +- **Runtime** (`dotnet ef database update`): `TimescaleDbMigrationsSqlGenerator` → `[Feature]SqlGenerator.Generate(operation)` → SQL statements. +- **Design-time** (`dotnet ef migrations add`): `TimescaleCSharpMigrationOperationGenerator` → `[Feature]CSharpGenerator.Generate(operation, builder)` → typed `migrationBuilder.[Method](...)` calls. -**Implementation Pattern:** -```csharp -public class YourOperationGenerator -{ - private readonly string _quoteString; - - public YourOperationGenerator(bool isDesignTime = false) - { - _quoteString = isDesignTime ? "\"\"" : "\""; - } - - // Use _quoteString in all SQL generation - var tableName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString); -} -``` +Generators carry no `isDesignTime` flag and do no quote-doubling. ### SqlBuilderHelper Usage -ALWAYS use SqlBuilderHelper for: -- Table name qualification: `GetQualifiedTableName(schema, table, quoteString)` -- Schema prefixing: `GetSchemaPrefix(schema, quoteString)` -- Identifier quoting: Methods handle this internally when you pass quoteString +In `[Feature]SqlGenerator`, build identifiers with: +- `SqlBuilderHelper.Regclass(table, schema)` → `'schema."table"'` (for `create_hypertable` and other regclass arguments) +- `SqlBuilderHelper.QualifiedIdentifier(table, schema)` → `"schema"."table"` (for `ALTER TABLE` etc.) +- `SqlBuilderHelper.QuoteIdentifier(column)` → `"column"` NEVER manually construct qualified names or handle quoting yourself. @@ -243,10 +196,12 @@ Once the feature initializer completes, relaunch this agent to implement the mig Implemented Components: - Internals/Features/[Feature]/[Feature]ModelExtractor.cs - Internals/Features/[Feature]/[Feature]Differ.cs -- Generators/[Feature]OperationGenerator.cs -- Updated: Internals/TimescaleMigrationsModelDiffer.cs -- Updated: TimescaleDbMigrationsSqlGenerator.cs -- Updated: Design/TimescaleCSharpMigrationOperationGenerator.cs +- Generators/[Feature]SqlGenerator.cs +- MigrationExtensions/[Feature]MigrationExtensions.cs +- Design/Generators/[Feature]CSharpGenerator.cs +- Updated: Internals/TimescaleMigrationsModelDiffer.cs (differ invocation + GetOperationPriority cases) +- Updated: TimescaleDbMigrationsSqlGenerator.cs (Generate switch case) +- Updated: Design/TimescaleCSharpMigrationOperationGenerator.cs (Generate switch case) Operation Priority: [X] (rationale: [explanation]) diff --git a/.claude/agents/eftdb-feature-initializer.md b/.claude/agents/eftdb-feature-initializer.md index 5b6ad74..1ee0da6 100644 --- a/.claude/agents/eftdb-feature-initializer.md +++ b/.claude/agents/eftdb-feature-initializer.md @@ -141,7 +141,7 @@ Created Files: NEXT STEPS: → Use eftdb-feature-implementer agent to implement migration logic - (Creates: Differ, ModelExtractor, OperationGenerator) + (Creates: Differ, ModelExtractor, SqlGenerator, MigrationExtensions, CSharpGenerator) → Then use eftdb-scaffold-support agent for db-first scaffolding (Creates: ScaffoldingExtractor, AnnotationApplier) diff --git a/.claude/agents/eftdb-scaffold-support.md b/.claude/agents/eftdb-scaffold-support.md index 3cf2241..5df2753 100644 --- a/.claude/agents/eftdb-scaffold-support.md +++ b/.claude/agents/eftdb-scaffold-support.md @@ -16,7 +16,7 @@ You are ONLY permitted to work within: You are ABSOLUTELY FORBIDDEN from: - Modifying any files in other projects (Runtime, Tests, Example, etc.) - Fixing bugs you discover in other projects -- Changing operation generators, differs, or migration code +- Changing SQL/C# generators, migration extensions, differs, or migration code - Altering the core runtime library If you encounter bugs or missing functionality in other projects, you MUST: diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md index 5455968..941c01c 100644 --- a/.claude/agents/pr-code-reviewer.md +++ b/.claude/agents/pr-code-reviewer.md @@ -34,10 +34,12 @@ You are an expert code reviewer specializing in Entity Framework Core extensions 4. **Critical Pattern Verification** - **StoreObjectIdentifier Usage**: Confirm `GetColumnName(storeIdentifier)` is used for column name resolution to support naming conventions - - **Quote Escaping**: Verify `isDesignTime` parameter is correctly passed to SQL generators + - **Generator Split**: Verify runtime SQL lives in `Generators/[Feature]SqlGenerator.cs` and design-time output in `Design/Generators/[Feature]CSharpGenerator.cs`; identifiers use `SqlBuilderHelper` (`Regclass`/`QualifiedIdentifier`/`QuoteIdentifier`) + - **Migration Extensions**: Confirm `MigrationExtensions/[Feature]MigrationExtensions.cs` adds the operation to `migrationBuilder.Operations` + - **Diff Context**: Verify differs accept `FeatureDiffContext` and resolve renames via it - **Annotation Storage**: Check that feature metadata uses centralized annotation constants - **Default Values**: Ensure `DefaultValues.cs` constants are referenced instead of hardcoded values - - **Continuous Aggregate Encoding**: Validate colon-delimited aggregate function strings follow the correct format + - **Continuous Aggregate Encoding**: Validate `ContinuousAggregateFunction` values and the colon-delimited annotation format follow the correct format 5. **Project Structure Compliance** - Verify files are in correct namespaces and directories diff --git a/.claude/agents/test-coverage-planner.md b/.claude/agents/test-coverage-planner.md index e6a8a0d..d91b257 100644 --- a/.claude/agents/test-coverage-planner.md +++ b/.claude/agents/test-coverage-planner.md @@ -193,18 +193,18 @@ For detailed test writing patterns and anti-patterns, see the `test-writer` agen Key principles for coverage analysis: - Tests should verify EF Core provider integration, not TimescaleDB itself - Prioritize migration lifecycle simulation over raw SQL execution -- Cover both design-time (C# string escaping) and runtime (SQL) code paths +- Cover both design-time (typed migration call) and runtime (SQL) code paths - Ensure naming convention support (snake_case, PascalCase, custom) ## Technical Considerations - **Naming Convention Support**: Ensure tests cover snake_case, camelCase, PascalCase, and custom conventions -- **Design-Time vs Runtime**: Test both `isDesignTime: true` and `isDesignTime: false` code paths +- **Design-Time vs Runtime**: Test both the runtime `[Feature]SqlGenerator` (SQL) and the design-time `[Feature]CSharpGenerator` (typed migration calls) paths - **Edge Cases**: Null values, empty collections, invalid configurations, malformed SQL - **Error Handling**: Exception scenarios, validation failures, database errors - **Cross-Feature**: Interactions between hypertables, continuous aggregates, and reorder policies -- **Quote Escaping**: Verify correct escaping for both SQL (`"table"`) and C# strings (`""table""`) -- **Schema Qualification**: Test `regclass()` formatting and qualified table names +- **Identifier Quoting**: Verify `SqlBuilderHelper` quoting (`"table"`, `'schema."table"'`) in generated SQL +- **Schema Qualification**: Test `Regclass()` formatting and qualified table names - **Column Name Resolution**: Test `StoreObjectIdentifier` and `GetColumnName()` with various naming conventions ## Your Constraints diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 5e79925..5109e76 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -201,17 +201,16 @@ Assert.NotNull(result); Assert.Equal(expectedValue, result.Property); ``` -**Testing SQL Generation** (Functional Tests): +**Testing SQL Generation**: ```csharp // Arrange -using TimescaleDbTestContainer container = new(); CreateHypertableOperation operation = new() { /* ... */ }; // Act -string sql = generator.Generate(operation, model, builder, isDesignTime: false); +List statements = HypertableSqlGenerator.Generate(operation); // Assert -Assert.Contains("SELECT create_hypertable", sql); +Assert.Contains(statements, s => s.Contains("SELECT create_hypertable")); ``` ### Column Name Convention Support diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 65619f7..604040f 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -54,6 +54,12 @@ This document provides detailed architectural information for the CmdScale.Entit - `ReorderPolicyAnnotations.cs` - Annotation constants - `ReorderPolicyTypeBuilder.cs` - Fluent API: `WithReorderPolicy()` +#### RetentionPolicy/ (4 files) +- `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy(DropAfter = "30 days")]` +- `RetentionPolicyConvention.cs` - IEntityTypeAddedConvention implementation +- `RetentionPolicyAnnotations.cs` - Annotation constants +- `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()` + #### ContinuousAggregate/ (8 files) - `ContinuousAggregateAttribute.cs` - Entity-level attribute defining materialized view - `TimeBucketAttribute.cs` - Property-level attribute for time bucketing @@ -76,7 +82,8 @@ This document provides detailed architectural information for the CmdScale.Entit |------|---------| | `Dimension.cs` | Represents range/hash partitioning with factory methods | | `EDimensionType.cs` | Enum: `Range`, `Hash` | -| `EAggregateFunction.cs` | Enum: `Avg`, `Sum`, `Min`, `Max`, `Count`, `First`, `Last` |W +| `EAggregateFunction.cs` | Enum: `Avg`, `Sum`, `Min`, `Max`, `Count`, `First`, `Last` | +| `ContinuousAggregateFunction.cs` | Strongly-typed `(Alias, Function, SourceColumn)` for continuous-aggregate columns; `ToAnnotationValue()` serializes to the `alias:Function:sourceColumn` wire format | ### Operations/ - Migration Operations @@ -84,6 +91,7 @@ All inherit `MigrationOperation` and contain feature-specific properties: - `CreateHypertableOperation.cs` / `AlterHypertableOperation.cs` - `AddReorderPolicyOperation.cs` / `AlterReorderPolicyOperation.cs` / `DropReorderPolicyOperation.cs` +- `AddRetentionPolicyOperation.cs` / `AlterRetentionPolicyOperation.cs` / `DropRetentionPolicyOperation.cs` - `CreateContinuousAggregateOperation.cs` / `AlterContinuousAggregateOperation.cs` / `DropContinuousAggregateOperation.cs` - `AddContinuousAggregatePolicyOperation.cs` / `RemoveContinuousAggregatePolicyOperation.cs` @@ -101,26 +109,53 @@ These are runtime-only — they have no in-memory implementation and throw when The plugin is registered in `TimescaleDbServiceCollectionExtensions.AddEntityFrameworkTimescaleDb()` via `.TryAdd()`. -### Generators/ - SQL and C# Code Generation +### Generators/ - Runtime SQL Generation + +Each `*SqlGenerator` exposes `static List Generate(XxxOperation operation)` and returns TimescaleDB SQL statements. `TimescaleDbMigrationsSqlGenerator` switches on the operation type, calls the matching generator, and passes the statements to `SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform)`. `CreateContinuousAggregateOperation` is emitted with `suppressTransaction: true` (continuous-aggregate DDL cannot run inside a transaction block). | File | Purpose | |------|---------| -| `HypertableOperationGenerator.cs` | Generates `create_hypertable()`, `set_chunk_time_interval()`, etc. | -| `ReorderPolicyOperationGenerator.cs` | Generates `add_reorder_policy()`, `remove_reorder_policy()`, etc. | -| `ContinuousAggregateOperationGenerator.cs` | Generates materialized view SQL | -| `SqlBuilderHelper.cs` | Quote handling utilities (`isDesignTime` parameter critical) | +| `HypertableSqlGenerator.cs` | `create_hypertable()`, `set_chunk_time_interval()`, `add_dimension()`, compression/chunk-skipping SQL | +| `ReorderPolicySqlGenerator.cs` | `add_reorder_policy()`, `remove_reorder_policy()`, `alter_job` tuning | +| `RetentionPolicySqlGenerator.cs` | `add_retention_policy()`, `remove_retention_policy()`, `alter_job` tuning | +| `ContinuousAggregateSqlGenerator.cs` | `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` plus drop/alter SQL | +| `ContinuousAggregatePolicySqlGenerator.cs` | `add_continuous_aggregate_policy()` / `remove_continuous_aggregate_policy()` | +| `PolicyJobSqlBuilder.cs` | Shared `alter_job` clause builder (schedule interval, max runtime, retries, retry period) used by reorder/retention/CA refresh policies | +| `SqlBuilderHelper.cs` | `Regclass()`, `QualifiedIdentifier()`, `QuoteIdentifier()`, statement grouping, and `SELECT`→`PERFORM` rewriting for idempotent scripts | + +### MigrationExtensions/ - Typed migrationBuilder API + +Generated migrations call strongly-typed extension methods that construct a `MigrationOperation` and add it to `migrationBuilder.Operations`. Methods are declared in the `Microsoft.EntityFrameworkCore.Migrations` namespace so they are available in migration files without extra `using` directives. + +| File | Methods | +|------|---------| +| `HypertableMigrationExtensions.cs` | `CreateHypertable(...)`, `AlterHypertable(...)` | +| `ReorderPolicyMigrationExtensions.cs` | `AddReorderPolicy(...)`, `AlterReorderPolicy(...)`, `DropReorderPolicy(...)` | +| `RetentionPolicyMigrationExtensions.cs` | `AddRetentionPolicy(...)`, `AlterRetentionPolicy(...)`, `DropRetentionPolicy(...)` | +| `ContinuousAggregateMigrationExtensions.cs` | `CreateContinuousAggregate(...)`, `AlterContinuousAggregate(...)`, `DropContinuousAggregate(...)` | +| `ContinuousAggregatePolicyMigrationExtensions.cs` | `AddContinuousAggregatePolicy(...)`, `RemoveContinuousAggregatePolicy(...)` | ### Internals/ - Core Diffing Logic -- `TimescaleMigrationsModelDiffer.cs` - Extends EF Core's MigrationsModelDiffer, implements `GetOperationPriority()` -- `Features/IFeatureDiffer.cs` - Interface: `GetDifferences(IRelationalModel? source, IRelationalModel? target)` +- `TimescaleMigrationsModelDiffer.cs` - Extends EF Core's MigrationsModelDiffer; orchestrates the feature differs, builds the `FeatureDiffContext`, implements `GetOperationPriority()` +- `Features/IFeatureDiffer.cs` - Interface: `GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)` +- `Features/FeatureDiffContext.cs` - Cross-cutting diff state passed to every feature differ **Feature-specific:** - `Features/Hypertables/` - `HypertableDiffer.cs`, `HypertableModelExtractor.cs` - `Features/ReorderPolicies/` - `ReorderPolicyDiffer.cs`, `ReorderPolicyModelExtractor.cs` +- `Features/RetentionPolicies/` - `RetentionPolicyDiffer.cs`, `RetentionPolicyModelExtractor.cs` - `Features/ContinuousAggregates/` - `ContinuousAggregateDiffer.cs`, `ContinuousAggregateModelExtractor.cs` - `Features/ContinuousAggregatePolicies/` - `ContinuousAggregatePolicyDiffer.cs`, `ContinuousAggregatePolicyModelExtractor.cs` +#### FeatureDiffContext + +`TimescaleMigrationsModelDiffer` runs EF Core's base differ first, builds a `FeatureDiffContext` from the resulting operations, and passes it to every feature differ. It carries: + +- **TableRenames / IndexRenames / ColumnRenames** - maps built from EF's `RenameTableOperation` / `RenameIndexOperation` / `RenameColumnOperation` so feature differs treat a rename as a rename rather than drop-and-create. Schemas are normalized to `DefaultValues.DefaultSchema`. Resolve via `ResolveTable()`, `ResolveIndex()`, `ResolveColumn()`. +- **RecreatedAggregates** - continuous aggregates being dropped and recreated in this diff, populated by `PopulateRecreatedAggregates` after the continuous-aggregate differ runs. Recreating a continuous aggregate cascades to drop its refresh and retention policies, so dependent policy differs re-add those policies even when their config is unchanged. +- `FeatureDiffContext.Empty` - identity context used when a differ runs without orchestration (e.g. unit tests). + ### DefaultValues.cs - Centralized Constants ```csharp @@ -143,8 +178,22 @@ ReorderPolicyMaxRuntime = "00:00:00" // no limit ### TimescaleCSharpMigrationOperationGenerator.cs - Generates C# code for `dotnet ef migrations add` -- Calls operation generators with `isDesignTime: true` -- Outputs `.Sql(@"...")` calls in migration Up/Down methods +- Switches on the operation type and delegates to the matching `*CSharpGenerator` (constructed with `Dependencies.CSharpHelper`) +- Emits typed `migrationBuilder.CreateHypertable(...)` / `AddRetentionPolicy(...)` / etc. calls in migration Up/Down methods + +### Generators/ - Design-Time C# Generation + +Each `*CSharpGenerator.Generate(XxxOperation, IndentedStringBuilder)` emits one typed `migrationBuilder` call, with one named argument per line. + +| File | Purpose | +|------|---------| +| `HypertableCSharpGenerator.cs` | Emits `CreateHypertable(...)` / `AlterHypertable(...)` | +| `ReorderPolicyCSharpGenerator.cs` | Emits `AddReorderPolicy(...)` / `AlterReorderPolicy(...)` / `DropReorderPolicy(...)` | +| `RetentionPolicyCSharpGenerator.cs` | Emits `AddRetentionPolicy(...)` / `AlterRetentionPolicy(...)` / `DropRetentionPolicy(...)` | +| `ContinuousAggregateCSharpGenerator.cs` | Emits `CreateContinuousAggregate(...)` / `AlterContinuousAggregate(...)` / `DropContinuousAggregate(...)` | +| `ContinuousAggregatePolicyCSharpGenerator.cs` | Emits `AddContinuousAggregatePolicy(...)` / `RemoveContinuousAggregatePolicy(...)` | +| `MigrationCallWriter.cs` | `IDisposable` helper that writes a `.Method(` call and named `arg: value` lines | +| `CSharpGeneratorHelper.cs` | `LiteralStringList()` for `["a", "b"]` collection expressions and `StaticCall()` for `Type.Method(args)` literals | ### TimescaleDatabaseModelFactory.cs @@ -172,15 +221,22 @@ Orchestrates db-first scaffolding with extractor/applier pairs: ## Migration Operation Priority Ordering -Custom operations are prioritized by `TimescaleMigrationsModelDiffer.GetOperationPriority()`: - -| Priority | Operation Type | Reason | -|----------|---------------|--------| -| 0 | Standard EF operations | CreateTable, AddColumn, DropColumn, etc. | -| 10 | `CreateHypertableOperation` | Tables must exist first | -| 20 | Reorder policy operations | Hypertables must exist | -| 30 | `CreateContinuousAggregateOperation` | Source hypertables must exist | -| 40 | Alter/Drop continuous aggregate | Last to ensure dependencies exist | +Custom operations are sorted by `TimescaleMigrationsModelDiffer.GetOperationPriority()`. Drop operations get negative priorities (run before standard EF table drops, in reverse dependency order); add/alter operations get positive priorities (run after standard EF table creation, in dependency order). + +| Priority | Operation Type | +|----------|---------------| +| -60 | `DropRetentionPolicyOperation` | +| -50 | `RemoveContinuousAggregatePolicyOperation` | +| -40 | `DropContinuousAggregateOperation` | +| -20 | `DropReorderPolicyOperation` | +| 0 | Standard EF operations (CreateTable, AddColumn, DropTable, …) | +| 10 | `CreateHypertableOperation` | +| 15 | `AlterHypertableOperation` | +| 20 | `AddReorderPolicyOperation` / `AlterReorderPolicyOperation` | +| 30 | `CreateContinuousAggregateOperation` | +| 40 | `AlterContinuousAggregateOperation` | +| 50 | `AddContinuousAggregatePolicyOperation` | +| 60 | `AddRetentionPolicyOperation` / `AlterRetentionPolicyOperation` | ## Continuous Aggregates Implementation Details @@ -190,9 +246,9 @@ Continuous aggregates are materialized views that automatically refresh: - **ParentName:** Entity name of source hypertable (resolved to table name via EF metadata) - **TimeBucketWidth:** Time interval for bucketing (e.g., "1 day", "1 hour") - **TimeBucketSourceColumn:** Time column to bucket on (resolved to database column name) -- **AggregateFunctions:** Colon-delimited strings (see patterns.md) +- **AggregateFunctions:** `ContinuousAggregateFunction` values in the typed API; stored as colon-delimited strings on the operation (see patterns.md) - **GroupByColumns:** Column names for GROUP BY -- **WhereClause:** Raw SQL for filtering (partially implemented) +- **WhereClause:** Raw SQL for filtering, emitted verbatim into the materialized view's `WHERE`. Identifiers are passed through unchanged, so quoted column references must match the resolved database column names. **SQL Generation Special Cases:** - `first()`/`last()` functions require time ordering column: `first(price, timestamp ORDER BY timestamp)` diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 5d92cd3..be8c448 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -24,7 +24,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/Hypertable/HypertableConvention.cs` | Convention processing | | `Internals/Features/Hypertables/HypertableDiffer.cs` | Diffing logic | | `Internals/Features/Hypertables/HypertableModelExtractor.cs` | Model extraction | -| `Generators/HypertableOperationGenerator.cs` | SQL/C# generation | +| `Generators/HypertableSqlGenerator.cs` | Runtime SQL generation | +| `MigrationExtensions/HypertableMigrationExtensions.cs` | Typed migrationBuilder methods | | `Operations/CreateHypertableOperation.cs` | Migration operation | | `Operations/AlterHypertableOperation.cs` | Migration operation | @@ -38,11 +39,28 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/ReorderPolicy/ReorderPolicyConvention.cs` | Convention processing | | `Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs` | Diffing logic | | `Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs` | Model extraction | -| `Generators/ReorderPolicyOperationGenerator.cs` | SQL/C# generation | +| `Generators/ReorderPolicySqlGenerator.cs` | Runtime SQL generation | +| `MigrationExtensions/ReorderPolicyMigrationExtensions.cs` | Typed migrationBuilder methods | | `Operations/AddReorderPolicyOperation.cs` | Migration operation | | `Operations/AlterReorderPolicyOperation.cs` | Migration operation | | `Operations/DropReorderPolicyOperation.cs` | Migration operation | +### Retention Policy + +| File | Purpose | +|------|---------| +| `Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs` | Fluent API | +| `Configuration/RetentionPolicy/RetentionPolicyAnnotations.cs` | Annotation constants | +| `Configuration/RetentionPolicy/RetentionPolicyAttribute.cs` | Data annotation | +| `Configuration/RetentionPolicy/RetentionPolicyConvention.cs` | Convention processing | +| `Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs` | Diffing logic | +| `Internals/Features/RetentionPolicies/RetentionPolicyModelExtractor.cs` | Model extraction | +| `Generators/RetentionPolicySqlGenerator.cs` | Runtime SQL generation | +| `MigrationExtensions/RetentionPolicyMigrationExtensions.cs` | Typed migrationBuilder methods | +| `Operations/AddRetentionPolicyOperation.cs` | Migration operation | +| `Operations/AlterRetentionPolicyOperation.cs` | Migration operation | +| `Operations/DropRetentionPolicyOperation.cs` | Migration operation | + ### Continuous Aggregate | File | Purpose | @@ -56,7 +74,9 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs` | Convention processing | | `Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs` | Diffing logic | | `Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs` | Model extraction | -| `Generators/ContinuousAggregateOperationGenerator.cs` | SQL/C# generation | +| `Generators/ContinuousAggregateSqlGenerator.cs` | Runtime SQL generation | +| `MigrationExtensions/ContinuousAggregateMigrationExtensions.cs` | Typed migrationBuilder methods | +| `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value | | `Operations/CreateContinuousAggregateOperation.cs` | Migration operation | | `Operations/AlterContinuousAggregateOperation.cs` | Migration operation | | `Operations/DropContinuousAggregateOperation.cs` | Migration operation | @@ -72,6 +92,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs` | Builder extensions | | `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs` | Diffing logic | | `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs` | Model extraction | +| `Generators/ContinuousAggregatePolicySqlGenerator.cs` | Runtime SQL generation | +| `MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs` | Typed migrationBuilder methods | | `Operations/AddContinuousAggregatePolicyOperation.cs` | Migration operation | | `Operations/RemoveContinuousAggregatePolicyOperation.cs` | Migration operation | @@ -88,19 +110,29 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | File | Purpose | |------|---------| -| `Internals/TimescaleMigrationsModelDiffer.cs` | Operation prioritization | +| `Internals/TimescaleMigrationsModelDiffer.cs` | Differ orchestration, context building, operation prioritization | | `Internals/Features/IFeatureDiffer.cs` | Differ interface | -| `Generators/SqlBuilderHelper.cs` | Quote handling, regclass | +| `Internals/Features/FeatureDiffContext.cs` | Cross-cutting diff state (renames, recreated aggregates) | +| `Generators/SqlBuilderHelper.cs` | Identifier quoting, regclass, command grouping, SELECT→PERFORM | +| `Generators/PolicyJobSqlBuilder.cs` | Shared `alter_job` clause builder for policies | | `DefaultValues.cs` | Centralized defaults | | `Abstractions/Dimension.cs` | Range/hash partitioning | | `Abstractions/EAggregateFunction.cs` | Aggregate function enum | +| `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value | ## Design Library Key Files | File | Purpose | |------|---------| | `TimescaleDBDesignTimeServices.cs` | Register design-time services | -| `TimescaleCSharpMigrationOperationGenerator.cs` | C# code generation for migrations | +| `TimescaleCSharpMigrationOperationGenerator.cs` | Dispatches operations to the `*CSharpGenerator` classes | +| `Generators/HypertableCSharpGenerator.cs` | Emits `CreateHypertable`/`AlterHypertable` calls | +| `Generators/ReorderPolicyCSharpGenerator.cs` | Emits reorder-policy calls | +| `Generators/RetentionPolicyCSharpGenerator.cs` | Emits retention-policy calls | +| `Generators/ContinuousAggregateCSharpGenerator.cs` | Emits continuous-aggregate calls | +| `Generators/ContinuousAggregatePolicyCSharpGenerator.cs` | Emits CA-policy calls | +| `Generators/MigrationCallWriter.cs` | Writes a `.Method(arg: value, …)` call | +| `Generators/CSharpGeneratorHelper.cs` | Collection-expression and static-call literal helpers | | `TimescaleDatabaseModelFactory.cs` | Db-first scaffolding orchestration | | `Scaffolding/ITimescaleFeatureExtractor.cs` | Extractor interface | | `Scaffolding/IAnnotationApplier.cs` | Applier interface | @@ -108,6 +140,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Scaffolding/HypertableAnnotationApplier.cs` | Apply hypertable annotations | | `Scaffolding/ReorderPolicyScaffoldingExtractor.cs` | Query reorder policies from database | | `Scaffolding/ReorderPolicyAnnotationApplier.cs` | Apply reorder policy annotations | +| `Scaffolding/RetentionPolicyScaffoldingExtractor.cs` | Query retention policies from database | +| `Scaffolding/RetentionPolicyAnnotationApplier.cs` | Apply retention policy annotations | | `Scaffolding/ContinuousAggregateScaffoldingExtractor.cs` | Query continuous aggregates | | `Scaffolding/ContinuousAggregateAnnotationApplier.cs` | Apply continuous aggregate annotations | | `build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets` | MSBuild integration | @@ -137,20 +171,24 @@ src/ │ │ ├── ContinuousAggregate/ │ │ ├── ContinuousAggregatePolicy/ │ │ ├── Hypertable/ -│ │ └── ReorderPolicy/ -│ ├── Generators/ # SQL and C# code generation +│ │ ├── ReorderPolicy/ +│ │ └── RetentionPolicy/ +│ ├── Generators/ # Runtime SQL generation +│ ├── MigrationExtensions/ # Typed migrationBuilder.* methods │ ├── Internals/ # Core diffing logic │ │ └── Features/ │ │ ├── ContinuousAggregates/ │ │ ├── ContinuousAggregatePolicies/ │ │ ├── Hypertables/ -│ │ └── ReorderPolicies/ +│ │ ├── ReorderPolicies/ +│ │ └── RetentionPolicies/ │ ├── Operations/ # Migration operations │ ├── Query/ # EF.Functions extensions and LINQ translators │ │ └── Internal/ # EF Core query pipeline integration │ └── *.cs # Entry points, extensions │ └── Eftdb.Design/ # Design-time library (CmdScale.EntityFrameworkCore.TimescaleDB.Design) + ├── Generators/ # Design-time C# (typed migration call) generation ├── Scaffolding/ # Extractors and appliers ├── build/ # MSBuild targets └── *.cs # Design-time services diff --git a/.claude/reference/patterns.md b/.claude/reference/patterns.md index 814a621..24b1060 100644 --- a/.claude/reference/patterns.md +++ b/.claude/reference/patterns.md @@ -44,33 +44,36 @@ Both approaches store identical annotation values in entity type metadata. ## 4. IFeatureDiffer Pattern -Each TimescaleDB feature has a dedicated differ implementing `IFeatureDiffer`. The differ uses a corresponding `*ModelExtractor` static class to read annotations from the source and target models, then compares them to generate appropriate migration operations (Create, Alter, Drop). +Each TimescaleDB feature has a dedicated differ implementing `IFeatureDiffer`. The differ uses a corresponding `*ModelExtractor` static class to read annotations from the source and target models, then compares them to generate appropriate migration operations (Create, Alter, Drop). A `FeatureDiffContext` carries rename maps and recreated-aggregate state the differ cannot derive on its own (see architecture.md). Example (`HypertableDiffer`): ```csharp public class HypertableDiffer : IFeatureDiffer { - public IEnumerable GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { + context ??= FeatureDiffContext.Empty; HypertableInfo? sourceInfo = HypertableModelExtractor.Extract(source); HypertableInfo? targetInfo = HypertableModelExtractor.Extract(target); - return CompareDifferences(sourceInfo, targetInfo); + return CompareDifferences(sourceInfo, targetInfo, context); } } ``` -All differs are registered in `TimescaleMigrationsModelDiffer`'s `_featureDiffers` list. +`TimescaleMigrationsModelDiffer.GetDifferences()` runs EF Core's base differ, builds the `FeatureDiffContext`, then invokes each feature differ with it. **Location:** `Internals/Features/{Feature}/` — check the source for the full list of feature differs. ## 5. Runtime vs Design-Time Duality -| Context | Generator | Quote String | isDesignTime | -|---------|-----------|--------------|--------------| -| Runtime (`dotnet ef database update`) | `TimescaleDbMigrationsSqlGenerator` | `"` | `false` | -| Design-time (`dotnet ef migrations add`) | `TimescaleCSharpMigrationOperationGenerator` | `""` | `true` | +The same custom `MigrationOperation` types feed two independent code paths: -Both use the same operation generators with different `isDesignTime` parameter values. +| Context | Entry point | Generators | Output | +|---------|-------------|------------|--------| +| Runtime (`dotnet ef database update`) | `TimescaleDbMigrationsSqlGenerator` | `Generators/*SqlGenerator` | TimescaleDB SQL statements | +| Design-time (`dotnet ef migrations add`) | `TimescaleCSharpMigrationOperationGenerator` | `Design/Generators/*CSharpGenerator` | Typed `migrationBuilder.*` calls | + +The design-time path emits typed calls (e.g. `migrationBuilder.CreateHypertable(...)`) from `MigrationExtensions/`; those operations are turned into SQL by the runtime path at `database update` time. ## 6. Annotation-Based Metadata Storage @@ -114,44 +117,39 @@ This automatically handles snake_case, camelCase, PascalCase, and custom naming **Location:** `Internals/Features/{Feature}/{Feature}ModelExtractor.cs` -## 8. SQL Generation with Quote Escaping +## 8. SQL Building Helpers -**SqlBuilderHelper** provides utilities for proper quoting: +`*SqlGenerator` classes build identifiers and table references through `SqlBuilderHelper`: ```csharp -// Runtime SQL (isDesignTime = false) -string sql = SqlBuilderHelper.BuildQueryString("SELECT * FROM \"my_table\"", builder, isDesignTime: false); -// Output: SELECT * FROM "my_table" - -// Design-time C# (isDesignTime = true) -string csharp = SqlBuilderHelper.BuildQueryString("SELECT * FROM \"my_table\"", builder, isDesignTime: true); -// Output: SELECT * FROM ""my_table"" (quotes doubled for C# string escaping) +SqlBuilderHelper.Regclass("my_table", "custom_schema"); // 'custom_schema."my_table"' +SqlBuilderHelper.QualifiedIdentifier("my_table", "custom_schema"); // "custom_schema"."my_table" +SqlBuilderHelper.QuoteIdentifier("my_column"); // "my_column" ``` -**Critical:** Always pass `isDesignTime` parameter correctly to operation generators. +`SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform)` groups the generated statements into commands and appends them to the `MigrationCommandListBuilder`. When `usePerform` is set (idempotent scripts), leading `SELECT` keywords are rewritten to `PERFORM` so the SQL is valid inside a PL/pgSQL block. -**Location:** `Generators/SqlBuilderHelper.cs` +**Location:** `Generators/SqlBuilderHelper.cs`, `Generators/PolicyJobSqlBuilder.cs` -## 9. Continuous Aggregate String Encoding +## 9. Continuous Aggregate Function Encoding -Aggregate functions are stored as colon-delimited strings in annotations: +The typed API uses `Abstractions/ContinuousAggregateFunction` — `(Alias, Function, SourceColumn)` — for each aggregate column: -**Format:** -- Basic: `"alias:function:sourceColumn"` -- First/Last: `"alias:function:sourceColumn:timeColumn"` - -**Examples:** ```csharp -// Avg aggregate -"avg_price:Avg:price" - -// Last aggregate with time column -"last_price:Last:price:timestamp" +new ContinuousAggregateFunction("average_price", EAggregateFunction.Avg, "price") ``` +`ToAnnotationValue()` serializes it to the colon-delimited wire format stored on `CreateContinuousAggregateOperation.AggregateFunctions`: + +**Format:** +- Basic: `"alias:Function:sourceColumn"` +- First/Last: `"alias:Function:sourceColumn:timeColumn"` + +**Examples:** `"average_price:Avg:price"`, `"last_price:Last:price:timestamp"` + **Parsing:** Split by `:` and validate array length (3 or 4 elements). -**Location:** `ContinuousAggregateModelExtractor.cs`, `ContinuousAggregateOperationGenerator.cs` +**Location:** `Abstractions/ContinuousAggregateFunction.cs`, `ContinuousAggregateModelExtractor.cs`, `Generators/ContinuousAggregateSqlGenerator.cs` ## 10. Expression-Based Configuration @@ -186,19 +184,19 @@ Lambda expressions are parsed to extract property names (via `LambdaExpression.B ## 11. DRY Principle Implementation -- Extract common logic into helper methods (`SqlBuilderHelper`) +- Extract common logic into helper methods (`SqlBuilderHelper`, `PolicyJobSqlBuilder`) - Centralize constants in `DefaultValues.cs` and annotation name classes - Use `StoreObjectIdentifier` pattern consistently across extractors -- Avoid duplicating SQL generation logic - use operation generators consistently +- Avoid duplicating SQL generation logic - route it through the `*SqlGenerator` classes ```csharp // Correct - Centralized helper -string tableName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString); +string qualifiedName = SqlBuilderHelper.QualifiedIdentifier(table, schema); // Incorrect - Duplicated logic -string tableName = string.IsNullOrEmpty(schema) - ? $"{_quoteString}{table}{_quoteString}" - : $"{_quoteString}{schema}{_quoteString}.{_quoteString}{table}{_quoteString}"; +string qualifiedName = string.IsNullOrEmpty(schema) + ? $"\"{table}\"" + : $"\"{schema}\".\"{table}\""; ``` ## 12. Separation of Concerns @@ -210,8 +208,10 @@ Keep each class focused on a single responsibility: | Configuration | User-facing APIs | Attributes, Fluent API, Conventions | | Model Extraction | Read from EF metadata | `*ModelExtractor` classes | | Diffing | Compare models, generate operations | `*Differ` classes | -| Generation | Convert operations to SQL/C# | `*OperationGenerator` classes | -| Design-time | Reverse engineer from database | Scaffolding extractors/appliers | +| Runtime SQL | Convert operations to SQL | `Generators/*SqlGenerator` classes | +| Design-time C# | Convert operations to typed migration calls | `Design/Generators/*CSharpGenerator` classes | +| Migration API | Construct operations from migration files | `MigrationExtensions/*MigrationExtensions` classes | +| Scaffolding | Reverse engineer from database | Scaffolding extractors/appliers | **Never mix concerns:** Extractors should not generate SQL, differs should not read databases. @@ -219,12 +219,12 @@ Keep each class focused on a single responsibility: // Correct - Separation of concerns public class HypertableDiffer : IFeatureDiffer { - public IEnumerable GetDifferences(IRelationalModel? source, IRelationalModel? target) + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) { // Only diffing logic - delegates extraction to HypertableModelExtractor HypertableInfo? sourceInfo = HypertableModelExtractor.Extract(source); HypertableInfo? targetInfo = HypertableModelExtractor.Extract(target); - return CompareDifferences(sourceInfo, targetInfo); + return CompareDifferences(sourceInfo, targetInfo, context ?? FeatureDiffContext.Empty); } } ``` From 1bc4b1ba3d2e6aba274940c8003c6bb753964926 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 9 Jun 2026 13:14:37 +0200 Subject: [PATCH 4/5] docs: update dotnet-tools migration examples to the typed API --- docs/01-dotnet-tools.md | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/01-dotnet-tools.md b/docs/01-dotnet-tools.md index 2ad2782..87c55dc 100644 --- a/docs/01-dotnet-tools.md +++ b/docs/01-dotnet-tools.md @@ -17,7 +17,7 @@ dotnet add package CmdScale.EntityFrameworkCore.TimescaleDB.Design --version 0.2 ## Code-First: Generating Migrations -When using the code-first approach, the design package ensures that your hypertable configurations, compression settings, and policies are correctly translated into SQL within your migration files. +When using the code-first approach, the design package ensures that your hypertable configurations, compression settings, and policies are correctly translated into TimescaleDB migration calls within your migration files. ### Usage @@ -28,7 +28,7 @@ dotnet ef migrations add --project --startup-p ``` ### Example -The generated 'Up' method will contain standard table creation logic, followed by a 'migrationBuilder.Sql()' call containing all the necessary Sql-statements to configure the hypertable and its features in TimescaleDB. +The generated 'Up' method will contain standard table creation logic, followed by typed `migrationBuilder` extension calls (such as `CreateHypertable`) that configure the hypertable and its features. These calls are translated into the required TimescaleDB SQL when the migration is applied. ```csharp protected override void Up(MigrationBuilder migrationBuilder) @@ -49,32 +49,33 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_DeviceReadings", x => new { x.Id, x.Time }); }); - // SQL generated by CmdScale.EntityFrameworkCore.TimescaleDB.Design - migrationBuilder.Sql(@" - SELECT create_hypertable('""DeviceReadings""', 'Time'); - SELECT set_chunk_time_interval('""DeviceReadings""', INTERVAL '1 day'); - ALTER TABLE ""DeviceReadings"" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('""DeviceReadings""', 'Time'); - "); + // TimescaleDB configuration generated by CmdScale.EntityFrameworkCore.TimescaleDB.Design + migrationBuilder.CreateHypertable( + tableName: "DeviceReadings", + timeColumnName: "Time", + chunkTimeInterval: "1 day", + enableCompression: true, + chunkSkipColumns: ["Time"]); } ``` -The tool also correctly generates `Down` migrations to revert changes, such as modifying a chunk time interval. +The tool also correctly generates `Down` migrations to revert changes, such as modifying a chunk time interval. An altered hypertable emits an `AlterHypertable` call that carries both the new value and the previous one (`oldChunkTimeInterval`) so the change is fully reversible. ```csharp protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql(@" - SELECT set_chunk_time_interval('""TradesWithId""', INTERVAL '7 days'); - "); + migrationBuilder.AlterHypertable( + tableName: "TradesWithId", + chunkTimeInterval: "7 days", + oldChunkTimeInterval: "2 day"); } protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql(@" - SELECT set_chunk_time_interval('""TradesWithId""', INTERVAL '2 day'); - "); + migrationBuilder.AlterHypertable( + tableName: "TradesWithId", + chunkTimeInterval: "2 day", + oldChunkTimeInterval: "7 days"); } ``` From 97327b0f3eeacc89bbd8eb8fddede59b06e166ed Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 9 Jun 2026 15:28:51 +0200 Subject: [PATCH 5/5] test: add CSharpGenerator tests --- ...ContinuousAggregateCSharpGeneratorTests.cs | 78 +++++++++ .../HypertableCSharpGeneratorTests.cs | 81 ++++++++++ .../ReorderPolicyCSharpGeneratorTests.cs | 81 ++++++++++ .../RetentionPolicyCSharpGeneratorTests.cs | 87 ++++++++++ .../TimescaleDbMigrationsSqlGeneratorTests.cs | 47 ++++++ .../Internals/OperationOrderingTests.cs | 149 ++++++++++++++++++ 6 files changed, 523 insertions(+) diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs index 57c122a..9f083a2 100644 --- a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs @@ -133,6 +133,84 @@ public void CreateContinuousAggregate_GroupByColumns_EmitCollectionExpression() #endregion + #region CreateContinuousAggregate_FullyPopulated_EmitsAllOptionalArgs + + [Fact] + public void CreateContinuousAggregate_FullyPopulated_EmitsAllOptionalArgs() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + Schema = "metrics", + ChunkInterval = "7 days", + WithNoData = true, + CreateGroupIndexes = true, + MaterializedOnly = true, + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "ts", + TimeBucketGroupBy = false, + AggregateFunctions = ["avg_t:Avg:temp"], + GroupByColumns = ["device_id"], + WhereClause = "temp > 0", + ViewDefinition = "SELECT 1", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("chunkInterval: \"7 days\"", result); + Assert.Contains("withNoData: true", result); + Assert.Contains("createGroupIndexes: true", result); + Assert.Contains("materializedOnly: true", result); + Assert.Contains("timeBucketWidth: \"1 hour\"", result); + Assert.Contains("timeBucketSourceColumn: \"ts\"", result); + Assert.Contains("timeBucketGroupBy: false", result); + Assert.Contains("aggregateFunctions:", result); + Assert.Contains("groupByColumns: [\"device_id\"]", result); + Assert.Contains("whereClause: \"temp > 0\"", result); + Assert.Contains("viewDefinition: \"SELECT 1\"", result); + } + + #endregion + + #region AlterContinuousAggregate_FullyPopulated_EmitsAllNewAndOldArgs + + [Fact] + public void AlterContinuousAggregate_FullyPopulated_EmitsAllNewAndOldArgs() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + Schema = "metrics", + ChunkInterval = "7 days", + CreateGroupIndexes = true, + MaterializedOnly = true, + OldChunkInterval = "1 day", + OldCreateGroupIndexes = true, + OldMaterializedOnly = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("chunkInterval: \"7 days\"", result); + Assert.Contains("createGroupIndexes: true", result); + Assert.Contains("materializedOnly: true", result); + + // Assert + Assert.Contains("oldChunkInterval: \"1 day\"", result); + Assert.Contains("oldCreateGroupIndexes: true", result); + Assert.Contains("oldMaterializedOnly: true", result); + } + + #endregion + #region AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault [Fact] diff --git a/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs index c572f97..3e8371d 100644 --- a/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs @@ -190,6 +190,87 @@ public void AlterHypertable_EmitsOldArgsOnlyWhenNonDefault() #endregion + #region CreateHypertable_FullyPopulated_EmitsMigrateDataAndAllCompressionLists + + [Fact] + public void CreateHypertable_FullyPopulated_EmitsMigrateDataAndAllCompressionLists() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + Schema = "metrics", + ChunkTimeInterval = "1 day", + EnableCompression = true, + MigrateData = true, + ChunkSkipColumns = ["a", "b"], + AdditionalDimensions = [Dimension.CreateHash("device_id", 4)], + CompressionSegmentBy = ["device_id"], + CompressionOrderBy = ["ts DESC"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("migrateData: true", result); + Assert.Contains("enableCompression: true", result); + Assert.Contains("chunkSkipColumns: [\"a\", \"b\"]", result); + Assert.Contains("compressionSegmentBy: [\"device_id\"]", result); + Assert.Contains("compressionOrderBy: [\"ts DESC\"]", result); + Assert.Contains("additionalDimensions:", result); + } + + #endregion + + #region AlterHypertable_FullyPopulated_EmitsAllNewAndOldLists + + [Fact] + public void AlterHypertable_FullyPopulated_EmitsAllNewAndOldLists() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + ChunkTimeInterval = "2 days", + EnableCompression = true, + ChunkSkipColumns = ["a", "b"], + AdditionalDimensions = [Dimension.CreateHash("device_id", 4)], + CompressionSegmentBy = ["device_id"], + CompressionOrderBy = ["ts DESC"], + OldChunkTimeInterval = "1 day", + OldEnableCompression = true, + OldChunkSkipColumns = ["a"], + OldAdditionalDimensions = [Dimension.CreateRange("region", "10")], + OldCompressionSegmentBy = ["region"], + OldCompressionOrderBy = ["ts"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("chunkTimeInterval: \"2 days\"", result); + Assert.Contains("enableCompression: true", result); + Assert.Contains("chunkSkipColumns: [\"a\", \"b\"]", result); + Assert.Contains("compressionSegmentBy: [\"device_id\"]", result); + Assert.Contains("compressionOrderBy: [\"ts DESC\"]", result); + Assert.Contains("additionalDimensions:", result); + + // Assert + Assert.Contains("oldChunkTimeInterval: \"1 day\"", result); + Assert.Contains("oldEnableCompression: true", result); + Assert.Contains("oldChunkSkipColumns: [\"a\"]", result); + Assert.Contains("oldCompressionSegmentBy: [\"region\"]", result); + Assert.Contains("oldCompressionOrderBy: [\"ts\"]", result); + Assert.Contains("oldAdditionalDimensions:", result); + } + + #endregion + #region AlterHypertable_OmitsOldArgsWhenDefault [Fact] diff --git a/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs index 03228f9..6415966 100644 --- a/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs @@ -89,6 +89,87 @@ public void AlterReorderPolicy_EmitsOldArgsOnlyWhenNonDefault() #endregion + #region AddReorderPolicy_FullyPopulated_EmitsAllOptionalArgs + + [Fact] + public void AddReorderPolicy_FullyPopulated_EmitsAllOptionalArgs() + { + // Arrange + AddReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "ix_ts", + Schema = "metrics", + InitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduleInterval = "1 day", + MaxRuntime = "5 minutes", + MaxRetries = 3, + RetryPeriod = "10 minutes", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("indexName: \"ix_ts\"", result); + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("scheduleInterval: \"1 day\"", result); + Assert.Contains("maxRuntime: \"5 minutes\"", result); + Assert.Contains("maxRetries: 3", result); + Assert.Contains("retryPeriod: \"10 minutes\"", result); + } + + #endregion + + #region AlterReorderPolicy_FullyPopulated_EmitsAllNewAndOldArgs + + [Fact] + public void AlterReorderPolicy_FullyPopulated_EmitsAllNewAndOldArgs() + { + // Arrange + AlterReorderPolicyOperation op = new() + { + TableName = "sensor_data", + IndexName = "new_ix", + Schema = "metrics", + InitialStart = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduleInterval = "2 days", + MaxRuntime = "10 minutes", + MaxRetries = 5, + RetryPeriod = "15 minutes", + OldIndexName = "old_ix", + OldInitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + OldScheduleInterval = "1 day", + OldMaxRuntime = "5 minutes", + OldMaxRetries = 3, + OldRetryPeriod = "10 minutes", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("indexName: \"new_ix\"", result); + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("scheduleInterval: \"2 days\"", result); + Assert.Contains("maxRuntime: \"10 minutes\"", result); + Assert.Contains("maxRetries: 5", result); + Assert.Contains("retryPeriod: \"15 minutes\"", result); + + // Assert. + Assert.Contains("oldIndexName: \"old_ix\"", result); + Assert.Contains("oldInitialStart:", result); + Assert.Contains("oldScheduleInterval: \"1 day\"", result); + Assert.Contains("oldMaxRuntime: \"5 minutes\"", result); + Assert.Contains("oldMaxRetries: 3", result); + Assert.Contains("oldRetryPeriod: \"10 minutes\"", result); + } + + #endregion + #region DropReorderPolicy_OmitsEmptySchema [Fact] diff --git a/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs index 13c5e81..89dc323 100644 --- a/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/RetentionPolicyCSharpGeneratorTests.cs @@ -99,5 +99,92 @@ public void AlterRetentionPolicy_EmitsOldArgsOnlyWhenNonDefault() } #endregion + + #region AddRetentionPolicy_FullyPopulated_EmitsAllOptionalArgs + + [Fact] + public void AddRetentionPolicy_FullyPopulated_EmitsAllOptionalArgs() + { + // Arrange + AddRetentionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + DropAfter = "30 days", + DropCreatedBefore = "60 days", + InitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduleInterval = "1 day", + MaxRuntime = "5 minutes", + MaxRetries = 3, + RetryPeriod = "10 minutes", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("dropAfter: \"30 days\"", result); + Assert.Contains("dropCreatedBefore: \"60 days\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("scheduleInterval: \"1 day\"", result); + Assert.Contains("maxRuntime: \"5 minutes\"", result); + Assert.Contains("maxRetries: 3", result); + Assert.Contains("retryPeriod: \"10 minutes\"", result); + } + + #endregion + + #region AlterRetentionPolicy_FullyPopulated_EmitsAllNewAndOldArgs + + [Fact] + public void AlterRetentionPolicy_FullyPopulated_EmitsAllNewAndOldArgs() + { + // Arrange + AlterRetentionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + DropAfter = "60 days", + DropCreatedBefore = "90 days", + InitialStart = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + ScheduleInterval = "2 days", + MaxRuntime = "10 minutes", + MaxRetries = 5, + RetryPeriod = "15 minutes", + OldDropAfter = "30 days", + OldDropCreatedBefore = "45 days", + OldInitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + OldScheduleInterval = "1 day", + OldMaxRuntime = "5 minutes", + OldMaxRetries = 3, + OldRetryPeriod = "10 minutes", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("dropAfter: \"60 days\"", result); + Assert.Contains("dropCreatedBefore: \"90 days\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("scheduleInterval: \"2 days\"", result); + Assert.Contains("maxRuntime: \"10 minutes\"", result); + Assert.Contains("maxRetries: 5", result); + Assert.Contains("retryPeriod: \"15 minutes\"", result); + + // Assert + Assert.Contains("oldDropAfter: \"30 days\"", result); + Assert.Contains("oldDropCreatedBefore: \"45 days\"", result); + Assert.Contains("oldInitialStart:", result); + Assert.Contains("oldScheduleInterval: \"1 day\"", result); + Assert.Contains("oldMaxRuntime: \"5 minutes\"", result); + Assert.Contains("oldMaxRetries: 3", result); + Assert.Contains("oldRetryPeriod: \"10 minutes\"", result); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs index dd1f351..309d6b8 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs @@ -183,6 +183,53 @@ public void Should_Suppress_Transaction_For_CreateContinuousAggregate() #endregion + #region Should_Generate_Command_For_AlterContinuousAggregate + + [Fact] + public void Should_Generate_Command_For_AlterContinuousAggregate() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + Schema = "public", + MaterializedViewName = "daily_avg", + ChunkInterval = "7 days", + OldChunkInterval = "1 day" + }; + List operations = [operation]; + + // Act + string sql = GenerateSql(operations); + + // Assert + Assert.Contains("ALTER MATERIALIZED VIEW", sql); + Assert.Contains("timescaledb.chunk_interval", sql); + } + + #endregion + + #region Should_Generate_Command_For_DropContinuousAggregate + + [Fact] + public void Should_Generate_Command_For_DropContinuousAggregate() + { + // Arrange + DropContinuousAggregateOperation operation = new() + { + Schema = "public", + MaterializedViewName = "daily_avg" + }; + List operations = [operation]; + + // Act + string sql = GenerateSql(operations); + + // Assert + Assert.Contains("DROP MATERIALIZED VIEW IF EXISTS", sql); + } + + #endregion + #region Should_Not_Suppress_Transaction_For_CreateHypertable [Fact] diff --git a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs index 723c1a4..86b4271 100644 --- a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs +++ b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs @@ -2,6 +2,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; @@ -259,4 +260,152 @@ public void Should_Order_Drop_Policies_Before_DropContinuousAggregate() } #endregion + + #region Should_Treat_Index_Rename_As_Rename_Through_Orchestrator + + private class IndexRenameMetricD + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IndexRenameSourceContextD : 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("idx_rename_metrics_d"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("idx_rename_old_d"); + entity.WithReorderPolicy("idx_rename_old_d"); + }); + } + + private class IndexRenameTargetContextD : 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("idx_rename_metrics_d"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("idx_rename_new_d"); + entity.WithReorderPolicy("idx_rename_new_d"); + }); + } + + [Fact] + public void Should_Treat_Index_Rename_As_Rename_Through_Orchestrator() + { + // Arrange + using IndexRenameSourceContextD sourceContext = new(); + using IndexRenameTargetContextD targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + Assert.Contains(operations.OfType(), o => o.NewName == "idx_rename_new_d"); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Order_AlterHypertable_After_CreateHypertable_Through_Orchestrator + + private class AlterHtMetricE + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlterHtNewMetricE + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AlterHtSourceContextE : 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("alter_ht_metrics_e"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + + private class AlterHtTargetContextE : DbContext + { + public DbSet Metrics => Set(); + public DbSet NewMetrics => 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("alter_ht_metrics_e"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("alter_ht_new_metrics_e"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Order_AlterHypertable_After_CreateHypertable_Through_Orchestrator() + { + // Arrange + using AlterHtSourceContextE sourceContext = new(); + using AlterHtTargetContextE targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + AlterHypertableOperation alterOp = Assert.Single(operations.OfType()); + Assert.Equal("alter_ht_metrics_e", alterOp.TableName); + Assert.Equal("7 days", alterOp.OldChunkTimeInterval); + Assert.Equal("1 day", alterOp.ChunkTimeInterval); + + int createHypertableIndex = operations.FindIndex(op => op is CreateHypertableOperation); + int alterHypertableIndex = operations.FindIndex(op => op is AlterHypertableOperation); + Assert.True(createHypertableIndex >= 0, "Expected a CreateHypertableOperation for the new hypertable."); + Assert.True(createHypertableIndex < alterHypertableIndex, "CreateHypertable (10) must be ordered before AlterHypertable (15)."); + } + + #endregion }