diff --git a/docs/data-annotations/continuous-aggregates.md b/docs/data-annotations/continuous-aggregates.md index d72d344..b3daab8 100644 --- a/docs/data-annotations/continuous-aggregates.md +++ b/docs/data-annotations/continuous-aggregates.md @@ -207,15 +207,20 @@ By default, continuous aggregates are populated with data when created. Set to ` ### CreateGroupIndexes -Controls whether indexes are automatically created on GROUP BY columns. Enabled by default: +Controls whether TimescaleDB creates indexes on the GROUP BY columns when the aggregate is created. Maps to the `timescaledb.create_group_indexes` option: + +- **Unconfigured** — the `CreateGroupIndexes` property is never set — the option is omitted from `CREATE MATERIALIZED VIEW`, so TimescaleDB's server default applies. +- Setting **`CreateGroupIndexes = true`** or **`CreateGroupIndexes = false`** emits the option explicitly. ```csharp [ContinuousAggregate( MaterializedViewName = "trade_stats", ParentName = nameof(Trade), - CreateGroupIndexes = true)] + CreateGroupIndexes = false)] ``` +> :warning: **Note:** `timescaledb.create_group_indexes` is a create-only option in TimescaleDB — `ALTER MATERIALIZED VIEW` rejects it. Only transitions to or from `false` are structural: because the server default is `true`, unconfigured and explicit `true` produce identical databases, so adding or removing a redundant `CreateGroupIndexes = true` is a no-op that never touches an existing aggregate. Changing the value to or from `false` on an **existing** aggregate drops and recreates it (see [Migration Ordering](../fluent-api/continuous-aggregates#migration-ordering)), which rematerializes it and, in a hierarchy, cascades the drop to every descendant. If the source hypertable's retention policy has already dropped the raw data covered by the aggregate, that history is lost on recreate. The flag is not recoverable from an existing database, so scaffolded aggregates never carry an explicit `CreateGroupIndexes` value. + ### MaterializedOnly By default, queries combine materialized data with recent unmaterialized data from the source hypertable. Set to `true` to return only pre-computed materialized data: diff --git a/docs/fluent-api/continuous-aggregates.md b/docs/fluent-api/continuous-aggregates.md index 427a68a..4256d59 100644 --- a/docs/fluent-api/continuous-aggregates.md +++ b/docs/fluent-api/continuous-aggregates.md @@ -285,7 +285,7 @@ public class TradeDaily Ordering across the chain is handled automatically: - Parents are created before their children; children are dropped before their parents. -- A structural change to a parent (bucket width, bucket column name, aggregate functions, GROUP BY, or WHERE) drops and recreates all of its descendants as well, and their refresh policies are re-added afterwards. +- A structural change to a parent (bucket width, bucket column name, aggregate functions, GROUP BY, WHERE, or the create-only create_group_indexes option) drops and recreates all of its descendants as well, and their refresh policies are re-added afterwards. ### Scaffolding @@ -310,7 +310,10 @@ builder.IsContinuousAggregate( ### CreateGroupIndexes -Control whether indexes are automatically created on GROUP BY columns. Enabled by default: +Controls whether TimescaleDB creates indexes on the GROUP BY columns when the aggregate is created. This maps to the `timescaledb.create_group_indexes` option: + +- **Unconfigured** — no `.CreateGroupIndexes(...)` call — the option is omitted from `CREATE MATERIALIZED VIEW`, so TimescaleDB's server default applies. +- **`.CreateGroupIndexes(true)`** and **`.CreateGroupIndexes(false)`** emit the option explicitly. ```csharp builder.IsContinuousAggregate( @@ -319,9 +322,13 @@ builder.IsContinuousAggregate( x => x.Timestamp) .AddAggregateFunction(x => x.AveragePrice, x => x.Price, EAggregateFunction.Avg) .AddGroupByColumn(x => x.Ticker) - .CreateGroupIndexes(true); + .CreateGroupIndexes(false); ``` +> :warning: **Note:** In version <= `v10.3.0` an aggregate configured without a `.CreateGroupIndexes(...)` call was created with `timescaledb.create_group_indexes = false`, contradicting the server default. Unconfigured aggregates now omit the option and inherit the server default (`true`). Aggregates that need indexes suppressed must call `.CreateGroupIndexes(false)` explicitly. + +> :warning: **Note:** `timescaledb.create_group_indexes` is a create-only option in TimescaleDB — `ALTER MATERIALIZED VIEW` rejects it. Only transitions to or from `false` are structural: because the server default is `true`, unconfigured and `.CreateGroupIndexes(true)` produce identical databases, so adding or removing a redundant `.CreateGroupIndexes(true)` call is a no-op that never touches an existing aggregate. Changing the value to or from `false` on an **existing** aggregate drops and recreates it (see [Migration Ordering](#migration-ordering)), which rematerializes it and, in a hierarchy, cascades the drop to every descendant. If the source hypertable's retention policy has already dropped the raw data covered by the aggregate, that history is lost on recreate. The flag is not recoverable from an existing database — it leaves only the indexes behind — so scaffolded aggregates never carry an explicit `.CreateGroupIndexes(...)` call. + ### MaterializedOnly By default, queries to a continuous aggregate combine materialized data with recent unmaterialized data from the source hypertable. Use `.MaterializedOnly()` to return only the pre-computed materialized data: diff --git a/samples/Eftdb.Samples.Shared/Configurations/TradeAggregateConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/TradeAggregateConfiguration.cs index 397ee50..1c59fe5 100644 --- a/samples/Eftdb.Samples.Shared/Configurations/TradeAggregateConfiguration.cs +++ b/samples/Eftdb.Samples.Shared/Configurations/TradeAggregateConfiguration.cs @@ -21,6 +21,7 @@ public void Configure(EntityTypeBuilder builder) .AddGroupByColumn(x => x.Exchange) .AddGroupByColumn("1, 2") .Where("\"ticker\" = 'MCRS'") + .CreateGroupIndexes(false) .MaterializedOnly() .WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "1 hour") .WithRefreshNewestFirst(true); diff --git a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs index 0f28d94..8dbf737 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs @@ -59,7 +59,7 @@ public IReadOnlyList GenerateFluentApiCalls( string? chunkInterval = GetString(annotations, ContinuousAggregateAnnotations.ChunkInterval); bool materializedOnly = Find(annotations, ContinuousAggregateAnnotations.MaterializedOnly)?.Value is true; bool withNoData = Find(annotations, ContinuousAggregateAnnotations.WithNoData)?.Value is true; - bool createGroupIndexes = Find(annotations, ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool? ?? true; + bool? createGroupIndexes = Find(annotations, ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool?; ViewDefinitionParser.ParsedViewDefinition? parsed = viewDefinition is not null ? ViewDefinitionParser.Parse(viewDefinition) @@ -100,7 +100,7 @@ public IReadOnlyList GenerateFluentApiCalls( call = call.Chain(WithNoDataMethod, true); } - if (!createGroupIndexes) + if (createGroupIndexes is false) { call = call.Chain(CreateGroupIndexesMethod, false); } @@ -172,7 +172,7 @@ public IReadOnlyList GenerateDataAnnotationAttributes( string? chunkInterval = GetString(annotations, ContinuousAggregateAnnotations.ChunkInterval); bool materializedOnly = Find(annotations, ContinuousAggregateAnnotations.MaterializedOnly)?.Value is true; bool withNoData = Find(annotations, ContinuousAggregateAnnotations.WithNoData)?.Value is true; - bool createGroupIndexes = Find(annotations, ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool? ?? true; + bool? createGroupIndexes = Find(annotations, ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool?; ViewDefinitionParser.ParsedViewDefinition? parsed = viewDefinition is not null ? ViewDefinitionParser.Parse(viewDefinition) @@ -223,7 +223,7 @@ public IReadOnlyList GenerateDataAnnotationAttributes( caNamedArgs[nameof(ContinuousAggregateAttribute.WithNoData)] = true; } - if (!createGroupIndexes) + if (createGroupIndexes is false) { caNamedArgs[nameof(ContinuousAggregateAttribute.CreateGroupIndexes)] = false; } diff --git a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs index 12218ca..191656e 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs @@ -29,8 +29,8 @@ public void Generate(CreateContinuousAggregateOperation operation, IndentedStrin if (operation.WithNoData) call.Arg("withNoData", code.Literal(true)); - if (operation.CreateGroupIndexes) - call.Arg("createGroupIndexes", code.Literal(true)); + if (operation.CreateGroupIndexes.HasValue) + call.Arg("createGroupIndexes", code.Literal(operation.CreateGroupIndexes.Value)); if (operation.MaterializedOnly) call.Arg("materializedOnly", code.Literal(true)); @@ -81,8 +81,6 @@ public void Generate(AlterContinuousAggregateOperation operation, IndentedString 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)); @@ -91,9 +89,6 @@ public void Generate(AlterContinuousAggregateOperation operation, IndentedString 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)); diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs index 2565ed2..1bd011e 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs @@ -36,9 +36,20 @@ public class ContinuousAggregateAttribute : Attribute /// /// Gets or sets a value indicating whether to automatically create indexes on the GROUP BY columns. - /// Defaults to true. Corresponds to the 'timescaledb.create_group_indexes' option. + /// Defaults to true. Corresponds to the create-only 'timescaledb.create_group_indexes' option. + public bool CreateGroupIndexes + { + get => _createGroupIndexes ?? true; + set => _createGroupIndexes = value; + } + + private bool? _createGroupIndexes; + + /// + /// The configured value, or when the property + /// was never set. The convention writes the annotation only when this has a value. /// - public bool CreateGroupIndexes { get; set; } = true; + internal bool? CreateGroupIndexesConfigured => _createGroupIndexes; /// /// Gets or sets a value indicating whether queries to the view should only return materialized data. diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs index 8791a0d..7f6bfdf 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs @@ -29,7 +29,10 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ParentName, continuousAggregateAttribute.ParentName); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ChunkInterval, continuousAggregateAttribute.ChunkInterval); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.WithNoData, continuousAggregateAttribute.WithNoData); - entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes, continuousAggregateAttribute.CreateGroupIndexes); + if (continuousAggregateAttribute.CreateGroupIndexesConfigured is bool createGroupIndexes) + { + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes, createGroupIndexes); + } entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.MaterializedOnly, continuousAggregateAttribute.MaterializedOnly); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, continuousAggregateAttribute.Where); diff --git a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index b2d44b9..b0ce5da 100644 --- a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -20,12 +20,14 @@ public static List Generate(CreateContinuousAggregateOperation operation List statements = []; // Build WITH options - List withOptions = - [ - "timescaledb.continuous", - $"timescaledb.create_group_indexes = {operation.CreateGroupIndexes.ToString().ToLower()}", - $"timescaledb.materialized_only = {operation.MaterializedOnly.ToString().ToLower()}" - ]; + List withOptions = ["timescaledb.continuous"]; + + if (operation.CreateGroupIndexes.HasValue) + { + withOptions.Add($"timescaledb.create_group_indexes = {operation.CreateGroupIndexes.Value.ToString().ToLower()}"); + } + + withOptions.Add($"timescaledb.materialized_only = {operation.MaterializedOnly.ToString().ToLower()}"); // Add optional chunk_interval if specified if (!string.IsNullOrEmpty(operation.ChunkInterval)) @@ -226,12 +228,6 @@ public static List Generate(AlterContinuousAggregateOperation operation, } } - // Check for CreateGroupIndexes change - if (operation.CreateGroupIndexes != operation.OldCreateGroupIndexes) - { - string createGroupIndexesValue = operation.CreateGroupIndexes.ToString().ToLower(); - statements.Add($"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET (timescaledb.create_group_indexes = {createGroupIndexesValue});"); - } // Check for MaterializedOnly change if (operation.MaterializedOnly != operation.OldMaterializedOnly) diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index 40a7efe..77ad0aa 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -87,9 +87,6 @@ public IReadOnlyList GetDifferences(IRelationalModel? source /// /// Find structural changes that require drop and recreate - /// Note: Only certain properties can be altered (ChunkInterval, CreateGroupIndexes, - /// MaterializedOnly, and compression settings). - /// For structural changes (time bucket, aggregates, group by, where), drop and recreate is required. /// private static void FindStructuralChanges( List sourceAggregates, @@ -113,6 +110,7 @@ private static void FindStructuralChanges( x.Target.TimeBucketColumnName != x.Source.TimeBucketColumnName || x.Target.TimeBucketGroupBy != x.Source.TimeBucketGroupBy || x.Target.WithNoData != x.Source.WithNoData || + (x.Target.CreateGroupIndexes ?? true) != (x.Source.CreateGroupIndexes ?? true) || !AreAggregateFunctionsEqual(x.Target.AggregateFunctions, x.Source.AggregateFunctions) || !AreGroupByColumnsEqual(x.Target.GroupByColumns, x.Source.GroupByColumns) || x.Target.WhereClause != x.Source.WhereClause || @@ -134,9 +132,8 @@ private static void FindStructuralChanges( /// /// Find changes limited to properties that can be applied in place (ChunkInterval, - /// CreateGroupIndexes, MaterializedOnly, and compression settings) and emit alter operations. - /// Aggregates already marked for drop and recreate are skipped; their recreated definition - /// carries the new settings. + /// MaterializedOnly, and compression settings) and emit alter operations. Aggregates already + /// marked for drop and recreate are skipped; their recreated definition carries the new settings. /// private static void FindAlterableChanges( List sourceAggregates, @@ -155,7 +152,6 @@ private static void FindAlterableChanges( !droppedNames.Contains(x.Target.MaterializedViewName) && ( x.Target.ChunkInterval != x.Source.ChunkInterval || - x.Target.CreateGroupIndexes != x.Source.CreateGroupIndexes || x.Target.MaterializedOnly != x.Source.MaterializedOnly || x.Target.EnableCompression != x.Source.EnableCompression || !CompressionDiffHelper.AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || @@ -170,13 +166,11 @@ private static void FindAlterableChanges( Schema = aggregate.Target.Schema, MaterializedViewName = aggregate.Target.MaterializedViewName, ChunkInterval = aggregate.Target.ChunkInterval, - CreateGroupIndexes = aggregate.Target.CreateGroupIndexes, MaterializedOnly = aggregate.Target.MaterializedOnly, EnableCompression = aggregate.Target.EnableCompression, CompressionSegmentBy = aggregate.Target.CompressionSegmentBy, CompressionOrderBy = aggregate.Target.CompressionOrderBy, OldChunkInterval = aggregate.Source.ChunkInterval, - OldCreateGroupIndexes = aggregate.Source.CreateGroupIndexes, OldMaterializedOnly = aggregate.Source.MaterializedOnly, OldEnableCompression = aggregate.Source.EnableCompression, OldCompressionSegmentBy = aggregate.Source.CompressionSegmentBy, diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index 7dcfaca..4237242 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -97,7 +97,7 @@ private static IEnumerable ExtractContinuous bool timeBucketGroupBy = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy)?.Value as bool? ?? true; string? chunkInterval = entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value as string; bool withNoData = entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value as bool? ?? false; - bool createGroupIndexes = entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool? ?? false; + bool? createGroupIndexes = entityType.FindAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes)?.Value as bool?; bool materializedOnly = entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value as bool? ?? false; string? whereClause = entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value as string; diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs index f91cb49..d97c48c 100644 --- a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs +++ b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs @@ -13,7 +13,7 @@ public static OperationBuilder CreateContinu string? schema = null, string? chunkInterval = null, bool withNoData = false, - bool createGroupIndexes = false, + bool? createGroupIndexes = null, bool materializedOnly = false, string? timeBucketWidth = null, string? timeBucketSourceColumn = null, @@ -75,13 +75,17 @@ public static OperationBuilder AlterContinuou MaterializedViewName = materializedViewName, Schema = schema ?? string.Empty, ChunkInterval = chunkInterval, +#pragma warning disable CS0618 // Create-only option retained for source compatibility; no longer emitted as SQL. CreateGroupIndexes = createGroupIndexes, +#pragma warning restore CS0618 MaterializedOnly = materializedOnly, EnableCompression = enableCompression, CompressionSegmentBy = compressionSegmentBy, CompressionOrderBy = compressionOrderBy, OldChunkInterval = oldChunkInterval, +#pragma warning disable CS0618 // Create-only option retained for source compatibility; no longer emitted as SQL. OldCreateGroupIndexes = oldCreateGroupIndexes, +#pragma warning restore CS0618 OldMaterializedOnly = oldMaterializedOnly, OldEnableCompression = oldEnableCompression, OldCompressionSegmentBy = oldCompressionSegmentBy, diff --git a/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs b/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs index a9f40a3..81e6916 100644 --- a/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs @@ -10,7 +10,10 @@ public class AlterContinuousAggregateOperation : MigrationOperation public string? ChunkInterval { get; set; } public string? OldChunkInterval { get; set; } + [Obsolete("timescaledb.create_group_indexes is a create-only option; changing it now drops and recreates the aggregate, so changing it has no effect in an alter operation. Retained for source compatibility with previously generated migrations.")] public bool CreateGroupIndexes { get; set; } + + [Obsolete("timescaledb.create_group_indexes is a create-only option; changing it now drops and recreates the aggregate, so changing it has no effect in an alter operation. Retained for source compatibility with previously generated migrations.")] public bool OldCreateGroupIndexes { get; set; } public bool MaterializedOnly { get; set; } diff --git a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs index 2338d50..a1a9476 100644 --- a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs @@ -10,7 +10,9 @@ public class CreateContinuousAggregateOperation : MigrationOperation public string? ChunkInterval { get; set; } public bool WithNoData { get; set; } - public bool CreateGroupIndexes { get; set; } + + public bool? CreateGroupIndexes { get; set; } + public bool MaterializedOnly { get; set; } public string TimeBucketWidth { get; set; } = string.Empty; diff --git a/tests/Eftdb.Tests/Configuration/ContinuousAggregateAttributeTests.cs b/tests/Eftdb.Tests/Configuration/ContinuousAggregateAttributeTests.cs new file mode 100644 index 0000000..b1c7cd9 --- /dev/null +++ b/tests/Eftdb.Tests/Configuration/ContinuousAggregateAttributeTests.cs @@ -0,0 +1,61 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify the tri-state CreateGroupIndexes tracking on ContinuousAggregateAttribute: +/// the public getter falls back to the server default (true) while the internal +/// CreateGroupIndexesConfigured view only reports explicitly assigned values. +/// +public class ContinuousAggregateAttributeTests +{ + [Fact] + public void CreateGroupIndexes_WhenUnset_ReturnsServerDefaultTrue() + { + // Arrange + ContinuousAggregateAttribute attr = new(); + + // Act & Assert + Assert.True(attr.CreateGroupIndexes); + } + + [Fact] + public void CreateGroupIndexesConfigured_WhenUnset_IsNull() + { + // Arrange + ContinuousAggregateAttribute attr = new(); + + // Act & Assert + Assert.Null(attr.CreateGroupIndexesConfigured); + } + + [Fact] + public void CreateGroupIndexes_SetToTrue_ReturnsTrueAndRecordsAssignment() + { + // Arrange + ContinuousAggregateAttribute attr = new() + { + // Act + CreateGroupIndexes = true + }; + + // Assert + Assert.True(attr.CreateGroupIndexes); + Assert.True(attr.CreateGroupIndexesConfigured); + } + + [Fact] + public void CreateGroupIndexes_SetToFalse_ReturnsFalseAndRecordsAssignment() + { + // Arrange + ContinuousAggregateAttribute attr = new() + { + // Act + CreateGroupIndexes = false + }; + + // Assert + Assert.False(attr.CreateGroupIndexes); + Assert.False(attr.CreateGroupIndexesConfigured); + } +} diff --git a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs index 54a820c..2c541ef 100644 --- a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs @@ -176,6 +176,64 @@ public void CreateContinuousAggregate_FullyPopulated_EmitsAllOptionalArgs() #endregion + #region CreateContinuousAggregate_CreateGroupIndexes_TriState + + [Fact] + public void CreateContinuousAggregate_NullCreateGroupIndexes_Omits_CreateGroupIndexesArg() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + CreateGroupIndexes = null, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("createGroupIndexes:", result); + } + + [Fact] + public void CreateContinuousAggregate_TrueCreateGroupIndexes_Emits_CreateGroupIndexesTrue() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + CreateGroupIndexes = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("createGroupIndexes: true", result); + } + + [Fact] + public void CreateContinuousAggregate_FalseCreateGroupIndexes_Emits_CreateGroupIndexesFalse() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + CreateGroupIndexes = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("createGroupIndexes: false", result); + } + + #endregion + #region AlterContinuousAggregate_FullyPopulated_EmitsAllNewAndOldArgs [Fact] @@ -187,10 +245,8 @@ public void AlterContinuousAggregate_FullyPopulated_EmitsAllNewAndOldArgs() MaterializedViewName = "hourly", Schema = "metrics", ChunkInterval = "7 days", - CreateGroupIndexes = true, MaterializedOnly = true, OldChunkInterval = "1 day", - OldCreateGroupIndexes = true, OldMaterializedOnly = true, }; @@ -200,12 +256,12 @@ public void AlterContinuousAggregate_FullyPopulated_EmitsAllNewAndOldArgs() // Assert Assert.Contains("schema: \"metrics\"", result); Assert.Contains("chunkInterval: \"7 days\"", result); - Assert.Contains("createGroupIndexes: true", result); + Assert.DoesNotContain("createGroupIndexes:", result); Assert.Contains("materializedOnly: true", result); // Assert Assert.Contains("oldChunkInterval: \"1 day\"", result); - Assert.Contains("oldCreateGroupIndexes: true", result); + Assert.DoesNotContain("oldCreateGroupIndexes:", result); Assert.Contains("oldMaterializedOnly: true", result); } @@ -222,7 +278,6 @@ public void AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault() MaterializedViewName = "hourly", ChunkInterval = "7 days", OldChunkInterval = "1 day", - OldCreateGroupIndexes = true, OldMaterializedOnly = false, }; @@ -232,7 +287,7 @@ public void AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault() // Assert Assert.Contains("chunkInterval: \"7 days\"", result); Assert.Contains("oldChunkInterval: \"1 day\"", result); - Assert.Contains("oldCreateGroupIndexes: true", result); + Assert.DoesNotContain("oldCreateGroupIndexes:", result); Assert.DoesNotContain("oldMaterializedOnly:", result); } diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs index 02d022a..75008f4 100644 --- a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs @@ -300,7 +300,7 @@ public void Should_Detect_ChunkInterval_Change() #endregion - #region Should_Detect_CreateGroupIndexes_Change + #region Should_Detect_CreateGroupIndexes_Change_To_False private class MetricEntity4 { @@ -344,7 +344,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - private class GroupIndexesEnabledContext4 : DbContext + private class GroupIndexesDisabledContext4 : DbContext { public DbSet Metrics => Set(); public DbSet HourlyMetrics => Set(); @@ -370,16 +370,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .CreateGroupIndexes(true); + .CreateGroupIndexes(false); }); } } [Fact] - public void Should_Detect_CreateGroupIndexes_Change() + public void Should_Detect_CreateGroupIndexes_Change_To_False() { using BasicContinuousAggregateContext4 sourceContext = new(); - using GroupIndexesEnabledContext4 targetContext = new(); + using GroupIndexesDisabledContext4 targetContext = new(); IRelationalModel sourceModel = GetModel(sourceContext); IRelationalModel targetModel = GetModel(targetContext); @@ -388,10 +388,9 @@ public void Should_Detect_CreateGroupIndexes_Change() IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); - AlterContinuousAggregateOperation? alterOp = operations.OfType().FirstOrDefault(); - Assert.NotNull(alterOp); - Assert.False(alterOp.OldCreateGroupIndexes); - Assert.True(alterOp.CreateGroupIndexes); + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); } #endregion @@ -965,7 +964,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp, chunkInterval: "30 days") .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .CreateGroupIndexes(true) .MaterializedOnly(true); }); } @@ -4114,4 +4112,607 @@ public void Should_Cascade_Drop_And_Recreate_When_Parent_BucketColumnName_Change } #endregion + + // ── CreateGroupIndexes tri-state transitions ── + + #region Should_Not_Generate_Operations_When_CreateGroupIndexes_Both_Null + + private class CgiRaw17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate17 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiUnconfiguredContext17 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_CreateGroupIndexes_Both_Null() + { + using CgiUnconfiguredContext17 sourceContext = new(); + using CgiUnconfiguredContext17 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Not_Generate_Operations_When_CreateGroupIndexes_Both_False + + private class CgiRaw18 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate18 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiFalseContext18 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(false); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_CreateGroupIndexes_Both_False() + { + using CgiFalseContext18 sourceContext = new(); + using CgiFalseContext18 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Not_Generate_Operations_When_CreateGroupIndexes_True_To_Null + + private class CgiRaw19 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate19 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiTrueContext19 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(true); + }); + } + } + + private class CgiUnconfiguredContext19 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_CreateGroupIndexes_True_To_Null() + { + using CgiTrueContext19 sourceContext = new(); + using CgiUnconfiguredContext19 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_CreateGroupIndexes_Null_To_False + + private class CgiRaw20 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate20 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiUnconfiguredContext20 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class CgiFalseContext20 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(false); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_CreateGroupIndexes_Null_To_False() + { + using CgiUnconfiguredContext20 sourceContext = new(); + using CgiFalseContext20 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_CreateGroupIndexes_True_To_False + + private class CgiRaw21 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate21 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiTrueContext21 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(true); + }); + } + } + + private class CgiFalseContext21 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(false); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_CreateGroupIndexes_True_To_False() + { + using CgiTrueContext21 sourceContext = new(); + using CgiFalseContext21 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Not_Generate_Operations_When_CreateGroupIndexes_Null_To_True + + private class CgiRaw22 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate22 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiUnconfiguredContext22 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class CgiTrueContext22 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .CreateGroupIndexes(true); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_CreateGroupIndexes_Null_To_True() + { + using CgiUnconfiguredContext22 sourceContext = new(); + using CgiTrueContext22 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Not_Generate_Operations_For_Legacy_True_Annotation_Vs_Unconfigured_Model + + private class CgiRaw23 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CgiAggregate23 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CgiLegacyTrueAnnotationContext23 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + entity.HasAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes, true); + }); + } + } + + private class CgiUnconfiguredContext23 : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_For_Legacy_True_Annotation_Vs_Unconfigured_Model() + { + using CgiLegacyTrueAnnotationContext23 sourceContext = new(); + using CgiUnconfiguredContext23 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 7803042..321a7b4 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -83,7 +83,7 @@ public void Should_Extract_Minimal_ContinuousAggregate() Assert.True(operation.TimeBucketGroupBy); Assert.Null(operation.ChunkInterval); Assert.False(operation.WithNoData); - Assert.False(operation.CreateGroupIndexes); + Assert.Null(operation.CreateGroupIndexes); Assert.False(operation.MaterializedOnly); Assert.Null(operation.WhereClause); Assert.Empty(operation.AggregateFunctions); @@ -426,6 +426,162 @@ public void Should_Extract_CreateGroupIndexes_True() #endregion + #region Should_Extract_CreateGroupIndexes_False + + private class CreateGroupIndexesFalseSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateGroupIndexesFalseHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class CreateGroupIndexesFalseContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).CreateGroupIndexes(false); + }); + } + } + + [Fact] + public void Should_Extract_CreateGroupIndexes_False() + { + using CreateGroupIndexesFalseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.False(Assert.Single(operations).CreateGroupIndexes); + } + + #endregion + + #region Should_Extract_Null_CreateGroupIndexes_When_Attribute_Does_Not_Set_It + + private class CreateGroupIndexesAttributeSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_attr_metrics", ParentName = nameof(CreateGroupIndexesAttributeSourceMetric))] + [TimeBucket("1 hour", nameof(CreateGroupIndexesAttributeSourceMetric.Timestamp))] + private class CreateGroupIndexesAttributeHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class CreateGroupIndexesAttributeContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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.ToTable("attr_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => entity.HasNoKey()); + } + } + + [Fact] + public void Should_Extract_Null_CreateGroupIndexes_When_Attribute_Does_Not_Set_It() + { + using CreateGroupIndexesAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Null(Assert.Single(operations).CreateGroupIndexes); + } + + #endregion + + #region Should_Extract_CreateGroupIndexes_True_When_Attribute_Sets_It + + private class CreateGroupIndexesTrueAttributeSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_attr_true_metrics", ParentName = nameof(CreateGroupIndexesTrueAttributeSourceMetric), CreateGroupIndexes = true)] + [TimeBucket("1 hour", nameof(CreateGroupIndexesTrueAttributeSourceMetric.Timestamp))] + private class CreateGroupIndexesTrueAttributeHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class CreateGroupIndexesTrueAttributeContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => 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.ToTable("attr_true_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => entity.HasNoKey()); + } + } + + [Fact] + public void Should_Extract_CreateGroupIndexes_True_When_Attribute_Sets_It() + { + using CreateGroupIndexesTrueAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.True(Assert.Single(operations).CreateGroupIndexes); + } + + #endregion + #region Should_Extract_MaterializedOnly_True private class MaterializedOnlySourceMetric diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs index 0652b90..a3d1fb2 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs @@ -391,8 +391,8 @@ public void Alter_CompressionWithOtherChanges_EmitsBothStatements() { MaterializedViewName = "alter_mixed_cagg", Schema = "public", - CreateGroupIndexes = true, - OldCreateGroupIndexes = false, + MaterializedOnly = true, + OldMaterializedOnly = false, EnableCompression = true, OldEnableCompression = false, }; @@ -402,7 +402,7 @@ public void Alter_CompressionWithOtherChanges_EmitsBothStatements() // Assert Assert.Equal(2, statements.Count); - Assert.Contains(statements, s => s.Contains("create_group_indexes")); + Assert.Contains(statements, s => s.Contains("materialized_only")); Assert.Contains(statements, s => s.Contains("timescaledb.enable_columnstore")); } diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index d48951a..84390a1 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -397,6 +397,89 @@ public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() #endregion + #region CreateGroupIndexes Tri-State WITH Clause + + [Fact] + public void Create_WithNullCreateGroupIndexes_OmitsCreateGroupIndexesOption() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "cgi_null", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = null, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("WITH (timescaledb.continuous", result); + Assert.DoesNotContain("create_group_indexes", result); + } + + [Fact] + public void Create_WithTrueCreateGroupIndexes_EmitsCreateGroupIndexesTrue() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "cgi_true", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = true, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("timescaledb.create_group_indexes = true", result); + } + + [Fact] + public void Create_WithFalseCreateGroupIndexes_EmitsCreateGroupIndexesFalse() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "cgi_false", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("timescaledb.create_group_indexes = false", result); + } + + #endregion + #region AlterContinuousAggregateOperation Tests [Fact] @@ -442,29 +525,6 @@ public void Runtime_Alter_ChunkInterval_GeneratesCorrectSQL() Assert.Contains("SET (timescaledb.chunk_interval = '90 days')", result); } - [Fact] - public void DesignTime_Alter_CreateGroupIndexes_GeneratesCorrectCode() - { - // Arrange - AlterContinuousAggregateOperation operation = new() - { - MaterializedViewName = "metrics_view", - Schema = "public", - CreateGroupIndexes = true, - OldCreateGroupIndexes = false - }; - - string expected = @" - ALTER MATERIALIZED VIEW ""public"".""metrics_view"" SET (timescaledb.create_group_indexes = true); - "; - - // Act - string result = GetDesignTimeCode(operation); - - // Assert - Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); - } - [Fact] public void DesignTime_Alter_MaterializedOnly_GeneratesCorrectCode() { @@ -498,8 +558,6 @@ public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() Schema = "analytics", ChunkInterval = "60 days", OldChunkInterval = "30 days", - CreateGroupIndexes = true, - OldCreateGroupIndexes = false, MaterializedOnly = false, OldMaterializedOnly = true }; @@ -509,7 +567,7 @@ public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() // Assert Assert.Contains("timescaledb.chunk_interval = '60 days'", result); - Assert.Contains("timescaledb.create_group_indexes = true", result); + Assert.DoesNotContain("timescaledb.create_group_indexes", result); Assert.Contains("timescaledb.materialized_only = false", result); } @@ -523,8 +581,6 @@ public void Alter_NoChanges_GeneratesNoSQL() Schema = "public", ChunkInterval = "7 days", OldChunkInterval = "7 days", - CreateGroupIndexes = false, - OldCreateGroupIndexes = false, MaterializedOnly = false, OldMaterializedOnly = false }; @@ -1012,29 +1068,6 @@ public void Alter_With_NullChunkInterval_And_NullOldChunkInterval_GeneratesNothi Assert.DoesNotContain("chunk_interval", result); } - [Fact] - public void Alter_OnlyCreateGroupIndexesChanged_GeneratesSingleStatement() - { - // Arrange - AlterContinuousAggregateOperation operation = new() - { - MaterializedViewName = "indexes_only", - Schema = "public", - CreateGroupIndexes = true, - OldCreateGroupIndexes = false, - MaterializedOnly = true, - OldMaterializedOnly = true - }; - - // Act - string result = GetRuntimeSql(operation); - - // Assert - Assert.Contains("create_group_indexes = true", result); - Assert.DoesNotContain("materialized_only", result); - Assert.DoesNotContain("chunk_interval", result); - } - [Fact] public void Alter_OnlyMaterializedOnlyChanged_GeneratesSingleStatement() { @@ -1043,8 +1076,6 @@ public void Alter_OnlyMaterializedOnlyChanged_GeneratesSingleStatement() { MaterializedViewName = "mat_only", Schema = "public", - CreateGroupIndexes = false, - OldCreateGroupIndexes = false, MaterializedOnly = true, OldMaterializedOnly = false }; diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index 8283f31..d913284 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -335,8 +335,6 @@ public void Generate_AlterContinuousAggregate_WithChunkIntervalChange_GeneratesV Schema = "public", ChunkInterval = "7 days", OldChunkInterval = "1 day", - CreateGroupIndexes = true, - OldCreateGroupIndexes = true, MaterializedOnly = false, OldMaterializedOnly = false }; @@ -368,8 +366,6 @@ public void Generate_AlterContinuousAggregate_WithMaterializedOnlyChange_Generat Schema = "public", ChunkInterval = null, OldChunkInterval = null, - CreateGroupIndexes = true, - OldCreateGroupIndexes = true, MaterializedOnly = true, OldMaterializedOnly = false }; @@ -386,38 +382,6 @@ public void Generate_AlterContinuousAggregate_WithMaterializedOnlyChange_Generat Assert.DoesNotContain("migrationBuilder;", result); } - [Fact] - public void Generate_AlterContinuousAggregate_WithCreateGroupIndexesChange_GeneratesValidCSharp() - { - // Arrange - CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); - TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); - IndentedStringBuilder builder = new(); - - AlterContinuousAggregateOperation operation = new() - { - MaterializedViewName = "hourly_stats", - Schema = "public", - ChunkInterval = null, - OldChunkInterval = null, - CreateGroupIndexes = false, - OldCreateGroupIndexes = true, - MaterializedOnly = false, - OldMaterializedOnly = false - }; - - // Act - generator.Generate("migrationBuilder", [operation], builder); - - // Assert - string result = builder.ToString(); - Assert.Contains("migrationBuilder", result); - Assert.Contains(".AlterContinuousAggregate(", result); - Assert.Contains("oldCreateGroupIndexes:", result); - Assert.DoesNotContain(".Sql(", result); - Assert.DoesNotContain("migrationBuilder;", result); - } - [Fact] public void Generate_AlterContinuousAggregate_WithNoChanges_GeneratesValidCSharpOrNoOp() { @@ -432,8 +396,6 @@ public void Generate_AlterContinuousAggregate_WithNoChanges_GeneratesValidCSharp Schema = "public", ChunkInterval = null, OldChunkInterval = null, - CreateGroupIndexes = true, - OldCreateGroupIndexes = true, MaterializedOnly = false, OldMaterializedOnly = false }; diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs index 204a402..45ab9ef 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs @@ -109,10 +109,14 @@ public void AlterContinuousAggregate_MapsCurrentAndOldArguments() AlterContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); Assert.Equal("hourly", op.MaterializedViewName); Assert.Equal("7 days", op.ChunkInterval); +#pragma warning disable CS0618 // Create-only option retained for source compatibility; asserts the mapping still round-trips. Assert.True(op.CreateGroupIndexes); +#pragma warning restore CS0618 Assert.True(op.MaterializedOnly); Assert.Equal("1 day", op.OldChunkInterval); +#pragma warning disable CS0618 // Create-only option retained for source compatibility; asserts the mapping still round-trips. Assert.True(op.OldCreateGroupIndexes); +#pragma warning restore CS0618 Assert.False(op.OldMaterializedOnly); }