From a79abfeb4bdc7ee89e9f731f5ed8d048880cd477 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Fri, 12 Jun 2026 17:37:22 +0200 Subject: [PATCH 01/12] feat: implement annotation code generator --- samples/Eftdb.Samples.DatabaseFirst/README.md | 5 - .../OrderStatusEventConfiguration.cs | 7 +- .../SensorMeasurementConfiguration.cs | 7 +- .../Models/DeviceReading.cs | 2 +- .../Models/SensorMeasurement.cs | 15 +- .../AnnotationRendererHelper.cs | 82 +++++ .../HypertableAnnotationRenderer.cs | 296 ++++++++++++++++++ .../IFeatureAnnotationRenderer.cs | 33 ++ .../AnnotationRenderers/NameOfCodeFragment.cs | 12 + .../Generators/TimescaleCSharpHelper.cs | 28 ++ .../TimescaleCSharpModelGenerator.cs | 143 +++++++++ .../TimescaleDbAnnotationCodeGenerator.cs | 55 ++++ .../TimescaleModelCodeGeneratorSelector.cs | 28 ++ .../HypertableScaffoldingExtractor.cs | 49 ++- .../TimescaleDBDesignTimeServices.cs | 9 +- .../Hypertable/DimensionAttribute.cs | 88 ++++++ .../Hypertable/HypertableConvention.cs | 16 +- .../Hypertable/HypertableTypeBuilder.cs | 70 ++++- .../HypertableScaffoldingExtractorTests.cs | 14 +- 19 files changed, 912 insertions(+), 47 deletions(-) create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/AnnotationRendererHelper.cs create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/NameOfCodeFragment.cs create mode 100644 src/Eftdb.Design/Generators/TimescaleCSharpHelper.cs create mode 100644 src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs create mode 100644 src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs create mode 100644 src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs create mode 100644 src/Eftdb/Configuration/Hypertable/DimensionAttribute.cs diff --git a/samples/Eftdb.Samples.DatabaseFirst/README.md b/samples/Eftdb.Samples.DatabaseFirst/README.md index 61dfe0e..81f5f8c 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/README.md +++ b/samples/Eftdb.Samples.DatabaseFirst/README.md @@ -2,11 +2,6 @@ This project demonstrates how to use the **Database-First** approach with [TimescaleDB](https://www.timescale.com/) using the `CmdScale.EntityFrameworkCore.TimescaleDB` package. - -> [!WARNING] -> Currently the `dotnet ef dbcontext scaffold` command can't be tested because of an issue in `efcore` (see https://github.com/dotnet/efcore/issues/37201). ->
TODO: Test as soon as there is a fix available - --- ## Required NuGet Packages diff --git a/samples/Eftdb.Samples.Shared/Configurations/OrderStatusEventConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/OrderStatusEventConfiguration.cs index ea74d32..681f0d0 100644 --- a/samples/Eftdb.Samples.Shared/Configurations/OrderStatusEventConfiguration.cs +++ b/samples/Eftdb.Samples.Shared/Configurations/OrderStatusEventConfiguration.cs @@ -1,5 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; -using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -15,8 +14,8 @@ public void Configure(EntityTypeBuilder builder) builder .IsHypertable(e => e.EventTimestamp) .WithChunkTimeInterval("7 days") - .HasDimension(Dimension.CreateRange("OrderPlacedTimestamp", "1 month")) - .HasDimension(Dimension.CreateHash("WarehouseId", 4)); + .HasRangeDimension(e => e.OrderPlacedTimestamp, "1 month") + .HasHashDimension(e => e.WarehouseId, 4); } } } diff --git a/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs index 7a86fbb..e4dd13b 100644 --- a/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs +++ b/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs @@ -14,9 +14,10 @@ public void Configure(EntityTypeBuilder builder) builder.IsHypertable(x => x.RecordedAt) .WithChunkTimeInterval("1 day") - .EnableCompression() - .WithCompressionSegmentBy(x => x.SensorId) - .WithCompressionOrderBy(s => [s.ByDescending(x => x.RecordedAt)]); + .WithCompressionSegmentBy(x => x.Site, x => x.MetricType) + .WithCompressionOrderBy( + s => s.By(x => x.SensorId), + s => s.ByDescending(x => x.RecordedAt)); } } } diff --git a/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs b/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs index 69aebc4..cbe8c42 100644 --- a/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs +++ b/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs @@ -4,7 +4,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models { - [Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "1 day", EnableCompression = true, CompressionSegmentBy = new[] { "DeviceId" }, CompressionOrderBy = new[] { "Time DESC" })] + [Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "1 day", EnableCompression = true, CompressionSegmentBy = new[] { "DeviceId" }, CompressionOrderBy = new[] { $"{nameof(Time)} DESC" })] [Index(nameof(Time), Name = "ix_device_readings_time")] [PrimaryKey(nameof(Id), nameof(Time))] [ReorderPolicy("ix_device_readings_time", InitialStart = "2025-09-23T09:15:19.3905112Z", ScheduleInterval = "1 day", MaxRuntime = "00:00:00", RetryPeriod = "00:05:00", MaxRetries = 3)] diff --git a/samples/Eftdb.Samples.Shared/Models/SensorMeasurement.cs b/samples/Eftdb.Samples.Shared/Models/SensorMeasurement.cs index afc829e..8e8796e 100644 --- a/samples/Eftdb.Samples.Shared/Models/SensorMeasurement.cs +++ b/samples/Eftdb.Samples.Shared/Models/SensorMeasurement.cs @@ -3,14 +3,23 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models { /// - /// IoT sensor measurement whose time column is a NodaTime . + /// IoT sensor measurement in long format, whose time column is a NodaTime . /// public class SensorMeasurement { public Guid Id { get; set; } public Instant RecordedAt { get; set; } + + /// Physical site the reading originates from (e.g. "berlin-dc1"). Low cardinality. + public string Site { get; set; } = string.Empty; + + /// Kind of metric recorded (e.g. "temperature", "humidity"). Low cardinality. + public string MetricType { get; set; } = string.Empty; + + /// Identifier of the individual sensor reporting the value. Higher cardinality. public string SensorId { get; set; } = string.Empty; - public double Temperature { get; set; } - public double Humidity { get; set; } + + /// The measured value for the given metric type. + public double Value { get; set; } } } diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/AnnotationRendererHelper.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/AnnotationRendererHelper.cs new file mode 100644 index 0000000..289658f --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/AnnotationRendererHelper.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Hhelpers shared by implementations: + /// annotation lookup and consumption, column list parsing, and database-column to CLR-property + /// resolution. + /// + internal static class AnnotationRendererHelper + { + public static PropertyAccessorCodeFragment PropertyAccessor(string property) => new("x", [property]); + + /// + /// Maps a scaffolded database column name to its CLR property name on the entity, so generated code + /// references the property (e.g. DeviceId) rather than the raw column (e.g. device_id). + /// + public static string ResolvePropertyName(IEntityType entityType, string columnName) + { + TryResolvePropertyName(entityType, columnName, out string propertyName); + return propertyName; + } + + /// + /// Maps a scaffolded database column name to its CLR property name. Returns false with the + /// raw value when no column mapping or property resolves, so callers can avoid emitting + /// nameof(...) references to members that do not exist. + /// + public static bool TryResolvePropertyName(IEntityType entityType, string columnName, out string propertyName) + { + StoreObjectIdentifier? store = + StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) + ?? StoreObjectIdentifier.Create(entityType, StoreObjectType.View); + + if (store is StoreObjectIdentifier id) + { + foreach (IProperty property in entityType.GetProperties()) + { + if (string.Equals(property.GetColumnName(id), columnName, StringComparison.Ordinal)) + { + propertyName = property.Name; + return true; + } + } + } + + if (entityType.FindProperty(columnName) is IProperty direct) + { + propertyName = direct.Name; + return true; + } + + propertyName = columnName; + return false; + } + + public static string[] ResolveColumns(IEntityType entityType, string? value) + => [.. SplitColumns(value).Select(column => ResolvePropertyName(entityType, column))]; + + public static IAnnotation? Find(IDictionary annotations, string key) + => annotations.TryGetValue(key, out IAnnotation? annotation) ? annotation : null; + + public static string? GetString(IDictionary annotations, string key) + => Find(annotations, key)?.Value as string; + + public static string[] SplitColumns(string? value) + => string.IsNullOrWhiteSpace(value) + ? [] + : [.. value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + + public static void Consume(IDictionary annotations, params string[] keys) + { + foreach (string key in keys) + { + annotations.Remove(key); + } + } + } +} diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs new file mode 100644 index 0000000..28db366 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs @@ -0,0 +1,296 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using System.Text.Json; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders the Hypertable feature's annotations as IsHypertable(...) Fluent API chains or + /// [Hypertable]/[Dimension] attributes. + /// + internal sealed class HypertableAnnotationRenderer : IFeatureAnnotationRenderer + { + private static readonly Type BuilderType = typeof(HypertableTypeBuilder); + + // Rendering only uses the method name and declaring namespace, so any overload's MethodInfo is fine. + private static MethodInfo HypertableMethod(string name) => + BuilderType.GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == name); + + private static readonly MethodInfo IsHypertableMethod = HypertableMethod(nameof(HypertableTypeBuilder.IsHypertable)); + private static readonly MethodInfo WithChunkTimeIntervalMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithChunkTimeInterval)); + private static readonly MethodInfo EnableCompressionMethod = HypertableMethod(nameof(HypertableTypeBuilder.EnableCompression)); + private static readonly MethodInfo WithCompressionSegmentByMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithCompressionSegmentBy)); + private static readonly MethodInfo WithCompressionOrderByMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithCompressionOrderBy)); + private static readonly MethodInfo WithChunkSkippingMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithChunkSkipping)); + private static readonly MethodInfo WithMigrateDataMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithMigrateData)); + private static readonly MethodInfo HasRangeDimensionMethod = HypertableMethod(nameof(HypertableTypeBuilder.HasRangeDimension)); + private static readonly MethodInfo HasHashDimensionMethod = HypertableMethod(nameof(HypertableTypeBuilder.HasHashDimension)); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + if (Find(annotations, HypertableAnnotations.IsHypertable)?.Value is not true) + { + return []; + } + + if (GetString(annotations, HypertableAnnotations.HypertableTimeColumn) is not string timeColumn || string.IsNullOrWhiteSpace(timeColumn)) + { + return []; + } + + string timeProperty = ResolvePropertyName(entityType, timeColumn); + MethodCallCodeFragment call = new(IsHypertableMethod, PropertyAccessor(timeProperty)); + + if (GetString(annotations, HypertableAnnotations.ChunkTimeInterval) is string interval && !string.IsNullOrWhiteSpace(interval)) + { + call = call.Chain(WithChunkTimeIntervalMethod, interval); + } + + // Both WithCompressionSegmentBy and WithCompressionOrderBy implicitly enable compression, so a + // separate EnableCompression call is only emitted when neither is rendered. + bool compressionConfigured = false; + + string[] segmentBy = ResolveColumns(entityType, GetString(annotations, HypertableAnnotations.CompressionSegmentBy)); + if (segmentBy.Length > 0) + { + call = call.Chain(WithCompressionSegmentByMethod, [.. segmentBy.Select(PropertyAccessor)]); + compressionConfigured = true; + } + + MethodCallCodeFragment? orderBy = GenerateCompressionOrderByFluent(entityType, GetString(annotations, HypertableAnnotations.CompressionOrderBy)); + if (orderBy != null) + { + call = call.Chain(orderBy); + compressionConfigured = true; + Consume(annotations, HypertableAnnotations.CompressionOrderBy); + } + + if (!compressionConfigured && Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) + { + call = call.Chain(EnableCompressionMethod); + } + + string[] chunkSkip = ResolveColumns(entityType, GetString(annotations, HypertableAnnotations.ChunkSkipColumns)); + if (chunkSkip.Length > 0) + { + call = call.Chain(WithChunkSkippingMethod, [.. chunkSkip.Select(PropertyAccessor)]); + } + + if (Find(annotations, HypertableAnnotations.MigrateData)?.Value is true) + { + call = call.Chain(WithMigrateDataMethod); + } + + List? dimensions = TryReadDimensions(GetString(annotations, HypertableAnnotations.AdditionalDimensions)); + if (dimensions is { Count: > 0 }) + { + foreach (Dimension dimension in dimensions) + { + PropertyAccessorCodeFragment column = PropertyAccessor(ResolvePropertyName(entityType, dimension.ColumnName)); + call = dimension.Type == EDimensionType.Hash + ? call.Chain(HasHashDimensionMethod, [column, dimension.NumberOfPartitions ?? 0]) + : call.Chain(HasRangeDimensionMethod, [column, dimension.Interval ?? string.Empty]); + } + + Consume(annotations, HypertableAnnotations.AdditionalDimensions); + } + + Consume(annotations, + HypertableAnnotations.IsHypertable, + HypertableAnnotations.HypertableTimeColumn, + HypertableAnnotations.ChunkTimeInterval, + HypertableAnnotations.EnableCompression, + HypertableAnnotations.CompressionSegmentBy, + HypertableAnnotations.ChunkSkipColumns, + HypertableAnnotations.MigrateData); + + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + AttributeCodeFragment? hypertable = GenerateHypertableAttribute(entityType, annotations); + return hypertable == null + ? [] + : [hypertable, .. GenerateDimensionAttributes(entityType, annotations)]; + } + + private static AttributeCodeFragment? GenerateHypertableAttribute(IEntityType entityType, IDictionary annotations) + { + if (Find(annotations, HypertableAnnotations.IsHypertable)?.Value is not true) + { + return null; + } + + if (GetString(annotations, HypertableAnnotations.HypertableTimeColumn) is not string timeColumn || string.IsNullOrWhiteSpace(timeColumn)) + { + return null; + } + + Dictionary named = []; + + if (Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) + { + named[nameof(HypertableAttribute.EnableCompression)] = true; + } + + if (GetString(annotations, HypertableAnnotations.ChunkTimeInterval) is string interval + && !string.IsNullOrWhiteSpace(interval) + && interval != DefaultValues.ChunkTimeInterval) + { + named[nameof(HypertableAttribute.ChunkTimeInterval)] = interval; + } + + if (Find(annotations, HypertableAnnotations.MigrateData)?.Value is true) + { + named[nameof(HypertableAttribute.MigrateData)] = true; + } + + object[] segmentBy = [.. SplitColumns(GetString(annotations, HypertableAnnotations.CompressionSegmentBy)).Select(column => ColumnReference(entityType, column))]; + if (segmentBy.Length > 0) + { + named[nameof(HypertableAttribute.CompressionSegmentBy)] = ToArgumentArray(segmentBy); + } + + object[] orderBy = [.. SplitColumns(GetString(annotations, HypertableAnnotations.CompressionOrderBy)).Select(entry => OrderByReference(entityType, entry))]; + if (orderBy.Length > 0) + { + named[nameof(HypertableAttribute.CompressionOrderBy)] = ToArgumentArray(orderBy); + } + + object[] chunkSkip = [.. SplitColumns(GetString(annotations, HypertableAnnotations.ChunkSkipColumns)).Select(column => ColumnReference(entityType, column))]; + if (chunkSkip.Length > 0) + { + named[nameof(HypertableAttribute.ChunkSkipColumns)] = ToArgumentArray(chunkSkip); + } + + Consume(annotations, + HypertableAnnotations.IsHypertable, + HypertableAnnotations.HypertableTimeColumn, + HypertableAnnotations.ChunkTimeInterval, + HypertableAnnotations.EnableCompression, + HypertableAnnotations.CompressionSegmentBy, + HypertableAnnotations.CompressionOrderBy, + HypertableAnnotations.ChunkSkipColumns, + HypertableAnnotations.MigrateData); + + return new AttributeCodeFragment(typeof(HypertableAttribute), [ColumnReference(entityType, timeColumn)], named); + } + + /// + /// References a column as nameof(Property) when it resolves to a CLR property on the entity; + /// falls back to the raw string for unmapped columns, where a nameof would not compile. + /// + private static object ColumnReference(IEntityType entityType, string column, string suffix = "") + => TryResolvePropertyName(entityType, column, out string property) + ? new NameOfCodeFragment(property, suffix) + : suffix.Length == 0 ? column : column + suffix; + + // Splits a "column [ASC|DESC] [NULLS ...]" entry into a property reference plus literal suffix. + private static object OrderByReference(IEntityType entityType, string entry) + { + int space = entry.IndexOf(' '); + return space < 0 + ? ColumnReference(entityType, entry) + : ColumnReference(entityType, entry[..space], entry[space..]); + } + + private static object ToArgumentArray(object[] entries) + => Array.Exists(entries, entry => entry is NameOfCodeFragment) + ? entries + : Array.ConvertAll(entries, entry => (string)entry); + + private static List GenerateDimensionAttributes(IEntityType entityType, IDictionary annotations) + { + List? dimensions = TryReadDimensions(GetString(annotations, HypertableAnnotations.AdditionalDimensions)); + if (dimensions is not { Count: > 0 }) + { + return []; + } + + List attributes = []; + foreach (Dimension dimension in dimensions) + { + object column = ColumnReference(entityType, dimension.ColumnName); + attributes.Add(dimension.Type == EDimensionType.Hash + ? new AttributeCodeFragment(typeof(DimensionAttribute), column, EDimensionType.Hash, dimension.NumberOfPartitions ?? 0) + : new AttributeCodeFragment(typeof(DimensionAttribute), column, EDimensionType.Range, dimension.Interval ?? string.Empty)); + } + + Consume(annotations, HypertableAnnotations.AdditionalDimensions); + return attributes; + } + + private static List? TryReadDimensions(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize>(json); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Renders CompressionOrderBy as .WithCompressionOrderBy(s => s.ByDescending(x => x.Time), ...) + /// with one selector closure per column. Returns null for empty input. + /// + private static MethodCallCodeFragment? GenerateCompressionOrderByFluent(IEntityType entityType, string? value) + { + string[] entries = SplitColumns(value); + if (entries.Length == 0) + { + return null; + } + + object?[] closures = [.. entries.Select(entry => OrderBySelectorClosure(entityType, entry))]; + + return new MethodCallCodeFragment(WithCompressionOrderByMethod, closures); + } + + private static NestedClosureCodeFragment OrderBySelectorClosure(IEntityType entityType, string entry) + { + (string column, bool? isAscending, bool? nullsFirst) = ParseOrderByEntry(entry); + string property = ResolvePropertyName(entityType, column); + + string selectorMethod = isAscending switch + { + true => nameof(OrderBySelector<>.ByAscending), + false => nameof(OrderBySelector<>.ByDescending), + null => nameof(OrderBySelector<>.By), + }; + + object?[] arguments = nullsFirst.HasValue + ? [PropertyAccessor(property), nullsFirst.Value] + : [PropertyAccessor(property)]; + + return new NestedClosureCodeFragment("s", new MethodCallCodeFragment(selectorMethod, arguments)); + } + + private static (string Column, bool? IsAscending, bool? NullsFirst) ParseOrderByEntry(string entry) + { + string[] tokens = entry.Split(' ', StringSplitOptions.RemoveEmptyEntries); + string column = tokens[0]; + string rest = string.Join(' ', tokens[1..]).ToUpperInvariant(); + + bool? isAscending = rest.Contains("ASC") ? true : rest.Contains("DESC") ? false : null; + bool? nullsFirst = rest.Contains("NULLS FIRST") ? true : rest.Contains("NULLS LAST") ? false : null; + + return (column, isAscending, nullsFirst); + } + } +} diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs new file mode 100644 index 0000000..452d837 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders the scaffolded TimescaleDB:* annotations of a single feature as Fluent API + /// calls (default scaffold mode) or DataAnnotation attributes (--data-annotations). + /// + /// + /// Implementations must be stateless; per-call state travels in the annotations dictionary. + /// Annotations that are rendered must be removed from the dictionary (consumed) - anything left + /// falls back to .HasAnnotation(...), keeping the generated model complete. In-place + /// rewrites of unconsumed annotations are allowed (e.g. translating database column names to + /// CLR property names before the fallback renders them). + /// + internal interface IFeatureAnnotationRenderer + { + /// + /// Renders the feature's Fluent API fragments for the entity. Each returned fragment is + /// chained onto the scaffolded entity... builder statement in list order. + /// + IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations); + + /// + /// Renders the feature's DataAnnotation attributes for the entity. + /// + IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations); + } +} diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/NameOfCodeFragment.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/NameOfCodeFragment.cs new file mode 100644 index 0000000..79a9c2c --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/NameOfCodeFragment.cs @@ -0,0 +1,12 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Marks an attribute argument as a property reference so TimescaleCSharpHelper renders it as + /// nameof(Property) - or as a constant interpolated string like $"{nameof(Property)} DESC" + /// when a suffix is present - instead of a quoted string literal. Keeps scaffolded attributes + /// rename-safe for users who evolve the generated entities by hand. + /// + /// The CLR property the argument references. + /// Trailing literal text (e.g. " DESC"); empty for a plain reference. + internal sealed record NameOfCodeFragment(string PropertyName, string Suffix = ""); +} diff --git a/src/Eftdb.Design/Generators/TimescaleCSharpHelper.cs b/src/Eftdb.Design/Generators/TimescaleCSharpHelper.cs new file mode 100644 index 0000000..1a8d317 --- /dev/null +++ b/src/Eftdb.Design/Generators/TimescaleCSharpHelper.cs @@ -0,0 +1,28 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Storage; + +#pragma warning disable EF1001 // CSharpHelper is the documented base for provider literal rendering. +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Extends the built-in C# helper to render arguments as + /// nameof(Property) references (or constant interpolated strings when a suffix follows) + /// instead of quoted string literals, keeping scaffolded attributes rename-safe. + /// + public class TimescaleCSharpHelper(ITypeMappingSource typeMappingSource) : CSharpHelper(typeMappingSource) + { + public override string UnknownLiteral(object? value) => value switch + { + NameOfCodeFragment nameOf => Literal(nameOf), + object?[] array when Array.Exists(array, entry => entry is NameOfCodeFragment) => + $"new[] {{ {string.Join(", ", array.Select(UnknownLiteral))} }}", + _ => base.UnknownLiteral(value), + }; + + private static string Literal(NameOfCodeFragment nameOf) => nameOf.Suffix.Length == 0 + ? $"nameof({nameOf.PropertyName})" + : $"$\"{{nameof({nameOf.PropertyName})}}{nameOf.Suffix}\""; + } +} +#pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs new file mode 100644 index 0000000..9bbd71d --- /dev/null +++ b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +#pragma warning disable EF1001 // CSharpModelGenerator is the documented extension point for scaffolded output post-processing. +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Extends the built-in C# scaffolding generator to add the using directives required by the + /// library's DataAnnotation attributes. + /// + /// + /// The built-in entity-type template hardcodes its using list and never inspects the + /// s an returns, so custom + /// attribute types ([Hypertable], [Dimension]) and their argument types + /// (EDimensionType) would not resolve in the generated entity files. Selected over the + /// built-in generator because the design-time service registration order makes the last matching + /// win. + /// + public class TimescaleCSharpModelGenerator( + ModelCodeGeneratorDependencies dependencies, + IOperationReporter reporter, + IServiceProvider serviceProvider, + IAnnotationCodeGenerator annotationCodeGenerator) + : CSharpModelGenerator(dependencies, reporter, serviceProvider) + { + private readonly IAnnotationCodeGenerator _annotationCodeGenerator = annotationCodeGenerator; + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + { + ScaffoldedModel scaffoldedModel = base.GenerateModel(model, options); + + if (!options.UseDataAnnotations) + { + return scaffoldedModel; + } + + Dictionary entityFiles = []; + foreach (ScaffoldedFile file in scaffoldedModel.AdditionalFiles) + { + entityFiles.TryAdd(Path.GetFileNameWithoutExtension(file.Path), file); + } + + foreach (IEntityType entityType in model.GetEntityTypes()) + { + List namespaces = CollectAttributeNamespaces(entityType); + if (namespaces.Count > 0 && entityFiles.TryGetValue(entityType.Name, out ScaffoldedFile? file)) + { + file.Code = AddMissingUsings(file.Code, namespaces); + } + } + + return scaffoldedModel; + } + + /// + /// Re-runs attribute generation on a scratch copy of the entity's annotations to discover which + /// namespaces the rendered attributes and their arguments require. + /// + private List CollectAttributeNamespaces(IEntityType entityType) + { + Dictionary annotations = entityType.GetAnnotations().ToDictionary(a => a.Name, a => a); + + List namespaces = []; + foreach (AttributeCodeFragment fragment in _annotationCodeGenerator.GenerateDataAnnotationAttributes(entityType, annotations)) + { + AddNamespace(namespaces, fragment.Type); + + foreach (object? argument in fragment.Arguments) + { + AddArgumentNamespace(namespaces, argument); + } + + foreach (object? argument in fragment.NamedArguments.Values) + { + AddArgumentNamespace(namespaces, argument); + } + } + + return namespaces; + } + + private static void AddArgumentNamespace(List namespaces, object? argument) + { + if (argument is Enum) + { + AddNamespace(namespaces, argument.GetType()); + } + else if (argument is Type type) + { + AddNamespace(namespaces, type); + } + } + + private static void AddNamespace(List namespaces, Type type) + { + if (type.Namespace is string ns && !namespaces.Contains(ns)) + { + namespaces.Add(ns); + } + } + + /// + /// Merges the required namespaces into the file's leading using block, preserving the + /// System-first ordering the built-in template produces. + /// + private static string AddMissingUsings(string code, IReadOnlyList namespaces) + { + string newLine = code.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + string[] lines = code.Split(newLine); + + int blockEnd = 0; + List usings = []; + while (blockEnd < lines.Length && lines[blockEnd].StartsWith("using ", StringComparison.Ordinal) && lines[blockEnd].EndsWith(';')) + { + usings.Add(lines[blockEnd]["using ".Length..^1]); + blockEnd++; + } + + if (!namespaces.Except(usings).Any()) + { + return code; + } + + List merged = [.. + usings.Union(namespaces) + .OrderBy(ns => ns == "System" || ns.StartsWith("System.", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(ns => ns, StringComparer.Ordinal)]; + + IEnumerable usingLines = merged.Select(ns => $"using {ns};"); + IEnumerable rest = lines.Skip(blockEnd); + + // A file without a leading using block gets one inserted, separated by a blank line. + return blockEnd == 0 + ? string.Join(newLine, [.. usingLines, string.Empty, .. rest]) + : string.Join(newLine, [.. usingLines, .. rest]); + } + } +} +#pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs new file mode 100644 index 0000000..267e565 --- /dev/null +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -0,0 +1,55 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Design.Internal; + +#pragma warning disable EF1001 // NpgsqlAnnotationCodeGenerator lives in *.Internal but is the documented base for provider annotation code generation. +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Converts the TimescaleDB:* annotations produced by scaffolding into the library's Fluent API + /// calls (default) or DataAnnotation attributes (--data-annotations). + /// + /// + /// Subclasses Npgsql's generator so its own annotation handling (arrays, identity, comments) is preserved. + /// Feature-specific rendering is delegated to one per feature, + /// mirroring the per-feature extractor/applier pairs in TimescaleDatabaseModelFactory. Annotations + /// no renderer consumes fall back to .HasAnnotation(...), keeping the generated model complete. + /// + public sealed class TimescaleDbAnnotationCodeGenerator(AnnotationCodeGeneratorDependencies dependencies) + : NpgsqlAnnotationCodeGenerator(dependencies) + { + private static readonly IReadOnlyList Renderers = + [ + new HypertableAnnotationRenderer(), + ]; + + public override IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + List calls = [.. base.GenerateFluentApiCalls(entityType, annotations)]; + + foreach (IFeatureAnnotationRenderer renderer in Renderers) + { + calls.AddRange(renderer.GenerateFluentApiCalls(entityType, annotations)); + } + + return calls; + } + + public override IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + List attributes = [.. base.GenerateDataAnnotationAttributes(entityType, annotations)]; + + foreach (IFeatureAnnotationRenderer renderer in Renderers) + { + attributes.AddRange(renderer.GenerateDataAnnotationAttributes(entityType, annotations)); + } + + return attributes; + } + } +} +#pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs b/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs new file mode 100644 index 0000000..c4ebc6b --- /dev/null +++ b/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +#pragma warning disable EF1001 // ModelCodeGeneratorSelector is the documented extension point for generator selection. +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Prefers over the built-in C# generator. + /// + /// + /// The design-time tooling registers provider services before the EF Core defaults, so the default + /// last-registration-wins selection would always pick the built-in + /// over the provider's. User T4 templates () keep priority. + /// + public class TimescaleModelCodeGeneratorSelector(IEnumerable services) + : ModelCodeGeneratorSelector(services) + { + public override IModelCodeGenerator Select(ModelCodeGenerationOptions options) + { + IModelCodeGenerator selected = base.Select(options); + + return selected.GetType() == typeof(CSharpModelGenerator) + ? Services.OfType().LastOrDefault() ?? selected + : selected; + } + } +} +#pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index 88358e5..de460ed 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -95,12 +95,25 @@ FROM timescaledb_information.dimensions // If it's the first dimension, it defines the primary hypertable settings if (dimensionNumber == 1) { - long chunkInterval = reader.IsDBNull(5) ? DefaultValues.ChunkTimeIntervalLong : (long)reader.GetDouble(5); + string chunkTimeInterval; + if (!reader.IsDBNull(5)) + { + chunkTimeInterval = HumanizeMicroseconds((long)reader.GetDouble(5)); + } + else if (!reader.IsDBNull(6)) + { + chunkTimeInterval = reader.GetInt64(6).ToString(); + } + else + { + chunkTimeInterval = DefaultValues.ChunkTimeInterval; + } + bool compressionEnabled = compressionSettings.TryGetValue(key, out bool enabled) && enabled; hypertables[key] = new HypertableInfo( TimeColumnName: columnName, - ChunkTimeInterval: chunkInterval.ToString(), + ChunkTimeInterval: chunkTimeInterval, CompressionEnabled: compressionEnabled, CompressionSegmentBy: [], CompressionOrderBy: [], @@ -122,9 +135,7 @@ FROM timescaledb_information.dimensions } else if (!reader.IsDBNull(5)) { - // Time-based range dimension - long interval = (long)reader.GetDouble(5); - dimension = Dimension.CreateRange(columnName, interval.ToString()); + dimension = Dimension.CreateRange(columnName, HumanizeMicroseconds((long)reader.GetDouble(5))); } else if (!reader.IsDBNull(6)) { @@ -217,5 +228,33 @@ FROM timescaledb_information.compression_settings } } } + + /// + /// Converts a microsecond chunk interval to the equivalent PostgreSQL interval string when it + /// divides cleanly into days, hours, minutes, or seconds (e.g. 86400000000 becomes + /// "1 day"), matching the hand-written configuration style. Uneven values keep the raw + /// microsecond form, which round-trips identically. + /// + private static string HumanizeMicroseconds(long microseconds) + { + (long Factor, string Unit)[] units = + [ + (86_400_000_000L, "day"), + (3_600_000_000L, "hour"), + (60_000_000L, "minute"), + (1_000_000L, "second"), + ]; + + foreach ((long factor, string unit) in units) + { + if (microseconds >= factor && microseconds % factor == 0) + { + long count = microseconds / factor; + return count == 1 ? $"1 {unit}" : $"{count} {unit}s"; + } + } + + return microseconds.ToString(); + } } } diff --git a/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs b/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs index e20fb54..194f8ea 100644 --- a/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs +++ b/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Migrations.Design; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.Extensions.DependencyInjection; @@ -16,7 +17,11 @@ public void ConfigureDesignTimeServices(IServiceCollection services) services.AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); } } } diff --git a/src/Eftdb/Configuration/Hypertable/DimensionAttribute.cs b/src/Eftdb/Configuration/Hypertable/DimensionAttribute.cs new file mode 100644 index 0000000..ec841b7 --- /dev/null +++ b/src/Eftdb/Configuration/Hypertable/DimensionAttribute.cs @@ -0,0 +1,88 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable +{ + /// + /// Declares an additional partitioning dimension on a hypertable entity. + /// Apply once per dimension. Corresponds to TimescaleDB's add_dimension. + /// + /// + /// + /// [Hypertable(nameof(EventTimestamp))] + /// [Dimension(nameof(OrderPlacedTimestamp), EDimensionType.Range, "1 month")] + /// [Dimension(nameof(WarehouseId), EDimensionType.Hash, 4)] + /// public class OrderStatusEvent { ... } + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class DimensionAttribute : Attribute + { + /// The column to partition on. + public string ColumnName { get; } + + /// The partitioning strategy (range or hash). + public EDimensionType Type { get; } + + /// The partitioning interval. Set for range dimensions; null for hash dimensions. + public string? Interval { get; } + + /// The number of hash partitions. Set for hash dimensions; 0 for range dimensions. + public int NumberOfPartitions { get; } + + /// + /// Declares a range partitioning dimension. + /// + /// The column to partition on. + /// Must be . + /// The partitioning interval (e.g. "1 month" or an integer interval as a string). + public DimensionAttribute(string columnName, EDimensionType type, string interval) + { + if (string.IsNullOrWhiteSpace(columnName)) + { + throw new ArgumentException("Dimension column name must be provided.", nameof(columnName)); + } + + if (type != EDimensionType.Range) + { + throw new ArgumentException("This constructor declares a range dimension; the type must be Range.", nameof(type)); + } + + if (string.IsNullOrWhiteSpace(interval)) + { + throw new ArgumentException("Interval must be provided for a range dimension.", nameof(interval)); + } + + ColumnName = columnName; + Type = EDimensionType.Range; + Interval = interval; + } + + /// + /// Declares a hash partitioning dimension. + /// + /// The column to partition on. + /// Must be . + /// The number of hash partitions. + public DimensionAttribute(string columnName, EDimensionType type, int numberOfPartitions) + { + if (string.IsNullOrWhiteSpace(columnName)) + { + throw new ArgumentException("Dimension column name must be provided.", nameof(columnName)); + } + + if (type != EDimensionType.Hash) + { + throw new ArgumentException("This constructor declares a hash dimension; the type must be Hash.", nameof(type)); + } + + if (numberOfPartitions <= 0) + { + throw new ArgumentException("Number of partitions must be greater than zero.", nameof(numberOfPartitions)); + } + + ColumnName = columnName; + Type = EDimensionType.Hash; + NumberOfPartitions = numberOfPartitions; + } + } +} diff --git a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs index 9c3b854..dda4e42 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs @@ -1,7 +1,9 @@ -using Microsoft.EntityFrameworkCore.Metadata; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using System.Reflection; +using System.Text.Json; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable { @@ -63,6 +65,18 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, string.Join(", ", attribute.CompressionOrderBy)); } } + + DimensionAttribute[] dimensionAttributes = entityType.ClrType?.GetCustomAttributes().ToArray() ?? []; + if (dimensionAttributes.Length > 0) + { + List dimensions = [.. dimensionAttributes.Select(ToDimension)]; + entityTypeBuilder.HasAnnotation(HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(dimensions)); + } } + + private static Dimension ToDimension(DimensionAttribute attribute) + => attribute.Type == EDimensionType.Hash + ? Dimension.CreateHash(attribute.ColumnName, attribute.NumberOfPartitions) + : Dimension.CreateRange(attribute.ColumnName, attribute.Interval!); } } diff --git a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs index fed6b9d..b1e916c 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs @@ -81,9 +81,13 @@ private static EntityTypeBuilder IsHypertableCore( } /// - /// Adds an additional partitioning dimension to the hypertable. + /// Adds an additional partitioning dimension to the hypertable from a object. /// /// + /// Prefer + /// or + /// for static configuration; they select the column with a type-safe lambda and match the scaffolder's output. + /// This overload suits dynamic scenarios where the column name is only known at runtime. /// This method can be called multiple times to add several dimensions (hash or range). /// These are often called "space" dimensions and are used to partition data within the same time interval, /// which can improve performance by enabling parallelism and query constraints. @@ -95,29 +99,50 @@ private static EntityTypeBuilder IsHypertableCore( public static EntityTypeBuilder HasDimension( this EntityTypeBuilder entityTypeBuilder, Dimension dimension) where TEntity : class + => AddDimension(entityTypeBuilder, dimension); + + private static EntityTypeBuilder AddDimension( + EntityTypeBuilder entityTypeBuilder, + Dimension dimension) where TEntity : class { - // Find existing dimensions annotations IAnnotation? existingAnnotation = entityTypeBuilder.Metadata.FindAnnotation(HypertableAnnotations.AdditionalDimensions); - // Deserialize existing dimensions or create a new list - List dimensions; - if (existingAnnotation?.Value is string json) - { - dimensions = JsonSerializer.Deserialize>(json) ?? []; - } - else - { - dimensions = []; - } + List dimensions = existingAnnotation?.Value is string json + ? JsonSerializer.Deserialize>(json) ?? [] + : []; - // Add new dimension to the list and serialize back to JSON dimensions.Add(dimension); - string updatedJson = JsonSerializer.Serialize(dimensions); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(dimensions)); - entityTypeBuilder.HasAnnotation(HypertableAnnotations.AdditionalDimensions, updatedJson); return entityTypeBuilder; } + /// + /// Adds a range partitioning dimension to the hypertable. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// A lambda expression representing the column to partition by (e.g. x => x.Timestamp). + /// The partitioning interval (e.g. "1 month" or an integer interval as a string). + public static EntityTypeBuilder HasRangeDimension( + this EntityTypeBuilder entityTypeBuilder, + Expression> column, + string interval) where TEntity : class + => AddDimension(entityTypeBuilder, Dimension.CreateRange(GetPropertyName(column), interval)); + + /// + /// Adds a hash (space) partitioning dimension to the hypertable. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// A lambda expression representing the column to partition by (e.g. x => x.WarehouseId). + /// The number of hash partitions. + public static EntityTypeBuilder HasHashDimension( + this EntityTypeBuilder entityTypeBuilder, + Expression> column, + int numberOfPartitions) where TEntity : class + => AddDimension(entityTypeBuilder, Dimension.CreateHash(GetPropertyName(column), numberOfPartitions)); + /// /// Sets the time interval for each chunk of the hypertable. /// @@ -231,6 +256,21 @@ public static EntityTypeBuilder WithCompressionOrderBy( return entityTypeBuilder.WithCompressionOrderBy([.. rules]); } + /// + /// Specifies the columns to order by within each compressed segment, one selector per column. + /// + /// + /// Example: .WithCompressionOrderBy(s => s.ByDescending(x => x.Time), s => s.By(x => x.Value)) + /// + public static EntityTypeBuilder WithCompressionOrderBy( + this EntityTypeBuilder entityTypeBuilder, + params Func, OrderBy>[] orderSelectors) where TEntity : class + { + OrderBySelector selector = new(); + + return entityTypeBuilder.WithCompressionOrderBy([.. orderSelectors.Select(orderSelector => orderSelector(selector))]); + } + /// /// Specifies whether existing data should be migrated when converting a table to a hypertable. /// diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index a4fd02a..5254414 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -795,7 +795,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasNoKey(); entity.ToTable("one_day_chunk_metrics"); - entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("86400000000"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("1 day"); }); } } @@ -815,7 +815,7 @@ public async Task Should_Extract_ChunkTimeInterval_In_Microseconds_For_OneDay() // Assert HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "one_day_chunk_metrics")]; - Assert.Equal("86400000000", info.ChunkTimeInterval); + Assert.Equal("1 day", info.ChunkTimeInterval); } #endregion @@ -841,8 +841,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasNoKey(); entity.ToTable("seven_days_chunk_metrics"); - // 7 days = 604,800 seconds * 1,000,000 us/s = 604,800,000,000 us - entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("604800000000"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("7 days"); }); } } @@ -862,7 +861,7 @@ public async Task Should_Extract_ChunkTimeInterval_In_Microseconds_For_SevenDays // Assert HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "seven_days_chunk_metrics")]; - Assert.Equal("604800000000", info.ChunkTimeInterval); + Assert.Equal("7 days", info.ChunkTimeInterval); } #endregion @@ -889,9 +888,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasNoKey(); entity.ToTable("time_range_dim_metrics"); - // 30 days expressed in microseconds (30 * 86,400 * 1,000,000 = 2,592,000,000,000) entity.IsHypertable(x => x.Timestamp) - .HasDimension(Dimension.CreateRange("SecondaryTimestamp", "2592000000000")); + .HasDimension(Dimension.CreateRange("SecondaryTimestamp", "30 days")); }); } } @@ -915,7 +913,7 @@ public async Task Should_Extract_AdditionalDimension_TimeRange_In_Microseconds() Assert.Equal("SecondaryTimestamp", dimension.ColumnName); Assert.Equal(EDimensionType.Range, dimension.Type); // 30 days in microseconds - Assert.Equal("2592000000000", dimension.Interval); + Assert.Equal("30 days", dimension.Interval); } #endregion From 822d884424dc398587cba4523442e4bd05df6d22 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Mon, 15 Jun 2026 13:36:07 +0200 Subject: [PATCH 02/12] fix: support COUNT(*) wildcard in continuous aggregate functions --- .../ContinuousAggregateSqlGenerator.cs | 3 +- .../ContinuousAggregateModelExtractor.cs | 7 +- .../ContinuousAggregateModelExtractorTests.cs | 55 +++++++++++++ ...tinuousAggregateOperationGeneratorTests.cs | 34 ++++++++ .../ContinuousAggregateIntegrationTests.cs | 78 +++++++++++++++++++ .../HypertableScaffoldingExtractorTests.cs | 1 + .../ReorderPolicyAnnotationApplierTests.cs | 18 ++--- 7 files changed, 184 insertions(+), 12 deletions(-) diff --git a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index b354af9..4fcdb5d 100644 --- a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -79,7 +79,8 @@ public static List Generate(CreateContinuousAggregateOperation operation string sourceColumn = parts[2]; string sqlFunction = GetSqlAggregateFunction(functionEnumString); - string quotedSourceColumn = $"{SqlBuilderHelper.QuoteIdentifier(sourceColumn)}"; + // The COUNT(*) wildcard is not an identifier and must stay unquoted. + string quotedSourceColumn = sourceColumn == "*" ? "*" : SqlBuilderHelper.QuoteIdentifier(sourceColumn); string quotedAlias = $"{SqlBuilderHelper.QuoteIdentifier(alias)}"; string aggregateExpression; diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index 2d02975..d304159 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -102,8 +102,11 @@ public static IEnumerable GetContinuousAggre string functionEnumString = parts[1]; string sourceColumnModelName = parts[2]; - // Resolve source column name from parent entity - string? sourceColumnDbName = ColumnNameResolver.Resolve(parentEntityType, sourceColumnModelName, parentStoreIdentifier); + // Resolve source column name from parent entity. "*" is not a column + // but the COUNT(*) wildcard, so it bypasses resolution. + string? sourceColumnDbName = sourceColumnModelName == "*" + ? "*" + : ColumnNameResolver.Resolve(parentEntityType, sourceColumnModelName, parentStoreIdentifier); if (string.IsNullOrWhiteSpace(sourceColumnDbName)) { // Skip if source column not found diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 9509f75..2c03d7e 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -1322,6 +1322,61 @@ public void Should_Extract_Count_AggregateFunction() #endregion + #region Should_Extract_CountStar_AggregateFunction + + private class CountStarSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_count_star_metrics", ParentName = nameof(CountStarSourceMetric))] + [TimeBucket("1 hour", nameof(CountStarSourceMetric.Timestamp))] + private class CountStarHourlyMetric + { + public DateTime Bucket { get; set; } + + [Aggregate(EAggregateFunction.Count, "*")] + public long RecordCount { get; set; } + } + + private class CountStarContext : 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("count_star_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => entity.HasNoKey()); + } + } + + [Fact] + public void Should_Extract_CountStar_AggregateFunction() + { + using CountStarContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Single(operations); + Assert.Single(operations[0].AggregateFunctions); + Assert.Equal("RecordCount:Count:*", operations[0].AggregateFunctions[0]); + } + + #endregion + #region Should_Extract_First_AggregateFunction private class FirstAggregateFunctionSourceMetric diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index f42c89c..f80f7d2 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -100,6 +100,40 @@ GROUP BY time_bucket Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } + [Fact] + public void DesignTime_Create_WithCountStarAggregate_GeneratesUnquotedWildcard() + { + // Arrange - the COUNT(*) wildcard is not an identifier and must not be quoted + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_counts", + Schema = "public", + ParentName = "events", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "event_time", + TimeBucketGroupBy = true, + AggregateFunctions = ["record_count:Count:*"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + string expected = @" + CREATE MATERIALIZED VIEW ""public"".""hourly_counts"" + WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS + SELECT time_bucket('1 hour', ""event_time"") AS time_bucket, COUNT(*) AS ""record_count"" + FROM ""public"".""events"" + GROUP BY time_bucket; + "; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + [Fact] public void DesignTime_Create_WithTimescaleDBFirstLastFunctions_GeneratesCorrectSyntax() { diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs index ab2b278..9723db6 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -29,6 +29,7 @@ public async ValueTask DisposeAsync() { await _container.DisposeAsync(); } + GC.SuppressFinalize(this); } #region Should_Create_ContinuousAggregate_With_BasicAggregates @@ -1133,5 +1134,82 @@ await context.Database.ExecuteSqlRawAsync( } #endregion + + #region Should_Create_ContinuousAggregate_With_CountStar_And_Verify_Counts + + private class CountStarEvent + { + public DateTime Timestamp { get; set; } + public string? Category { get; set; } + public decimal Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "count_star_integration_aggregate", ParentName = nameof(CountStarEvent))] + [TimeBucket("1 hour", nameof(CountStarEvent.Timestamp))] + private class CountStarAggregate + { + public DateTime Bucket { get; set; } + + [Aggregate(EAggregateFunction.Count, "*")] + public long TotalCount { get; set; } + + [Aggregate(EAggregateFunction.Count, nameof(CountStarEvent.Category))] + public long CategoryCount { get; set; } + } + + private class CountStarIntegrationContext(string connectionString) : DbContext + { + public DbSet Events => Set(); + public DbSet Aggregates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("count_star_events"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.Property(x => x.TotalCount).HasColumnName("TotalCount"); + entity.Property(x => x.CategoryCount).HasColumnName("CategoryCount"); + }); + } + } + + [Fact] + public async Task Should_Create_ContinuousAggregate_With_CountStar_And_Verify_Counts() + { + await using CountStarIntegrationContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO count_star_events (""Timestamp"", ""Category"", ""Value"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"sports"}, {1.0m}), + ({new DateTime(2025, 1, 6, 10, 15, 0, DateTimeKind.Utc)}, {"news"}, {2.0m}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {(string?)null}, {3.0m})", + TestContext.Current.CancellationToken); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.count_star_integration_aggregate', NULL, NULL);", + [], TestContext.Current.CancellationToken); + + List aggregates = await context.Aggregates + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Single(aggregates); + Assert.Equal(3L, aggregates[0].TotalCount); + Assert.Equal(2L, aggregates[0].CategoryCount); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 5254414..4122b04 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -30,6 +30,7 @@ public async ValueTask DisposeAsync() { await _container.DisposeAsync(); } + GC.SuppressFinalize(this); } #region Should_Extract_Minimal_Hypertable diff --git a/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs index 851fc12..f89ef6f 100644 --- a/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs @@ -423,7 +423,7 @@ public void Should_Not_Apply_RetryPeriod_When_Default() ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, MaxRetries: DefaultValues.ReorderPolicyMaxRetries, - RetryPeriod: DefaultValues.ReorderPolicyScheduleInterval // "00:05:00" + RetryPeriod: DefaultValues.ReorderPolicyScheduleInterval ); // Act @@ -665,14 +665,14 @@ public void Should_Handle_Various_InitialStart_DateTimes(DateTime initialStart) Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); } - public static IEnumerable InitialStartTestData() - { - yield return new object[] { new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc) }; - yield return new object[] { new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc) }; - yield return new object[] { new DateTime(2024, 6, 15, 12, 30, 45, DateTimeKind.Utc) }; - yield return new object[] { DateTime.MinValue }; - yield return new object[] { DateTime.MaxValue }; - } + public static TheoryData InitialStartTestData() => + [ + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 6, 15, 12, 30, 45, DateTimeKind.Utc), + DateTime.MinValue, + DateTime.MaxValue, + ]; #endregion From 35635410c06edbdc51b4401e3724de05dfd7c6de Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Sun, 21 Jun 2026 18:12:39 +0200 Subject: [PATCH 03/12] test: expand scaffolding test coverage --- src/Eftdb.Design/Eftdb.Design.csproj | 3 + .../TimescaleModelCodeGeneratorSelector.cs | 7 +- .../Configuration/DimensionAttributeTests.cs | 137 +++ .../Conventions/HypertableConventionTests.cs | 148 +++ .../AnnotationRendererHelperTests.cs | 372 ++++++ .../HypertableAnnotationRendererTests.cs | 1049 +++++++++++++++++ .../Generators/TimescaleCSharpHelperTests.cs | 110 ++ .../TimescaleCSharpModelGeneratorTests.cs | 138 +++ .../HypertableScaffoldingExtractorTests.cs | 206 ++++ .../HypertableTypeBuilderTests.cs | 151 +++ 10 files changed, 2315 insertions(+), 6 deletions(-) create mode 100644 tests/Eftdb.Tests/Configuration/DimensionAttributeTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/TimescaleCSharpHelperTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs diff --git a/src/Eftdb.Design/Eftdb.Design.csproj b/src/Eftdb.Design/Eftdb.Design.csproj index b7aae2c..8134e29 100644 --- a/src/Eftdb.Design/Eftdb.Design.csproj +++ b/src/Eftdb.Design/Eftdb.Design.csproj @@ -45,4 +45,7 @@ + + + \ No newline at end of file diff --git a/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs b/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs index c4ebc6b..7a1bcb4 100644 --- a/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs +++ b/src/Eftdb.Design/Generators/TimescaleModelCodeGeneratorSelector.cs @@ -7,11 +7,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators /// /// Prefers over the built-in C# generator. /// - /// - /// The design-time tooling registers provider services before the EF Core defaults, so the default - /// last-registration-wins selection would always pick the built-in - /// over the provider's. User T4 templates () keep priority. - /// public class TimescaleModelCodeGeneratorSelector(IEnumerable services) : ModelCodeGeneratorSelector(services) { @@ -20,7 +15,7 @@ public override IModelCodeGenerator Select(ModelCodeGenerationOptions options) IModelCodeGenerator selected = base.Select(options); return selected.GetType() == typeof(CSharpModelGenerator) - ? Services.OfType().LastOrDefault() ?? selected + ? Services.OfType().FirstOrDefault() ?? selected : selected; } } diff --git a/tests/Eftdb.Tests/Configuration/DimensionAttributeTests.cs b/tests/Eftdb.Tests/Configuration/DimensionAttributeTests.cs new file mode 100644 index 0000000..d22cbf0 --- /dev/null +++ b/tests/Eftdb.Tests/Configuration/DimensionAttributeTests.cs @@ -0,0 +1,137 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +public class DimensionAttributeTests +{ + #region Range_Constructor_Throws_For_Null_ColumnName + + [Fact] + public void Range_Constructor_Throws_For_Null_ColumnName() + { + Assert.Throws(() => new DimensionAttribute(null!, EDimensionType.Range, "1 day")); + } + + #endregion + + #region Range_Constructor_Throws_For_Whitespace_ColumnName + + [Fact] + public void Range_Constructor_Throws_For_Whitespace_ColumnName() + { + Assert.Throws(() => new DimensionAttribute(" ", EDimensionType.Range, "1 day")); + } + + #endregion + + #region Range_Constructor_Throws_For_Hash_Type + + [Fact] + public void Range_Constructor_Throws_For_Hash_Type() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Hash, "1 day")); + } + + #endregion + + #region Range_Constructor_Throws_For_Null_Interval + + [Fact] + public void Range_Constructor_Throws_For_Null_Interval() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Range, null!)); + } + + #endregion + + #region Range_Constructor_Throws_For_Whitespace_Interval + + [Fact] + public void Range_Constructor_Throws_For_Whitespace_Interval() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Range, " ")); + } + + #endregion + + #region Range_Constructor_Sets_Properties + + [Fact] + public void Range_Constructor_Sets_Properties() + { + DimensionAttribute attr = new("MyColumn", EDimensionType.Range, "1 month"); + + Assert.Equal("MyColumn", attr.ColumnName); + Assert.Equal(EDimensionType.Range, attr.Type); + Assert.Equal("1 month", attr.Interval); + Assert.Equal(0, attr.NumberOfPartitions); + } + + #endregion + + #region Hash_Constructor_Throws_For_Null_ColumnName + + [Fact] + public void Hash_Constructor_Throws_For_Null_ColumnName() + { + Assert.Throws(() => new DimensionAttribute(null!, EDimensionType.Hash, 4)); + } + + #endregion + + #region Hash_Constructor_Throws_For_Whitespace_ColumnName + + [Fact] + public void Hash_Constructor_Throws_For_Whitespace_ColumnName() + { + Assert.Throws(() => new DimensionAttribute(" ", EDimensionType.Hash, 4)); + } + + #endregion + + #region Hash_Constructor_Throws_For_Range_Type + + [Fact] + public void Hash_Constructor_Throws_For_Range_Type() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Range, 4)); + } + + #endregion + + #region Hash_Constructor_Throws_For_Zero_NumberOfPartitions + + [Fact] + public void Hash_Constructor_Throws_For_Zero_NumberOfPartitions() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Hash, 0)); + } + + #endregion + + #region Hash_Constructor_Throws_For_Negative_NumberOfPartitions + + [Fact] + public void Hash_Constructor_Throws_For_Negative_NumberOfPartitions() + { + Assert.Throws(() => new DimensionAttribute("Col", EDimensionType.Hash, -1)); + } + + #endregion + + #region Hash_Constructor_Sets_Properties + + [Fact] + public void Hash_Constructor_Sets_Properties() + { + DimensionAttribute attr = new("MyColumn", EDimensionType.Hash, 8); + + Assert.Equal("MyColumn", attr.ColumnName); + Assert.Equal(EDimensionType.Hash, attr.Type); + Assert.Equal(8, attr.NumberOfPartitions); + Assert.Null(attr.Interval); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs index e10025c..75b664b 100644 --- a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs @@ -1,7 +1,9 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using System.Text.Json; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; @@ -854,6 +856,152 @@ public void Should_Not_Apply_MigrateData_By_Default() #endregion + #region DimensionAttribute_Range_Should_Set_AdditionalDimensions_Annotation + + [Hypertable("Timestamp")] + [Dimension("Location", EDimensionType.Range, "30 days")] + private class RangeDimensionAttrEntity + { + public DateTime Timestamp { get; set; } + public string? Location { get; set; } + } + + private class RangeDimensionAttrContext : DbContext + { + public DbSet Entities => 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("RangeDimensionAttr"); + }); + } + } + + [Fact] + public void DimensionAttribute_Range_Should_Set_AdditionalDimensions_Annotation() + { + using RangeDimensionAttrContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RangeDimensionAttrEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("Location", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[0].Type); + Assert.Equal("30 days", dimensions[0].Interval); + } + + #endregion + + #region DimensionAttribute_Hash_Should_Set_AdditionalDimensions_Annotation + + [Hypertable("Timestamp")] + [Dimension("WarehouseId", EDimensionType.Hash, 4)] + private class HashDimensionAttrEntity + { + public DateTime Timestamp { get; set; } + public int WarehouseId { get; set; } + } + + private class HashDimensionAttrContext : DbContext + { + public DbSet Entities => 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("HashDimensionAttr"); + }); + } + } + + [Fact] + public void DimensionAttribute_Hash_Should_Set_AdditionalDimensions_Annotation() + { + using HashDimensionAttrContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(HashDimensionAttrEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("WarehouseId", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[0].Type); + Assert.Equal(4, dimensions[0].NumberOfPartitions); + } + + #endregion + + #region Multiple_DimensionAttributes_Should_All_Appear_In_Annotation + + [Hypertable("Timestamp")] + [Dimension("Region", EDimensionType.Range, "1 month")] + [Dimension("TenantId", EDimensionType.Hash, 8)] + private class MultipleDimensionAttrsEntity + { + public DateTime Timestamp { get; set; } + public string? Region { get; set; } + public int TenantId { get; set; } + } + + private class MultipleDimensionAttrsContext : DbContext + { + public DbSet Entities => 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("MultipleDimensionAttrs"); + }); + } + } + + [Fact] + public void Multiple_DimensionAttributes_Should_All_Appear_In_Annotation() + { + using MultipleDimensionAttrsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MultipleDimensionAttrsEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Equal(2, dimensions.Count); + + Assert.Contains(dimensions, d => d.ColumnName == "Region" && d.Type == EDimensionType.Range && d.Interval == "1 month"); + Assert.Contains(dimensions, d => d.ColumnName == "TenantId" && d.Type == EDimensionType.Hash && d.NumberOfPartitions == 8); + } + + #endregion + #region MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI [Hypertable("Timestamp", MigrateData = true)] diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs new file mode 100644 index 0000000..8ea4700 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs @@ -0,0 +1,372 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +public class AnnotationRendererHelperTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + #region Find_Returns_Annotation_When_Key_Exists + + [Fact] + public void Find_Returns_Annotation_When_Key_Exists() + { + Dictionary annotations = Annotations(("some:Key", "hello")); + + IAnnotation? result = AnnotationRendererHelper.Find(annotations, "some:Key"); + + Assert.NotNull(result); + Assert.Equal("hello", result.Value); + } + + #endregion + + #region Find_Returns_Null_When_Key_Missing + + [Fact] + public void Find_Returns_Null_When_Key_Missing() + { + Dictionary annotations = Annotations(); + + IAnnotation? result = AnnotationRendererHelper.Find(annotations, "missing:Key"); + + Assert.Null(result); + } + + #endregion + + #region GetString_Returns_String_Value + + [Fact] + public void GetString_Returns_String_Value() + { + Dictionary annotations = Annotations(("some:Key", "myValue")); + + string? result = AnnotationRendererHelper.GetString(annotations, "some:Key"); + + Assert.Equal("myValue", result); + } + + #endregion + + #region GetString_Returns_Null_When_Value_Is_Not_String + + [Fact] + public void GetString_Returns_Null_When_Value_Is_Not_String() + { + Dictionary annotations = Annotations(("some:Key", 42)); + + string? result = AnnotationRendererHelper.GetString(annotations, "some:Key"); + + Assert.Null(result); + } + + #endregion + + #region GetString_Returns_Null_When_Key_Missing + + [Fact] + public void GetString_Returns_Null_When_Key_Missing() + { + Dictionary annotations = Annotations(); + + string? result = AnnotationRendererHelper.GetString(annotations, "missing:Key"); + + Assert.Null(result); + } + + #endregion + + #region SplitColumns_Returns_Empty_For_Null + + [Fact] + public void SplitColumns_Returns_Empty_For_Null() + { + string[] result = AnnotationRendererHelper.SplitColumns(null); + + Assert.Empty(result); + } + + #endregion + + #region SplitColumns_Returns_Empty_For_Whitespace + + [Fact] + public void SplitColumns_Returns_Empty_For_Whitespace() + { + string[] result = AnnotationRendererHelper.SplitColumns(" "); + + Assert.Empty(result); + } + + #endregion + + #region SplitColumns_Returns_Single_Entry + + [Fact] + public void SplitColumns_Returns_Single_Entry() + { + string[] result = AnnotationRendererHelper.SplitColumns("Timestamp"); + + Assert.Single(result); + Assert.Equal("Timestamp", result[0]); + } + + #endregion + + #region SplitColumns_Returns_Multiple_Trimmed_Entries + + [Fact] + public void SplitColumns_Returns_Multiple_Trimmed_Entries() + { + string[] result = AnnotationRendererHelper.SplitColumns(" DeviceId , TenantId "); + + Assert.Equal(2, result.Length); + Assert.Equal("DeviceId", result[0]); + Assert.Equal("TenantId", result[1]); + } + + #endregion + + #region Consume_Removes_Specified_Keys + + [Fact] + public void Consume_Removes_Specified_Keys() + { + Dictionary annotations = Annotations( + ("key:A", "a"), + ("key:B", "b"), + ("key:C", "c")); + + AnnotationRendererHelper.Consume(annotations, "key:A", "key:C"); + + Assert.False(annotations.ContainsKey("key:A")); + Assert.True(annotations.ContainsKey("key:B")); + Assert.False(annotations.ContainsKey("key:C")); + } + + #endregion + + #region Consume_Does_Not_Throw_For_Missing_Keys + + [Fact] + public void Consume_Does_Not_Throw_For_Missing_Keys() + { + Dictionary annotations = Annotations(("key:A", "a")); + + // Should not throw even if key is absent + AnnotationRendererHelper.Consume(annotations, "key:A", "key:NotPresent"); + + Assert.Empty(annotations); + } + + #endregion + + #region ResolvePropertyName_Maps_Column_Name_To_Property_Name + + private class ColumnMappingEntity + { + public DateTime EventTime { get; set; } + public double Value { get; set; } + } + + private class ColumnMappingContext : DbContext + { + public DbSet Items => 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("column_mapping_items"); + entity.Property(x => x.EventTime).HasColumnName("event_time"); + }); + } + } + + [Fact] + public void ResolvePropertyName_Maps_Column_Name_To_Property_Name() + { + using ColumnMappingContext context = new(); + IEntityType entityType = GetEntityType(context); + + string resolved = AnnotationRendererHelper.ResolvePropertyName(entityType, "event_time"); + + Assert.Equal("EventTime", resolved); + } + + #endregion + + #region ResolvePropertyName_Returns_Raw_When_Not_Found + + private class UnmappedColumnEntity + { + public DateTime Timestamp { get; set; } + } + + private class UnmappedColumnContext : DbContext + { + public DbSet Items => 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("unmapped_items"); + }); + } + } + + [Fact] + public void ResolvePropertyName_Returns_Raw_When_Not_Found() + { + using UnmappedColumnContext context = new(); + IEntityType entityType = GetEntityType(context); + + string resolved = AnnotationRendererHelper.ResolvePropertyName(entityType, "nonexistent_column"); + + Assert.Equal("nonexistent_column", resolved); + } + + #endregion + + #region TryResolvePropertyName_Returns_True_On_Match + + private class TryResolveMatchEntity + { + public DateTime CreatedAt { get; set; } + } + + private class TryResolveMatchContext : DbContext + { + public DbSet Items => 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("try_resolve_match_items"); + entity.Property(x => x.CreatedAt).HasColumnName("created_at"); + }); + } + } + + [Fact] + public void TryResolvePropertyName_Returns_True_On_Match() + { + using TryResolveMatchContext context = new(); + IEntityType entityType = GetEntityType(context); + + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "created_at", out string propertyName); + + Assert.True(found); + Assert.Equal("CreatedAt", propertyName); + } + + #endregion + + #region TryResolvePropertyName_Returns_False_When_Not_Found + + private class TryResolveNoMatchEntity + { + public DateTime Timestamp { get; set; } + } + + private class TryResolveNoMatchContext : DbContext + { + public DbSet Items => 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("try_resolve_no_match_items"); + }); + } + } + + [Fact] + public void TryResolvePropertyName_Returns_False_When_Not_Found() + { + using TryResolveNoMatchContext context = new(); + IEntityType entityType = GetEntityType(context); + + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "ghost_column", out string propertyName); + + Assert.False(found); + Assert.Equal("ghost_column", propertyName); + } + + #endregion + + #region TryResolvePropertyName_View_Mapped_Entity_Returns_True_On_Match + + private class ViewMappedEntity + { + public DateTime CreatedAt { get; set; } + public string? Name { get; set; } + } + + private class ViewMappedContext : DbContext + { + public DbSet Items => 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.ToView("view_mapped_items"); + entity.Property(x => x.CreatedAt).HasColumnName("created_at"); + }); + } + } + + [Fact] + public void TryResolvePropertyName_View_Mapped_Entity_Returns_True_On_Match() + { + using ViewMappedContext context = new(); + IEntityType entityType = GetEntityType(context); + + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "created_at", out string propertyName); + + Assert.True(found); + Assert.Equal("CreatedAt", propertyName); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs new file mode 100644 index 0000000..a877865 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs @@ -0,0 +1,1049 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for HypertableAnnotationRenderer exercised through the public +/// surface (no InternalsVisibleTo on the design project). +/// +public class HypertableAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private static string DimensionsJson(params Dimension[] dimensions) + => JsonSerializer.Serialize(dimensions.ToList()); + + // ── GenerateFluentApiCalls ────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Not_Set + + private class NotHypertableEntity { public DateTime Ts { get; set; } } + + private class NotHypertableContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_not_ht"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Not_Set() + { + using NotHypertableContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations(); // no IsHypertable + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_TimeColumn_Is_Missing + + private class NoTimeColumnEntity { public DateTime Ts { get; set; } } + + private class NoTimeColumnContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_no_tc"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_TimeColumn_Is_Missing() + { + using NoTimeColumnContext context = new(); + IEntityType entityType = GetEntityType(context); + // IsHypertable is true but no HypertableTimeColumn annotation + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + } + + #endregion + + #region GenerateFluentApiCalls_Minimal_Returns_IsHypertable_Fragment + + private class MinimalFluentEntity { public DateTime Ts { get; set; } } + + private class MinimalFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_minimal"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Minimal_Returns_IsHypertable_Fragment() + { + using MinimalFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.FirstOrDefault(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.NotNull(hypertableCall); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.IsHypertable), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithChunkTimeInterval + + private class ChunkTimeIntervalFluentEntity { public DateTime Ts { get; set; } } + + private class ChunkTimeIntervalFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_cti"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithChunkTimeInterval() + { + using ChunkTimeIntervalFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, "1 day")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithChunkTimeInterval), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionSegmentBy + + private class SegmentByFluentEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SegmentByFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_segby"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionSegmentBy() + { + using SegmentByFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSegmentBy, "DeviceId"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithCompressionSegmentBy), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionOrderBy_Ascending + + private class OrderByAscFluentEntity { public DateTime EventTime { get; set; } } + + private class OrderByAscFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ob_asc"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_Ascending() + { + using OrderByAscFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "EventTime"), + (HypertableAnnotations.CompressionOrderBy, "EventTime ASC"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithCompressionOrderBy), chain); + + // Find the WithCompressionOrderBy fragment and verify the closure uses ByAscending + MethodCallCodeFragment? current = hypertableCall; + MethodCallCodeFragment? orderByCall = null; + while (current != null) + { + if (current.Method == nameof(HypertableTypeBuilder.WithCompressionOrderBy)) + { + orderByCall = current; + break; + } + current = current.ChainedCall; + } + Assert.NotNull(orderByCall); + NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); + Assert.Equal(nameof(OrderBySelector<>.ByAscending), closure.MethodCalls[0].Method); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionOrderBy_Descending + + private class OrderByDescFluentEntity { public int DeviceId { get; set; } public DateTime Ts { get; set; } } + + private class OrderByDescFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ob_desc"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_Descending() + { + using OrderByDescFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "DeviceId DESC"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + MethodCallCodeFragment? current = hypertableCall; + MethodCallCodeFragment? orderByCall = null; + while (current != null) + { + if (current.Method == nameof(HypertableTypeBuilder.WithCompressionOrderBy)) { orderByCall = current; break; } + current = current.ChainedCall; + } + Assert.NotNull(orderByCall); + NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); + Assert.Equal(nameof(OrderBySelector<>.ByDescending), closure.MethodCalls[0].Method); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NoDirection + + private class OrderByNoDirFluentEntity { public DateTime EventTime { get; set; } public DateTime Ts { get; set; } } + + private class OrderByNoDirFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ob_nodir"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NoDirection() + { + using OrderByNoDirFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "EventTime"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + MethodCallCodeFragment? current = hypertableCall; + MethodCallCodeFragment? orderByCall = null; + while (current != null) + { + if (current.Method == nameof(HypertableTypeBuilder.WithCompressionOrderBy)) { orderByCall = current; break; } + current = current.ChainedCall; + } + Assert.NotNull(orderByCall); + NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); + Assert.Equal(nameof(OrderBySelector<>.By), closure.MethodCalls[0].Method); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NullsFirst + + private class OrderByNullsFirstFluentEntity { public DateTime EventTime { get; set; } public DateTime Ts { get; set; } } + + private class OrderByNullsFirstFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ob_nullsfirst"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NullsFirst() + { + using OrderByNullsFirstFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "EventTime ASC NULLS FIRST"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + MethodCallCodeFragment? current = hypertableCall; + MethodCallCodeFragment? orderByCall = null; + while (current != null) + { + if (current.Method == nameof(HypertableTypeBuilder.WithCompressionOrderBy)) { orderByCall = current; break; } + current = current.ChainedCall; + } + Assert.NotNull(orderByCall); + NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); + Assert.Equal(nameof(OrderBySelector<>.ByAscending), closure.MethodCalls[0].Method); + // Second argument to ByAscending should be true (nullsFirst) + Assert.Equal(2, closure.MethodCalls[0].Arguments.Count); + Assert.Equal(true, closure.MethodCalls[0].Arguments[1]); + } + + #endregion + + #region GenerateFluentApiCalls_SegmentBy_Suppresses_Standalone_EnableCompression + + private class SegmentByNoEnableCompressionEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SegmentByNoEnableCompressionContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_segby_nosep"); }); + } + + [Fact] + public void GenerateFluentApiCalls_SegmentBy_Suppresses_Standalone_EnableCompression() + { + using SegmentByNoEnableCompressionContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSegmentBy, "DeviceId"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + // SegmentBy implicitly enables compression, so standalone EnableCompression should not appear + Assert.DoesNotContain(nameof(HypertableTypeBuilder.EnableCompression), chain); + Assert.Contains(nameof(HypertableTypeBuilder.WithCompressionSegmentBy), chain); + } + + #endregion + + #region GenerateFluentApiCalls_EmitsStandalone_EnableCompression_When_No_SegmentBy_Or_OrderBy + + private class StandaloneEnableCompressionEntity { public DateTime Ts { get; set; } } + + private class StandaloneEnableCompressionContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_standalone_ec"); }); + } + + [Fact] + public void GenerateFluentApiCalls_EmitsStandalone_EnableCompression_When_No_SegmentBy_Or_OrderBy() + { + using StandaloneEnableCompressionContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.EnableCompression), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithChunkSkipping + + private class ChunkSkippingFluentEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class ChunkSkippingFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_chunk_skip"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithChunkSkipping() + { + using ChunkSkippingFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkSkipColumns, "DeviceId")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithChunkSkipping), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithMigrateData + + private class MigrateDataFluentEntity { public DateTime Ts { get; set; } } + + private class MigrateDataFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_migrate"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithMigrateData() + { + using MigrateDataFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.MigrateData, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithMigrateData), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_HasRangeDimension + + private class RangeDimFluentEntity { public DateTime Ts { get; set; } public string? Region { get; set; } } + + private class RangeDimFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_range_dim"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_HasRangeDimension() + { + using RangeDimFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateRange("Region", "1 month")))); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.HasRangeDimension), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_HasHashDimension + + private class HashDimFluentEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class HashDimFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_hash_dim"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_HasHashDimension() + { + using HashDimFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateHash("DeviceId", 4)))); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.HasHashDimension), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Consumes_All_Annotations + + private class ConsumeAllFluentEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class ConsumeAllFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_consume_all"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Consumes_All_Annotations() + { + using ConsumeAllFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, "1 day"), + (HypertableAnnotations.EnableCompression, true), + (HypertableAnnotations.CompressionSegmentBy, "DeviceId"), + (HypertableAnnotations.CompressionOrderBy, "Ts DESC"), + (HypertableAnnotations.ChunkSkipColumns, "DeviceId"), + (HypertableAnnotations.MigrateData, true), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateHash("DeviceId", 4)))); + + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + Assert.DoesNotContain(HypertableAnnotations.IsHypertable, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.HypertableTimeColumn, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.ChunkTimeInterval, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.EnableCompression, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.CompressionSegmentBy, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.CompressionOrderBy, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.ChunkSkipColumns, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.MigrateData, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.AdditionalDimensions, annotations.Keys); + } + + #endregion + + // ── GenerateDataAnnotationAttributes ─────────────────────────────────── + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Not_Set + + private class DataAnnotNotHypertableEntity { public DateTime Ts { get; set; } } + + private class DataAnnotNotHypertableContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_not_ht"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Not_Set() + { + using DataAnnotNotHypertableContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations(); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + Assert.DoesNotContain(result, a => a.Type == typeof(HypertableAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Minimal_Returns_HypertableAttribute + + private class DataAnnotMinimalEntity { public DateTime Ts { get; set; } } + + private class DataAnnotMinimalContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_minimal"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Minimal_Returns_HypertableAttribute() + { + using DataAnnotMinimalContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + Assert.Contains(result, a => a.Type == typeof(HypertableAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Includes_EnableCompression_In_Named_Args + + private class DataAnnotEnableCompressionEntity { public DateTime Ts { get; set; } } + + private class DataAnnotEnableCompressionContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_enable_comp"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Includes_EnableCompression_In_Named_Args() + { + using DataAnnotEnableCompressionContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.EnableCompression))); + Assert.Equal(true, hypertableAttr.NamedArguments[nameof(HypertableAttribute.EnableCompression)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Includes_ChunkTimeInterval_When_NonDefault + + private class DataAnnotChunkIntervalNonDefaultEntity { public DateTime Ts { get; set; } } + + private class DataAnnotChunkIntervalNonDefaultContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_cti_nondefault"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Includes_ChunkTimeInterval_When_NonDefault() + { + using DataAnnotChunkIntervalNonDefaultContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, "1 hour")); // not the default "7 days" + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.ChunkTimeInterval))); + Assert.Equal("1 hour", hypertableAttr.NamedArguments[nameof(HypertableAttribute.ChunkTimeInterval)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_ChunkTimeInterval_When_Equal_To_Default + + private class DataAnnotChunkIntervalDefaultEntity { public DateTime Ts { get; set; } } + + private class DataAnnotChunkIntervalDefaultContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_cti_default"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_ChunkTimeInterval_When_Equal_To_Default() + { + using DataAnnotChunkIntervalDefaultContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, DefaultValues.ChunkTimeInterval)); // "7 days" == default + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.False(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.ChunkTimeInterval))); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Includes_MigrateData_In_Named_Args + + private class DataAnnotMigrateDataEntity { public DateTime Ts { get; set; } } + + private class DataAnnotMigrateDataContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_migrate"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Includes_MigrateData_In_Named_Args() + { + using DataAnnotMigrateDataContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.MigrateData, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.MigrateData))); + Assert.Equal(true, hypertableAttr.NamedArguments[nameof(HypertableAttribute.MigrateData)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_EmitsDimension_Hash_Attribute + + private class DataAnnotHashDimEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class DataAnnotHashDimContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_hash_dim"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_EmitsDimension_Hash_Attribute() + { + using DataAnnotHashDimContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateHash("DeviceId", 8)))); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + Assert.Contains(result, a => a.Type == typeof(DimensionAttribute)); + AttributeCodeFragment dimAttr = result.First(a => a.Type == typeof(DimensionAttribute)); + // Second positional arg should be EDimensionType.Hash + Assert.Equal(EDimensionType.Hash, dimAttr.Arguments[1]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_EmitsDimension_Range_Attribute + + private class DataAnnotRangeDimEntity { public DateTime Ts { get; set; } public string? Region { get; set; } } + + private class DataAnnotRangeDimContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_range_dim"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_EmitsDimension_Range_Attribute() + { + using DataAnnotRangeDimContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateRange("Region", "1 month")))); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + Assert.Contains(result, a => a.Type == typeof(DimensionAttribute)); + AttributeCodeFragment dimAttr = result.First(a => a.Type == typeof(DimensionAttribute)); + Assert.Equal(EDimensionType.Range, dimAttr.Arguments[1]); + Assert.Equal("1 month", dimAttr.Arguments[2]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Consumes_All_Annotations + + private class DataAnnotConsumeAllEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class DataAnnotConsumeAllContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_consume_all"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Consumes_All_Annotations() + { + using DataAnnotConsumeAllContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, "1 hour"), + (HypertableAnnotations.EnableCompression, true), + (HypertableAnnotations.CompressionSegmentBy, "DeviceId"), + (HypertableAnnotations.CompressionOrderBy, "Ts DESC"), + (HypertableAnnotations.ChunkSkipColumns, "DeviceId"), + (HypertableAnnotations.MigrateData, true), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(Dimension.CreateHash("DeviceId", 4)))); + + CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + Assert.DoesNotContain(HypertableAnnotations.IsHypertable, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.HypertableTimeColumn, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.ChunkTimeInterval, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.EnableCompression, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.CompressionSegmentBy, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.CompressionOrderBy, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.ChunkSkipColumns, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.MigrateData, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.AdditionalDimensions, annotations.Keys); + } + + #endregion + + #region GenerateFluentApiCalls_MalformedDimensions_Json_Does_Not_Throw + + private class MalformedDimEntity { public DateTime Ts { get; set; } } + + private class MalformedDimContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_malformed_dim"); }); + } + + [Fact] + public void GenerateFluentApiCalls_MalformedDimensions_Json_Does_Not_Throw() + { + using MalformedDimContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, "not-valid-json")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.FirstOrDefault(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.NotNull(hypertableCall); + List chain = CollectMethodChain(hypertableCall); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.HasRangeDimension), chain); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.HasHashDimension), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NullsLast + + private class OrderByNullsLastFluentEntity { public DateTime EventTime { get; set; } public DateTime Ts { get; set; } } + + private class OrderByNullsLastFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ob_nullslast"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NullsLast() + { + using OrderByNullsLastFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "EventTime DESC NULLS LAST"), + (HypertableAnnotations.EnableCompression, true)); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + MethodCallCodeFragment? current = hypertableCall; + MethodCallCodeFragment? orderByCall = null; + while (current != null) + { + if (current.Method == nameof(HypertableTypeBuilder.WithCompressionOrderBy)) { orderByCall = current; break; } + current = current.ChainedCall; + } + Assert.NotNull(orderByCall); + NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); + Assert.Equal(nameof(OrderBySelector<>.ByDescending), closure.MethodCalls[0].Method); + Assert.Equal(2, closure.MethodCalls[0].Arguments.Count); + Assert.Equal(false, closure.MethodCalls[0].Arguments[1]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CompressionSegmentBy_NamedArgument_Contains_NameOfCodeFragment + + private class SegmentByNofEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SegmentByNofContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_segby_nof"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_CompressionSegmentBy_NamedArgument_Contains_NameOfCodeFragment() + { + using SegmentByNofContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSegmentBy, "DeviceId")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.CompressionSegmentBy))); + object?[] segmentByArray = Assert.IsType(hypertableAttr.NamedArguments[nameof(HypertableAttribute.CompressionSegmentBy)]); + Assert.All(segmentByArray, entry => Assert.IsType(entry)); + } + + #endregion + + #region GenerateFluentApiCalls_WhitespaceOnly_TimeColumn_Returns_Empty + + private class WhitespaceTimeColumnEntity { public DateTime Ts { get; set; } } + + private class WhitespaceTimeColumnContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ws_tc"); }); + } + + [Fact] + public void GenerateFluentApiCalls_WhitespaceOnly_TimeColumn_Returns_Empty() + { + using WhitespaceTimeColumnContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, " ")); + + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpHelperTests.cs new file mode 100644 index 0000000..b7057b5 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpHelperTests.cs @@ -0,0 +1,110 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +/// +/// Tests that verify TimescaleCSharpHelper.UnknownLiteral renders +/// values and mixed arrays correctly, while delegating other types to the base class. +/// +public class TimescaleCSharpHelperTests +{ + private readonly ICSharpHelper _code = DesignTimeHelper.CreateRealCSharpHelper(); + + #region UnknownLiteral_NameOfCodeFragment_NoSuffix_RendersAsNameof + + [Fact] + public void UnknownLiteral_NameOfCodeFragment_NoSuffix_RendersAsNameof() + { + NameOfCodeFragment fragment = new("Timestamp"); + + string result = _code.UnknownLiteral(fragment); + + Assert.Equal("nameof(Timestamp)", result); + } + + #endregion + + #region UnknownLiteral_NameOfCodeFragment_WithSuffix_RendersAsInterpolatedString + + [Fact] + public void UnknownLiteral_NameOfCodeFragment_WithSuffix_RendersAsInterpolatedString() + { + NameOfCodeFragment fragment = new("Timestamp", " DESC"); + + string result = _code.UnknownLiteral(fragment); + + Assert.Equal("$\"{nameof(Timestamp)} DESC\"", result); + } + + #endregion + + #region UnknownLiteral_MixedArray_RendersAsNewArrayExpression + + [Fact] + public void UnknownLiteral_MixedArray_RendersAsNewArrayExpression() + { + object?[] array = [new NameOfCodeFragment("MyProp"), "literal"]; + + string result = _code.UnknownLiteral(array); + + Assert.StartsWith("new[] {", result); + Assert.Contains("nameof(MyProp)", result); + Assert.Contains("\"literal\"", result); + } + + #endregion + + #region UnknownLiteral_PlainString_Passthrough + + [Fact] + public void UnknownLiteral_PlainString_Passthrough() + { + string result = _code.UnknownLiteral("hello"); + + Assert.Equal("\"hello\"", result); + } + + #endregion + + #region UnknownLiteral_Integer_Passthrough + + [Fact] + public void UnknownLiteral_Integer_Passthrough() + { + string result = _code.UnknownLiteral(42); + + Assert.Equal("42", result); + } + + #endregion + + #region UnknownLiteral_Null_Passthrough + + [Fact] + public void UnknownLiteral_Null_Passthrough() + { + string result = _code.UnknownLiteral(null); + + Assert.Equal("null", result); + } + + #endregion + + #region UnknownLiteral_AllNameOfCodeFragment_Array_RendersAsNewArrayExpression + + [Fact] + public void UnknownLiteral_AllNameOfCodeFragment_Array_RendersAsNewArrayExpression() + { + object?[] array = [new NameOfCodeFragment("PropA"), new NameOfCodeFragment("PropB")]; + + string result = _code.UnknownLiteral(array); + + Assert.StartsWith("new[] {", result); + Assert.Contains("nameof(PropA)", result); + Assert.Contains("nameof(PropB)", result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs new file mode 100644 index 0000000..5c804d5 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs @@ -0,0 +1,138 @@ +#pragma warning disable EF1001 +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +public class TimescaleCSharpModelGeneratorTests +{ + private static IModelCodeGeneratorSelector CreateSelector() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static ModelCodeGenerationOptions DefaultOptions(bool useDataAnnotations = false) => new() + { + Language = "C#", + UseDataAnnotations = useDataAnnotations, + ProjectDir = ".", + ModelNamespace = "TestModels", + ContextName = "TestDbContext", + ContextNamespace = "TestModels", + ConnectionString = "Host=localhost;Database=test" + }; + + // ── Selector tests ───────────────────────────────────────────────────── + + #region Select_Returns_TimescaleCSharpModelGenerator_When_BaseReturns_CSharpModelGenerator + + [Fact] + public void Select_Returns_TimescaleCSharpModelGenerator_When_BaseReturns_CSharpModelGenerator() + { + IModelCodeGeneratorSelector selector = CreateSelector(); + + IModelCodeGenerator generator = selector.Select(DefaultOptions()); + + Assert.IsType(generator); + } + + #endregion + + #region Select_Returns_CSharpModelGenerator_When_TimescaleCSharpModelGenerator_Not_In_Services + + [Fact] + public void Select_Returns_CSharpModelGenerator_When_TimescaleCSharpModelGenerator_Not_In_Services() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + IModelCodeGeneratorSelector selector = services.BuildServiceProvider() + .GetRequiredService(); + + IModelCodeGenerator generator = selector.Select(DefaultOptions()); + + Assert.NotNull(generator); + Assert.IsType(generator); + } + + #endregion + + // ── GenerateModel tests ──────────────────────────────────────────────── + + #region GenerateModel_UseDataAnnotations_False_Does_Not_Inject_Timescale_Usings + + private class NoAnnotationsEntity { public DateTime Ts { get; set; } } + + [Hypertable(nameof(Ts))] + private class HypertableAnnotatedEntityFalse { public DateTime Ts { get; set; } } + + private class GenerateModelFalseContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("gen_model_false"); }); + } + + [Fact] + public void GenerateModel_UseDataAnnotations_False_Does_Not_Inject_Timescale_Usings() + { + using GenerateModelFalseContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: false); + IModelCodeGenerator generator = selector.Select(options); + + ScaffoldedModel result = generator.GenerateModel(model, options); + + Assert.All(result.AdditionalFiles, file => + Assert.DoesNotContain("using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable;", file.Code)); + } + + #endregion + + #region GenerateModel_UseDataAnnotations_True_Injects_Hypertable_Namespace + + [Hypertable(nameof(Ts))] + private class HypertableAnnotatedEntityTrue { public DateTime Ts { get; set; } } + + private class GenerateModelTrueContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("gen_model_true"); }); + } + + [Fact] + public void GenerateModel_UseDataAnnotations_True_Injects_Hypertable_Namespace() + { + using GenerateModelTrueContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + IModelCodeGenerator generator = selector.Select(options); + + ScaffoldedModel result = generator.GenerateModel(model, options); + + // The entity file should contain the Hypertable namespace using + bool found = result.AdditionalFiles.Any(f => + f.Code.Contains("using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable;")); + Assert.True(found); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 4122b04..10a54c9 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -978,4 +978,210 @@ public async Task Should_Extract_Multiple_Hypertables() } #endregion + + #region Should_Humanize_ChunkTimeInterval_OneHour + + private class OneHourChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OneHourChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("humanize_1h_metrics"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("1 hour"); + }); + } + + [Fact] + public async Task Should_Humanize_ChunkTimeInterval_OneHour() + { + await using OneHourChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_1h_metrics")]; + Assert.Equal("1 hour", info.ChunkTimeInterval); + } + + #endregion + + #region Should_Humanize_ChunkTimeInterval_TwoHours + + private class TwoHoursChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TwoHoursChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("humanize_2h_metrics"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("2 hours"); + }); + } + + [Fact] + public async Task Should_Humanize_ChunkTimeInterval_TwoHours() + { + await using TwoHoursChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_2h_metrics")]; + Assert.Equal("2 hours", info.ChunkTimeInterval); + } + + #endregion + + #region Should_Humanize_ChunkTimeInterval_TwoDays + + private class TwoDaysChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TwoDaysChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("humanize_2d_metrics"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("2 days"); + }); + } + + [Fact] + public async Task Should_Humanize_ChunkTimeInterval_TwoDays() + { + await using TwoDaysChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_2d_metrics")]; + Assert.Equal("2 days", info.ChunkTimeInterval); + } + + #endregion + + #region Should_Humanize_ChunkTimeInterval_OneMinute + + private class OneMinuteChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OneMinuteChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("humanize_1min_metrics"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("1 minute"); + }); + } + + [Fact] + public async Task Should_Humanize_ChunkTimeInterval_OneMinute() + { + await using OneMinuteChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_1min_metrics")]; + Assert.Equal("1 minute", info.ChunkTimeInterval); + } + + #endregion + + #region Should_Humanize_ChunkTimeInterval_NonRound_Falls_Back_To_Seconds + + private class NonRoundChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NonRoundChunkIntervalContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("humanize_nonround_metrics"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("90 seconds"); + }); + } + + [Fact] + public async Task Should_Humanize_ChunkTimeInterval_NonRound_Falls_Back_To_Seconds() + { + await using NonRoundChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_nonround_metrics")]; + // 90s doesn't fit a round minute, so humanizer outputs "90 seconds" + Assert.Equal("90 seconds", info.ChunkTimeInterval); + } + + #endregion } diff --git a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs index e27fa0a..d244469 100644 --- a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -1343,4 +1343,155 @@ public void WithCompressionSegmentBy_Should_Throw_For_Converted_Non_Member_Expre } #endregion + + #region HasRangeDimension_Should_Add_Range_Dimension_To_AdditionalDimensions_Annotation + + private class HasRangeDimensionEntity + { + public DateTime Timestamp { get; set; } + public string? Region { get; set; } + } + + private class HasRangeDimensionContext : 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.HasNoKey(); + entity.ToTable("metrics_has_range_dimension"); + entity.IsHypertable(x => x.Timestamp) + .HasRangeDimension(x => (object)x.Region!, "1 month"); + }); + } + } + + [Fact] + public void HasRangeDimension_Should_Add_Range_Dimension_To_AdditionalDimensions_Annotation() + { + using HasRangeDimensionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(HasRangeDimensionEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("Region", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[0].Type); + Assert.Equal("1 month", dimensions[0].Interval); + } + + #endregion + + #region HasHashDimension_Should_Add_Hash_Dimension_To_AdditionalDimensions_Annotation + + private class HasHashDimensionEntity + { + public DateTime Timestamp { get; set; } + public int WarehouseId { get; set; } + } + + private class HasHashDimensionContext : 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.HasNoKey(); + entity.ToTable("metrics_has_hash_dimension"); + entity.IsHypertable(x => x.Timestamp) + .HasHashDimension(x => (object)x.WarehouseId, 8); + }); + } + } + + [Fact] + public void HasHashDimension_Should_Add_Hash_Dimension_To_AdditionalDimensions_Annotation() + { + using HasHashDimensionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(HasHashDimensionEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Single(dimensions); + Assert.Equal("WarehouseId", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[0].Type); + Assert.Equal(8, dimensions[0].NumberOfPartitions); + } + + #endregion + + #region HasRangeDimension_And_HasHashDimension_Can_Be_Combined + + private class CombinedDimensionEntity + { + public DateTime Timestamp { get; set; } + public string? Region { get; set; } + public int WarehouseId { get; set; } + } + + private class CombinedDimensionContext : 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.HasNoKey(); + entity.ToTable("metrics_combined_dimensions"); + entity.IsHypertable(x => x.Timestamp) + .HasRangeDimension(x => (object)x.Region!, "30 days") + .HasHashDimension(x => (object)x.WarehouseId, 4); + }); + } + } + + [Fact] + public void HasRangeDimension_And_HasHashDimension_Can_Be_Combined() + { + using CombinedDimensionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CombinedDimensionEntity))!; + + string? dimensionsJson = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)?.Value as string; + Assert.NotNull(dimensionsJson); + + List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); + Assert.NotNull(dimensions); + Assert.Equal(2, dimensions.Count); + + Assert.Equal("Region", dimensions[0].ColumnName); + Assert.Equal(EDimensionType.Range, dimensions[0].Type); + Assert.Equal("30 days", dimensions[0].Interval); + + Assert.Equal("WarehouseId", dimensions[1].ColumnName); + Assert.Equal(EDimensionType.Hash, dimensions[1].Type); + Assert.Equal(4, dimensions[1].NumberOfPartitions); + } + + #endregion } From da24c0ad293e1a9bff1344ed53f58bcfc9d53c83 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Sun, 21 Jun 2026 23:44:47 +0200 Subject: [PATCH 04/12] chores: update reference and agent documents with new scaffolding patterns --- .claude/agents/eftdb-bug-fixer.md | 203 ++--------------- .claude/agents/eftdb-feature-implementer.md | 101 ++------- .claude/agents/eftdb-feature-initializer.md | 76 ++----- .claude/agents/eftdb-scaffold-support.md | 238 ++++++++------------ .claude/agents/example-feature-generator.md | 98 ++------ .claude/reference/architecture.md | 49 ++-- .claude/reference/file-organization.md | 26 ++- .claude/reference/patterns.md | 40 +++- 8 files changed, 252 insertions(+), 579 deletions(-) diff --git a/.claude/agents/eftdb-bug-fixer.md b/.claude/agents/eftdb-bug-fixer.md index c0fe457..66d4e16 100644 --- a/.claude/agents/eftdb-bug-fixer.md +++ b/.claude/agents/eftdb-bug-fixer.md @@ -120,56 +120,7 @@ Before fixing, understand WHY the bug exists: - Use `SqlBuilderHelper` for SQL construction - Use `StoreObjectIdentifier` pattern for column names -**Fix Pattern Examples:** - -```csharp -// Bug: Missing null check causing NullReferenceException -// INCORRECT FIX - Too broad -public void ProcessEntity(IEntityType entity) -{ - try - { - var annotation = entity.FindAnnotation("SomeKey")?.Value; - // ... process - } - catch (Exception ex) - { - // Swallow all exceptions - } -} - -// CORRECT FIX - Targeted null check -public void ProcessEntity(IEntityType entity) -{ - IAnnotation? annotation = entity.FindAnnotation("SomeKey"); - if (annotation?.Value == null) - { - return; // Or handle appropriately - } - - // ... process with guaranteed non-null value -} -``` - -```csharp -// Bug: Column name not respecting naming convention -// INCORRECT FIX - Hard-coded conversion -string columnName = propertyName.ToSnakeCase(); // Don't assume convention - -// CORRECT FIX - Use EF Core's convention system -StoreObjectIdentifier storeIdentifier = StoreObjectIdentifier.Table(tableName, schema); -string columnName = property.GetColumnName(storeIdentifier); -``` - -```csharp -// Bug: identifier not quoted via the helper -// INCORRECT FIX - Hard-coded quotes -string sql = $"SELECT * FROM \"{schema}\".\"{table}\""; - -// CORRECT FIX - Use SqlBuilderHelper -string qualifiedName = SqlBuilderHelper.QualifiedIdentifier(table, schema); -string sql = $"SELECT * FROM {qualifiedName}"; -``` +**Fix patterns:** For null-check, column-name resolution, and identifier-quoting approaches, see `.claude/reference/patterns.md` sections 7–8 — both include INCORRECT vs CORRECT examples. ### Phase 4: Verification @@ -194,144 +145,26 @@ After implementing the fix: ## Common Debugging Techniques -### For Annotation Issues: -```csharp -// Add diagnostic logging -IAnnotation? annotation = entity.FindAnnotation(SomeAnnotations.KeyName); -if (annotation == null) -{ - // Log: Expected annotation not found - return null; -} - -// Verify annotation value type -if (annotation.Value is not string expectedValue) -{ - // Log: Annotation value has unexpected type - return null; -} -``` - -### For SQL Generation Issues: -```csharp -// Inspect the statements returned by the feature SqlGenerator -List statements = HypertableSqlGenerator.Generate(operation); -foreach (string statement in statements) -{ - System.Diagnostics.Debug.WriteLine($"Generated SQL: {statement}"); -} -``` - -### For Differ Issues: -```csharp -// Compare properties one by one -bool hasChanges = - sourceInfo.Property1 != targetInfo.Property1 || - sourceInfo.Property2 != targetInfo.Property2 || - !AreListsEqual(sourceInfo.List1, targetInfo.List1); - -// Log what changed -if (sourceInfo.Property1 != targetInfo.Property1) -{ - // Log: Property1 changed from X to Y -} -``` +Add temporary `Console.Error.WriteLine` or `Debug.WriteLine` statements to inspect annotation values, generated SQL strings, or differ property comparisons. Remove all diagnostic output before committing. ## Handoff Protocol -### Successful Fix Completion: - -``` -✅ BUG FIX COMPLETE - -Bug Description: -[Brief description of the bug] - -Root Cause: -[Explanation of why the bug occurred] - -Files Modified: -- [File path 1] - [Brief description of change] -- [File path 2] - [Brief description of change] - -Fix Summary: -[1-2 paragraph explanation of what was changed and why] - -Verification: -□ Solution builds successfully -□ Bug reproduction case now works correctly -□ No regressions observed -□ Existing tests still pass (if applicable) - -NEXT STEPS: -→ Use test-writer agent to add regression test - (Prevents this bug from reoccurring) - -→ Use git-committer agent when ready to commit - (Creates fix: [bug description] commit) - -RECOMMENDATION: -Add test case covering: [specific scenario that exposed this bug] -``` - -### When Additional Issues are Discovered: - -While fixing one bug, you might discover related issues: - -``` -⚠️ ADDITIONAL ISSUE FOUND - -While fixing [Original Bug], discovered related issue: - -Secondary Issue: -[Description of additional bug found] - -File Affected: [File path] - -Relationship to Original Bug: -[How this relates to the bug being fixed] - -OPTIONS: -1. Fix both issues together (if closely related and fix is still minimal) -2. Fix original bug only, create separate bug report for secondary issue - -RECOMMENDATION: [Choice with rationale] - -If proceeding with option 2: -→ Complete current fix first -→ Document secondary issue clearly -→ User can relaunch eftdb-bug-fixer for secondary issue -``` - -### When Fix Requires Design Change: - -If the bug cannot be fixed without significant design changes: - -``` -❌ BUG REQUIRES ARCHITECTURAL CHANGE - -Bug: [Description] -File: [Path] - -Analysis: -[Explanation of why simple fix won't work] - -Issue: -The current architecture [describe limitation] which prevents a proper fix. - -Required Changes: -1. [Architectural change 1] -2. [Architectural change 2] -3. [Impact on existing code] - -RECOMMENDATION: -This is beyond bug-fixing scope. Options: -1. Implement workaround with known limitations: [describe workaround] -2. Plan architectural refactoring (coordinate with user) -3. Document as known limitation if low impact - -Cannot proceed with standard bug fix. User decision required. -``` +**On successful fix**, report: +- Description of the bug and its root cause +- Files modified with a one-line description of each change +- Verification: solution builds, reproduction case resolved, no regressions, existing tests pass +- Next step: launch `test-writer` agent to add a regression test + +**If an additional issue is found during the fix**, report: +- Description of the secondary issue and which file is affected +- How it relates to the original bug +- Recommendation: fix both together (if closely related) or complete the original fix first and relaunch for the secondary issue + +**If the fix requires architectural change**, report: +- Why a minimal fix is insufficient (the architectural constraint preventing it) +- What structural changes would be required and their impact +- Options: implement a known-limitation workaround, plan a refactoring, or document as a known limitation +- Stop work — user decision required before proceeding ## Quality Standards diff --git a/.claude/agents/eftdb-feature-implementer.md b/.claude/agents/eftdb-feature-implementer.md index 46a651b..cea8e23 100644 --- a/.claude/agents/eftdb-feature-implementer.md +++ b/.claude/agents/eftdb-feature-implementer.md @@ -105,13 +105,7 @@ NEVER manually construct qualified names or handle quoting yourself. ### Column Name Resolution -ALWAYS use the StoreObjectIdentifier pattern: -```csharp -var storeIdentifier = StoreObjectIdentifier.Table(tableName, schema); -var columnName = property.GetColumnName(storeIdentifier); -``` - -This automatically handles all naming conventions (snake_case, camelCase, PascalCase, custom conventions). +ALWAYS use `StoreObjectIdentifier.Table(tableName, schema)` and `property.GetColumnName(storeIdentifier)` — see the code example under Model Extractor above. NEVER manually convert property names to column names or assume a naming convention. ## Code Quality Standards @@ -165,86 +159,17 @@ You are not just writing code - you are extending a carefully architected system ## Handoff Protocol -### When Operations Don't Exist - ABORT with Instructions: - -``` -❌ CANNOT PROCEED - OPERATIONS MISSING - -Required operation classes not found in Operations/ directory: -- Create[Feature]Operation.cs -- Alter[Feature]Operation.cs (if applicable) -- Drop[Feature]Operation.cs (if applicable) - -REQUIRED ACTION: -→ Use eftdb-feature-initializer agent first to create the foundational scaffolding - -The eftdb-feature-initializer agent will create: -- Operation classes with all required properties -- FluentAPI configuration methods -- Data attributes (if applicable) -- Convention implementations -- Annotation constant definitions - -Once the feature initializer completes, relaunch this agent to implement the migration logic. -``` - -### Successful Completion Handoff: - -``` -✅ MIGRATION IMPLEMENTATION COMPLETE - -Implemented Components: -- Internals/Features/[Feature]/[Feature]ModelExtractor.cs -- Internals/Features/[Feature]/[Feature]Differ.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]) - -NEXT STEPS: -→ Use eftdb-scaffold-support agent to implement db-first scaffolding - (Creates: ScaffoldingExtractor, AnnotationApplier for reverse engineering from database) - -→ Then use test-writer agent to create comprehensive tests - (Creates: Unit tests for differ/extractor, integration tests for SQL generation) - -→ Then use example-feature-generator agent to create usage examples - (Creates: Example models showcasing the new feature) - -TESTING CHECKLIST before proceeding: -□ Run `dotnet build` - verify no compilation errors -□ Test with Eftdb.Samples.CodeFirst: - □ Run `dotnet ef migrations add Test[Feature]Migration --project samples/Eftdb.Samples.CodeFirst` - □ Inspect generated C# code in migration file - □ Run `dotnet ef database update --project samples/Eftdb.Samples.CodeFirst` - □ Verify SQL execution succeeds -□ Test column naming conventions (try snake_case) -□ Verify operation priority ordering in migrations -``` - -### When Discovering Bugs in Other Code: - -If you encounter bugs in existing code while implementing: - -``` -⚠️ EXISTING BUG DETECTED DURING IMPLEMENTATION - -File: [File path] -Line: [Approximate line number] -Component: [Differ/Extractor/Generator/Other] - -Issue Description: -[Clear description of the bug] - -Impact on Current Implementation: -[How this bug affects your work] +**If operations are missing (abort)**, report: +- Which operation files are missing from `Operations/` +- Instruct the user to run `eftdb-feature-initializer` first; relaunch this agent after -REQUIRED ACTION: -→ Use eftdb-bug-fixer agent to resolve the existing bug first +**On successful completion**, report: +- List of files created and updated +- Operation priority value chosen and the rationale +- Next agents in sequence: `eftdb-scaffold-support` → `test-writer` → `example-feature-generator` +- Testing checklist: `dotnet build`, generate a test migration, inspect the C# output, run `database update`, verify SQL, test column naming conventions -This agent will pause current implementation. After the bug is fixed, relaunch this agent to continue the feature implementation. -``` +**If a bug is found in existing code during implementation**, report: +- File, approximate line, and component affected +- How it blocks the current implementation +- Stop work; instruct the user to run `eftdb-bug-fixer` to resolve it first, then relaunch this agent diff --git a/.claude/agents/eftdb-feature-initializer.md b/.claude/agents/eftdb-feature-initializer.md index 1ee0da6..c508ab5 100644 --- a/.claude/agents/eftdb-feature-initializer.md +++ b/.claude/agents/eftdb-feature-initializer.md @@ -124,66 +124,16 @@ Remember: You are creating the architectural foundation. Other agents or develop ## Handoff Protocol -After successfully creating the initial feature scaffolding, provide clear next steps: - -### Successful Completion Handoff: - -``` -✅ FEATURE INITIALIZATION COMPLETE - -Created Files: -- Operations/[Feature]Operation.cs -- Configuration/[Feature]/[Feature]TypeBuilder.cs -- Configuration/[Feature]/[Feature]Annotations.cs -- Configuration/[Feature]/[Feature]Attribute.cs (if applicable) -- Conventions/[Feature]Convention.cs -- Updated: TimescaleDbContextOptionsBuilderExtensions.cs - -NEXT STEPS: -→ Use eftdb-feature-implementer agent to implement migration logic - (Creates: Differ, ModelExtractor, SqlGenerator, MigrationExtensions, CSharpGenerator) - -→ Then use eftdb-scaffold-support agent for db-first scaffolding - (Creates: ScaffoldingExtractor, AnnotationApplier) - -→ Then use test-writer agent to create comprehensive tests - -→ Finally use example-feature-generator agent to create usage examples -``` - -### When User Needs More Information: - -If the user's feature request is ambiguous or lacks TimescaleDB specifics: - -``` -⚠️ NEED MORE INFORMATION - -To properly scaffold [Feature] support, need clarification on: -1. [Specific question about TimescaleDB SQL syntax] -2. [Specific question about parameters/options] -3. [Specific question about table vs database scope] - -Please provide: -- Link to TimescaleDB documentation for this feature -- Example SQL commands showing the feature in use -- List of parameters that should be configurable - -Once clarified, relaunch this agent to create the scaffolding. -``` - -### When Feature is Not Feasible: - -If analysis determines a feature cannot be properly integrated: - -``` -❌ FEATURE NOT FEASIBLE FOR EF CORE INTEGRATION - -Reason: [Clear technical explanation] - -Alternatives: -1. [Suggest workaround using existing features] -2. [Suggest raw SQL approach if applicable] -3. [Explain what would need to change for feasibility] - -This agent will not proceed with scaffolding. Consider discussing alternatives with the user. -``` +**On successful completion**, report: +- Files created: operation class(es) in `Operations/`, fluent API builder, annotation constants, data annotation attribute, and convention class in `Configuration/[Feature]/` +- Files updated: `TimescaleDbContextOptionsBuilderExtensions.cs` (convention registration) +- Next agents in sequence: `eftdb-feature-implementer` → `eftdb-scaffold-support` → `test-writer` → `example-feature-generator` + +**If more information is needed**, report: +- Specific questions about TimescaleDB SQL syntax, available parameters, or table vs. database scope +- What to provide before relaunching: documentation link, example SQL commands, list of configurable parameters + +**If the feature is not feasible for EF Core integration**, report: +- Clear technical reason why integration is not possible +- Available alternatives: workarounds using existing features, raw SQL approach +- Stop work — do not scaffold diff --git a/.claude/agents/eftdb-scaffold-support.md b/.claude/agents/eftdb-scaffold-support.md index 5df2753..4662595 100644 --- a/.claude/agents/eftdb-scaffold-support.md +++ b/.claude/agents/eftdb-scaffold-support.md @@ -11,7 +11,9 @@ You are a specialized TimescaleDB scaffolding architect with deep expertise in E You are ONLY permitted to work within: - CmdScale.EntityFrameworkCore.TimescaleDB.Design/Scaffolding/ directory +- CmdScale.EntityFrameworkCore.TimescaleDB.Design/Generators/AnnotationRenderers/ directory - CmdScale.EntityFrameworkCore.TimescaleDB.Design/TimescaleDatabaseModelFactory.cs +- CmdScale.EntityFrameworkCore.TimescaleDB.Design/TimescaleDbAnnotationCodeGenerator.cs You are ABSOLUTELY FORBIDDEN from: - Modifying any files in other projects (Runtime, Tests, Example, etc.) @@ -101,78 +103,91 @@ Scaffolding/ ### 5. Extractor Implementation Pattern +Key rules: +- Use `async/await` with `CancellationToken` +- Use `new NpgsqlParameter("p0", schema)` — NEVER string-interpolate values into SQL +- Always pass schema and table as `@p0`/`@p1` parameters +- Read columns by ordinal, not by name + +Follow `HypertableScaffoldingExtractor` as the reference implementation. + +### 6. Applier Implementation Pattern + +Key rules: +- Find the table by matching both `Schema` and `Name` (null-safe) +- Use annotation constants from `TimescaleDbAnnotationNames` — never hard-code annotation key strings +- JSON-serialize complex types (lists, objects) before storing as annotations + +Follow `HypertableAnnotationApplier` as the reference implementation. + +### 7. Annotation Code Generation + +Scaffolding has two phases. The extractor/applier pipeline (sections 1–6) handles phase 1: reading the database and placing annotations on the `DatabaseModel`. Phase 2 converts those annotations into generated C# code — either fluent API calls or data annotation attributes. This second phase is implemented via `IFeatureAnnotationRenderer`. + +**Create an annotation renderer** (in `Generators/AnnotationRenderers/`): + ```csharp -public class [Feature]Extractor +internal sealed class [Feature]AnnotationRenderer : IFeatureAnnotationRenderer { - private readonly IRelationalConnection _connection; - - public [Feature]Extractor(IRelationalConnection connection) + public void GenerateFluentApiCalls( + IEntityType entityType, + Dictionary annotations, + CSharpRuntimeAnnotationCodeGeneratorParameters parameters) { - _connection = connection; + // Read your annotation + string? value = AnnotationRendererHelper.GetString(annotations, [Feature]Annotations.SomeKey); + if (value is null) return; + + // Build the fluent API call fragment + parameters.Statements.Add(new MethodCallCodeFragment( + nameof(SomeExtension.SomeMethod), + value)); + + // Mark annotation as consumed so EF does not emit a raw .HasAnnotation() fallback + AnnotationRendererHelper.Consume(annotations, [Feature]Annotations.SomeKey); } - - public async Task> ExtractAsync( - string schema, - string tableName, - CancellationToken cancellationToken = default) + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, + Dictionary annotations) { - var sql = @" - SELECT column1, column2, column3 - FROM timescaledb_information.[feature_view] - WHERE schema_name = @p0 AND table_name = @p1"; - - var command = _connection.DbConnection.CreateCommand(); - command.CommandText = sql; - command.Parameters.Add(new NpgsqlParameter("p0", schema)); - command.Parameters.Add(new NpgsqlParameter("p1", tableName)); - - var results = new List<[Feature]Metadata>(); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - results.Add(new [Feature]Metadata - { - Property1 = reader.GetString(0), - Property2 = reader.GetInt32(1), - // Map all relevant columns - }); - } - - return results; + string? value = AnnotationRendererHelper.GetString(annotations, [Feature]Annotations.SomeKey); + if (value is null) return []; + + AnnotationRendererHelper.Consume(annotations, [Feature]Annotations.SomeKey); + return [new AttributeCodeFragment(typeof([Feature]Attribute), value)]; } } ``` -### 6. Applier Implementation Pattern +**Key helpers in `AnnotationRendererHelper`:** +- `Find(annotations, key)` — returns the annotation or null +- `GetString(annotations, key)` — casts annotation value to string or returns null +- `SplitColumns(csv)` — splits a comma-separated column string, trims, skips empty entries +- `Consume(annotations, keys...)` — removes keys from the dictionary to prevent EF's `.HasAnnotation()` fallback +- `ResolvePropertyName(entityType, columnName)` — maps a database column name to the EF property name +- `TryResolvePropertyName(entityType, columnName, out propertyName)` — same, returns false when no mapping exists + +**For refactoring-safe property references** (generates `nameof(X)` instead of string literals): ```csharp -public class [Feature]Applier -{ - public void Apply(DatabaseModel databaseModel, List<[Feature]Metadata> metadata) - { - foreach (var item in metadata) - { - var table = databaseModel.Tables.FirstOrDefault(t => - t.Schema == item.Schema && t.Name == item.TableName); - - if (table == null) continue; - - // Apply simple annotations - table.AddAnnotation( - TimescaleDbAnnotationNames.[Feature]Property, - item.Value); - - // Serialize complex types as JSON - table.AddAnnotation( - TimescaleDbAnnotationNames.[Feature]ComplexProperty, - JsonSerializer.Serialize(item.ComplexValue)); - } - } -} +// Produces nameof(MyEntity.Timestamp) in the scaffolded code +NameOfCodeFragment nameOf = new(propertyName); + +// Produces $"{nameof(MyEntity.Timestamp)} DESC" +NameOfCodeFragment nameOfDesc = new(propertyName, " DESC"); + +// Pass as argument — TimescaleCSharpHelper.UnknownLiteral handles rendering +parameters.Statements.Add(new MethodCallCodeFragment( + nameof(SomeExtension.SomeMethod), + nameOf)); ``` -### 7. Testing Your Scaffolding +**Register your renderer** in `TimescaleDbAnnotationCodeGenerator` by adding it to the renderer list in the constructor. + +**`using` directives**: If your renderer emits data annotation attributes from a new namespace, the namespace must be added to `TimescaleCSharpModelGenerator.CollectAttributeNamespaces()` so it is injected into the scaffolded entity files when `UseDataAnnotations = true`. + +### 8. Testing Your Scaffolding After implementing scaffolding support: @@ -210,8 +225,9 @@ When assigned a scaffolding task: 5. **Design Applier**: Map metadata to EF Core annotations 6. **Implement & Organize**: Create extractor and applier in proper directories 7. **Integrate**: Update TimescaleDatabaseModelFactory to use your components -8. **Validate**: Ensure annotations match runtime library expectations EXACTLY -9. **Report Issues**: If runtime library has bugs/missing features, report and abort +8. **Implement Renderer**: Create `[Feature]AnnotationRenderer` in `Generators/AnnotationRenderers/` and register it in `TimescaleDbAnnotationCodeGenerator` +9. **Validate**: Ensure annotations match runtime library expectations EXACTLY +10. **Report Issues**: If runtime library has bugs/missing features, report and abort ## COMMUNICATION PROTOCOL @@ -239,86 +255,20 @@ Remember: Your expertise is in design-time scaffolding. Stay in your lane, repor ## Handoff Protocol -### Successful Completion Handoff: - -``` -✅ SCAFFOLDING IMPLEMENTATION COMPLETE - -Implemented Components: -- Design/Scaffolding/[Feature]ScaffoldingExtractor.cs -- Design/Scaffolding/[Feature]AnnotationApplier.cs -- Updated: Design/TimescaleDatabaseModelFactory.cs - -TimescaleDB System Tables Queried: -- [List of timescaledb_information views used] -- [List of _timescaledb_catalog tables used] - -NEXT STEPS: -→ Use test-writer agent to create scaffolding tests - (Creates: Tests verifying extraction from database and annotation application) - -→ Then use example-feature-generator agent to create db-first examples - (Creates: Example showing `dotnet ef dbcontext scaffold` with this feature) - -TESTING CHECKLIST before proceeding: -□ Start TimescaleDB via docker-compose -□ Create test database with [Feature] enabled -□ Run: dotnet ef dbcontext scaffold "connection_string" Npgsql.EntityFrameworkCore.PostgreSQL -□ Verify generated DbContext includes [Feature] annotations -□ Verify generated entity configurations are correct -□ Verify generated code compiles -□ Verify migrations can be generated from scaffolded code -``` - -### When Runtime Library Has Issues: - -If you discover that the runtime library's annotations don't match your scaffolding needs: - -``` -⚠️ BLOCKING ISSUE - RUNTIME LIBRARY MISMATCH - -Project: CmdScale.EntityFrameworkCore.TimescaleDB -File: [File path to annotation constants or model extractor] -Line: [Approximate line number] - -Problem Description: -[Clear description of mismatch between what scaffolding needs and what runtime provides] - -Examples: -- Missing annotation constant for [specific property] -- ModelExtractor expects different data format than scaffolding can provide -- Annotation name inconsistency - -Impact on Scaffolding: -[Explain why this blocks your scaffolding work] - -Recommended Fix in Runtime Library: -[Specific changes needed in the runtime library] - -REQUIRED ACTION: -→ Use eftdb-bug-fixer agent to resolve the runtime library issue first - -This agent will pause scaffolding implementation. After the runtime issue is fixed, relaunch this agent to continue scaffolding development. -``` - -### When TimescaleDB Feature is Version-Dependent: - -If the feature requires specific TimescaleDB version: - -``` -⚠️ VERSION DEPENDENCY DETECTED - -Feature: [Feature name] -Minimum TimescaleDB Version: [Version number] -System Tables/Views Used: [List] - -IMPLEMENTATION NOTES: -- Added version check in extractor to gracefully handle older TimescaleDB versions -- Extractor returns empty results if feature tables/views don't exist -- Logs warning when feature is unavailable due to version - -NEXT STEPS: -→ Document version requirement in README or feature documentation -→ Consider adding version detection utility if not already present -→ Proceed with test-writer agent for testing against multiple TimescaleDB versions -``` +**On successful completion**, report: +- Files created: `Scaffolding/[Feature]ScaffoldingExtractor.cs`, `Scaffolding/[Feature]AnnotationApplier.cs`, `Generators/AnnotationRenderers/[Feature]AnnotationRenderer.cs` +- Files updated: `TimescaleDatabaseModelFactory.cs`, `TimescaleDbAnnotationCodeGenerator.cs` +- TimescaleDB system views and catalog tables queried +- Next steps: launch `test-writer` agent for scaffolding tests, then `example-feature-generator` for db-first examples +- Testing checklist: start docker-compose, run `dotnet ef dbcontext scaffold`, verify generated entity includes correct annotations and fluent API or attribute code, verify generated code compiles + +**If the runtime library has a blocking issue**, report: +- File, approximate line, and description of the mismatch between what scaffolding needs and what the runtime provides +- Why it blocks the scaffolding work +- Recommended fix (suggest `eftdb-bug-fixer`) +- Stop work — cannot proceed until resolved + +**If a TimescaleDB version dependency is detected**, report: +- Minimum required TimescaleDB version +- System tables/views used and whether they require a version guard +- Note if the extractor gracefully returns empty results on older versions diff --git a/.claude/agents/example-feature-generator.md b/.claude/agents/example-feature-generator.md index 7f6a6bc..f0ebe54 100644 --- a/.claude/agents/example-feature-generator.md +++ b/.claude/agents/example-feature-generator.md @@ -173,86 +173,18 @@ Your examples are the face of the library for users - they must be clear, correc ## Handoff Protocol -### Successful Completion Handoff: - -``` -✅ EXAMPLES CREATED SUCCESSFULLY - -Created/Modified Files: -- samples/Eftdb.Samples.Shared/Models/[Entity].cs (Data Annotations approach) -- samples/Eftdb.Samples.Shared/Models/[Entity]FluentApi.cs (Fluent API approach) -- samples/Eftdb.Samples.Shared/Configurations/[Entity]Configuration.cs -- Updated: samples/Eftdb.Samples.CodeFirst/TimescaleContext.cs (added DbSets) - -Features Demonstrated: -- [List of TimescaleDB features shown in examples] -- [Configuration approaches: Data Annotations, Fluent API, or both] - -NEXT STEPS: -→ Use test-writer agent to create tests for the example models - (Optional but recommended for complex examples) - -→ Use git-committer agent to commit the examples - (Run formatter, verify build, create commit) - -VERIFICATION CHECKLIST: -□ Run `dotnet build` on Example projects - verify compilation -□ Run `dotnet ef migrations add ExampleTest` - verify migration generation -□ Inspect generated migration - verify correct SQL operations -□ Run `dotnet ef database update` - verify migration applies successfully -□ Examples demonstrate both simple and advanced usage patterns -□ XML documentation is complete and helpful -``` - -### When Feature Implementation is Incomplete: - -If examples reveal that the feature implementation is missing or buggy: - -``` -⚠️ IMPLEMENTATION ISSUE DISCOVERED - -While creating examples for [Feature], discovered: - -Issue Type: [Missing functionality / Bug / Incomplete implementation] - -Problem Description: -[Clear description of what's wrong] - -File Affected: [Runtime or Design project file path] - -Example That Exposed Issue: -[Code snippet showing what user would try to do] - -Expected Behavior: -[What should happen] - -Actual Behavior: -[What currently happens or error message] - -REQUIRED ACTION: -→ Use eftdb-bug-fixer agent to resolve the implementation issue - -This agent cannot proceed with examples until the underlying feature works correctly. After the bug is fixed, relaunch this agent to complete the examples. -``` - -### When Examples Need Testing: - -For complex examples with multiple interacting features: - -``` -⚠️ COMPLEX EXAMPLE - RECOMMEND TESTING - -Created Examples: -- [List of example models] - -Complexity Factors: -- [Multiple TimescaleDB features combined] -- [Complex aggregate functions or time bucketing] -- [Advanced query scenarios] - -RECOMMENDATION: -→ Use test-writer agent to create integration tests for these examples - (Ensures examples remain working as library evolves) - -This helps maintain example quality and catches breaking changes early. -``` +**On successful completion**, report: +- Files created/modified within `.Example` projects +- TimescaleDB features demonstrated and which configuration approaches are shown (data annotations, Fluent API, or both) +- Next step: `git-committer` agent for commit preparation; `test-writer` agent if the example combines multiple features +- Verification checklist: `dotnet build`, generate a test migration and inspect the SQL, run `database update` + +**If an implementation issue is discovered**, report: +- Issue type: missing functionality, bug, or incomplete implementation +- File affected in the runtime or design project +- The example code that exposed the issue and the expected vs. actual behavior +- Stop work; instruct the user to run `eftdb-bug-fixer` first, then relaunch this agent + +**For complex multi-feature examples**, note: +- Which features are combined and any interaction risks +- Recommend `test-writer` agent to cover the example with integration tests diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 0ef9f76..1ca9ad8 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -42,11 +42,12 @@ This document provides detailed architectural information for the CmdScale.Entit > When adding new features, follow the same directory structure pattern. -#### Hypertable/ (4 files) +#### Hypertable/ (5 files) - `HypertableAttribute.cs` - Data annotation: `[Hypertable("TimeColumn", ChunkTimeInterval = "1 day")]` -- `HypertableConvention.cs` - IEntityTypeAddedConvention implementation +- `DimensionAttribute.cs` - Data annotation for additional partitioning dimensions: `[Dimension("Col", EDimensionType.Range, "1 month")]` +- `HypertableConvention.cs` - IEntityTypeAddedConvention implementation; processes both `[Hypertable]` and `[Dimension]` attributes - `HypertableAnnotations.cs` - Annotation constants -- `HypertableTypeBuilder.cs` - Fluent API: `IsHypertable()`, `WithChunkTimeInterval()`, etc. +- `HypertableTypeBuilder.cs` - Fluent API: `IsHypertable()`, `WithChunkTimeInterval()`, `HasRangeDimension()`, `HasHashDimension()`, etc. #### ReorderPolicy/ (3 files) - `ReorderPolicyAttribute.cs` - Data annotation: `[ReorderPolicy("index_name")]` @@ -177,6 +178,8 @@ ReorderPolicyMaxRuntime = "00:00:00" // no limit - Registers: - `ICSharpMigrationOperationGenerator` → `TimescaleCSharpMigrationOperationGenerator` - `IDatabaseModelFactory` → `TimescaleDatabaseModelFactory` + - `IAnnotationCodeGenerator` → `TimescaleDbAnnotationCodeGenerator` + - `IModelCodeGeneratorSelector` → `TimescaleModelCodeGeneratorSelector` ### TimescaleCSharpMigrationOperationGenerator.cs @@ -186,6 +189,8 @@ ReorderPolicyMaxRuntime = "00:00:00" // no limit ### Generators/ - Design-Time C# Generation +#### Migration code generation + Each `*CSharpGenerator.Generate(XxxOperation, IndentedStringBuilder)` emits one typed `migrationBuilder` call, with one named argument per line. | File | Purpose | @@ -198,24 +203,42 @@ Each `*CSharpGenerator.Generate(XxxOperation, IndentedStringBuilder)` emits one | `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 +#### Annotation code generation (scaffolding phase 2) + +Converts `DatabaseModel` annotations to C# fluent API calls or data annotation attributes in scaffolded entity files. -Orchestrates db-first scaffolding with extractor/applier pairs: -- `HypertableScaffoldingExtractor` + `HypertableAnnotationApplier` +| File | Purpose | +|------|---------| +| `TimescaleModelCodeGeneratorSelector.cs` | Selects `TimescaleCSharpModelGenerator` over EF Core's default `CSharpModelGenerator` | +| `TimescaleCSharpModelGenerator.cs` | Wraps base model generator; injects TimescaleDB `using` directives when `UseDataAnnotations = true` | +| `TimescaleDbAnnotationCodeGenerator.cs` | `IAnnotationCodeGenerator` implementation; dispatches to `IFeatureAnnotationRenderer` instances | +| `TimescaleCSharpHelper.cs` | Extends `ICSharpHelper.UnknownLiteral` to render `NameOfCodeFragment` and mixed `object?[]` arrays | +| `AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface: `GenerateFluentApiCalls` + `GenerateDataAnnotationAttributes` | +| `AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable and dimension annotations to fluent API or data annotation attributes | +| `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | +| `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | + +### Scaffolding Pipeline + +`dotnet ef dbcontext scaffold` runs in two phases: + +**Phase 1 — Database extraction** (`TimescaleDatabaseModelFactory.cs` + `Scaffolding/`): +`TimescaleDatabaseModelFactory` overrides NpgsqlDatabaseModelFactory. After the base factory builds the `DatabaseModel` from the database schema, it calls each extractor/applier pair to layer TimescaleDB metadata on top as annotations: +- `HypertableScaffoldingExtractor` + `HypertableAnnotationApplier` — hypertable config, dimensions, chunk time interval - `ReorderPolicyScaffoldingExtractor` + `ReorderPolicyAnnotationApplier` - `ContinuousAggregateScaffoldingExtractor` + `ContinuousAggregateAnnotationApplier` -### Scaffolding/ +**Phase 2 — Annotation code generation** (`TimescaleDbAnnotationCodeGenerator` + `AnnotationRenderers/`): +EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to convert those annotations into C# code. The dispatcher iterates its registered `IFeatureAnnotationRenderer` implementations: +- When `UseDataAnnotations = false` → `GenerateFluentApiCalls` → fluent API method chains in `OnModelCreating` +- When `UseDataAnnotations = true` → `GenerateDataAnnotationAttributes` → `[Attribute]` declarations on entity classes + +`TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. -**Interfaces:** +**Scaffolding/ Interfaces:** - `ITimescaleFeatureExtractor.cs` - `Extract(DbConnection connection)` returns feature metadata - `IAnnotationApplier.cs` - `ApplyAnnotations(DatabaseTable table, object featureInfo)` -**Feature Extractors** query TimescaleDB system tables: -- `HypertableScaffoldingExtractor.cs` - Queries `timescaledb_information.hypertables`, dimensions, chunk stats -- `ReorderPolicyScaffoldingExtractor.cs` - Queries `timescaledb_information.jobs` -- `ContinuousAggregateScaffoldingExtractor.cs` - Queries continuous aggregate metadata - ### build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets - MSBuild integration that injects DesignTimeServicesReference attribute diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 98e31c7..e507edf 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -21,6 +21,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/Hypertable/HypertableTypeBuilder.cs` | Fluent API | | `Configuration/Hypertable/HypertableAnnotations.cs` | Annotation constants | | `Configuration/Hypertable/HypertableAttribute.cs` | Data annotation | +| `Configuration/Hypertable/DimensionAttribute.cs` | Data annotation for additional partitioning dimensions | | `Configuration/Hypertable/HypertableConvention.cs` | Convention processing | | `Internals/Features/Hypertables/HypertableDiffer.cs` | Diffing logic | | `Internals/Features/Hypertables/HypertableModelExtractor.cs` | Model extraction | @@ -124,6 +125,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times ## Design Library Key Files +### Entry Points & Migration Code Generation + | File | Purpose | |------|---------| | `TimescaleDBDesignTimeServices.cs` | Register design-time services | @@ -135,6 +138,24 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `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 | + +### Annotation Code Generation (Scaffolding Phase 2) + +| File | Purpose | +|------|---------| +| `Generators/TimescaleModelCodeGeneratorSelector.cs` | Prefers `TimescaleCSharpModelGenerator` over base `CSharpModelGenerator` | +| `Generators/TimescaleCSharpModelGenerator.cs` | Injects TimescaleDB `using` directives when `UseDataAnnotations = true` | +| `Generators/TimescaleDbAnnotationCodeGenerator.cs` | Dispatches to `IFeatureAnnotationRenderer` implementations | +| `Generators/TimescaleCSharpHelper.cs` | Extends `ICSharpHelper.UnknownLiteral` for `NameOfCodeFragment` and mixed arrays | +| `Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface | +| `Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable annotations to fluent API or data annotation C# | +| `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | +| `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | + +### Scaffolding (Phase 1: Database Extraction) + +| File | Purpose | +|------|---------| | `TimescaleDatabaseModelFactory.cs` | Db-first scaffolding orchestration | | `Scaffolding/ITimescaleFeatureExtractor.cs` | Extractor interface | | `Scaffolding/IAnnotationApplier.cs` | Applier interface | @@ -190,8 +211,9 @@ src/ │ └── *.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 + ├── Generators/ # Design-time C# generation (migration calls + scaffolding code) + │ └── AnnotationRenderers/ # Per-feature annotation-to-C# renderers + ├── Scaffolding/ # Database extractors and annotation appliers ├── build/ # MSBuild targets └── *.cs # Design-time services diff --git a/.claude/reference/patterns.md b/.claude/reference/patterns.md index 24b1060..1e83b68 100644 --- a/.claude/reference/patterns.md +++ b/.claude/reference/patterns.md @@ -211,10 +211,48 @@ Keep each class focused on a single responsibility: | 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 | +| Scaffolding extraction | Reverse engineer from database | `Scaffolding/*ScaffoldingExtractor` + `*AnnotationApplier` classes | +| Scaffolding code generation | Annotations → C# fluent API or data annotation attributes | `Design/Generators/AnnotationRenderers/*AnnotationRenderer` classes | **Never mix concerns:** Extractors should not generate SQL, differs should not read databases. +## 13. Scaffolding Annotation Code Generation + +`dotnet ef dbcontext scaffold` runs two distinct phases: + +**Phase 1 — Database extraction** (`Scaffolding/`): `TimescaleDatabaseModelFactory` calls each `*ScaffoldingExtractor` to query TimescaleDB system tables, then calls the matching `*AnnotationApplier` to store the metadata as annotations on the EF Core `DatabaseModel`. The result is the same annotation format the runtime library uses. + +**Phase 2 — Code generation** (`Generators/AnnotationRenderers/`): EF Core's scaffolding pipeline asks `TimescaleDbAnnotationCodeGenerator` to convert those annotations into C# code. It dispatches to registered `IFeatureAnnotationRenderer` implementations. + +**`IFeatureAnnotationRenderer` contract:** + +```csharp +interface IFeatureAnnotationRenderer +{ + // Called when UseDataAnnotations = false — emit fluent API calls + void GenerateFluentApiCalls( + IEntityType entityType, + Dictionary annotations, + CSharpRuntimeAnnotationCodeGeneratorParameters parameters); + + // Called when UseDataAnnotations = true — return attribute fragments + IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, + Dictionary annotations); +} +``` + +**Key rules:** +- Call `AnnotationRendererHelper.Consume(annotations, keys...)` for every annotation key you handle. Unconsumed annotations cause EF Core to emit a raw `.HasAnnotation("key", value)` fallback in the scaffolded code. +- Use `AnnotationRendererHelper.ResolvePropertyName(entityType, columnName)` to map database column names back to C# property names. +- Use `NameOfCodeFragment` to emit `nameof(Entity.Property)` instead of hard-coded string literals so the scaffolded code is refactoring-safe. `TimescaleCSharpHelper.UnknownLiteral` handles rendering these. +- Register each new renderer in `TimescaleDbAnnotationCodeGenerator`. +- If a renderer emits attributes from a new namespace, add that namespace to `TimescaleCSharpModelGenerator.CollectAttributeNamespaces()` so the `using` directive is injected automatically. + +**`TimescaleCSharpModelGenerator`** sits at the top of the scaffolding code generation chain. It wraps EF Core's standard `CSharpModelGenerator` and, when `UseDataAnnotations = true`, inspects the generated entity files to add any missing TimescaleDB `using` directives. `TimescaleModelCodeGeneratorSelector` ensures this generator is selected in preference to EF Core's default `CSharpModelGenerator`. + +**Location:** `Design/Generators/AnnotationRenderers/`, `Design/Generators/TimescaleDbAnnotationCodeGenerator.cs`, `Design/Generators/TimescaleCSharpModelGenerator.cs`, `Design/Generators/TimescaleModelCodeGeneratorSelector.cs` + ```csharp // Correct - Separation of concerns public class HypertableDiffer : IFeatureDiffer From 7fe65844475f6c2af978cd93d10c6b7b63da374f Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Mon, 20 Jul 2026 23:56:16 +0200 Subject: [PATCH 05/12] feat: scaffold continuous aggregates into typed fluent API and data annotations --- .claude/CLAUDE.md | 2 +- .claude/agents/code-detective.md | 53 +- .claude/agents/eftdb-bug-fixer.md | 31 +- .claude/agents/eftdb-docs-writer.md | 39 +- .claude/agents/eftdb-feature-implementer.md | 39 +- .claude/agents/eftdb-feature-initializer.md | 21 +- .claude/agents/eftdb-scaffold-support.md | 24 +- .claude/agents/example-feature-generator.md | 24 +- .claude/agents/git-committer.md | 59 +- .claude/agents/pr-code-reviewer.md | 39 +- .claude/agents/test-coverage-planner.md | 30 +- .claude/agents/test-writer.md | 31 +- .claude/reference/architecture.md | 18 +- .claude/reference/file-organization.md | 8 +- .claude/skills/prepare-commit/SKILL.md | 9 +- docs/01-dotnet-tools.md | 38 +- .../data-annotations/continuous-aggregates.md | 25 +- docs/fluent-api/continuous-aggregates.md | 2 +- .../TimescaleDBDesignTimeService.cs | 4 +- samples/Eftdb.Samples.DatabaseFirst/README.md | 16 + .../ContinuousAggregateAnnotationRenderer.cs | 294 ++++ .../HypertableAnnotationRenderer.cs | 19 + .../IFeatureAnnotationRenderer.cs | 9 + .../TimescaleCSharpModelGenerator.cs | 74 +- .../TimescaleDbAnnotationCodeGenerator.cs | 185 +- ...ContinuousAggregateScaffoldingExtractor.cs | 19 +- .../HypertableAnnotationApplier.cs | 47 + .../HypertableScaffoldingExtractor.cs | 33 +- .../Scaffolding/IntervalParsingHelper.cs | 166 +- .../Scaffolding/ViewDefinitionParser.cs | 266 +++ .../ContinuousAggregateBuilder.cs | 47 +- .../ContinuousAggregateBuilderCore.cs | 65 + .../ContinuousAggregateConvention.cs | 12 + .../ContinuousAggregateStringBuilder.cs | 101 ++ .../ContinuousAggregateTypeBuilder.cs | 28 + .../GroupByColumnAttribute.cs | 22 + src/Eftdb/Eftdb.csproj | 1 + .../ContinuousAggregateDiffer.cs | 2 +- .../ContinuousAggregateModelExtractor.cs | 6 +- .../Internals/ParentEntityTypeResolver.cs | 21 + .../Abstractions/TimescaleCopyConfigTests.cs | 89 + .../ContinuousAggregateConventionTests.cs | 123 ++ ...ontinuousAggregatePolicyConventionTests.cs | 57 + .../AnnotationRendererHelperTests.cs | 44 +- ...tinuousAggregateAnnotationRendererTests.cs | 1510 +++++++++++++++++ .../HypertableAnnotationRendererTests.cs | 54 +- .../MigrationSnapshotGenerationTests.cs | 97 ++ .../ScaffoldedModelRenderingTests.cs | 211 +++ .../TimescaleCSharpModelGeneratorTests.cs | 95 ++ .../Generators/ViewDefinitionParserTests.cs | 591 +++++++ .../Differs/HypertableDifferTests.cs | 76 + .../Extensions/BulkCopyExtensionsTests.cs | 1 - ...eDbContextOptionsBuilderExtensionsTests.cs | 263 +++ .../HypertableModelExtractorTests.cs | 87 + .../ReorderPolicyModelExtractorTests.cs | 43 + ...tinuousAggregateOperationGeneratorTests.cs | 1 - ...eCSharpMigrationOperationGeneratorTests.cs | 29 + ...nuousAggregateScaffoldingExtractorTests.cs | 79 +- .../HypertableScaffoldingExtractorTests.cs | 51 + .../Integration/MigrationTestBase.cs | 1 - .../RetentionPolicyIntegrationTests.cs | 4 - .../Internals/FeatureDiffContextTests.cs | 1 - .../HypertableAnnotationApplierTests.cs | 117 ++ .../Scaffolding/IntervalParsingHelperTests.cs | 248 ++- .../ContinuousAggregateBuilderTests.cs | 335 ++++ .../HypertableTypeBuilderTests.cs | 51 + 66 files changed, 5989 insertions(+), 198 deletions(-) create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs create mode 100644 src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregate/GroupByColumnAttribute.cs create mode 100644 src/Eftdb/Internals/ParentEntityTypeResolver.cs create mode 100644 tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs create mode 100644 tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 145b68f..38ac5ff 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -110,7 +110,7 @@ New Feature → [1] eftdb-feature-initializer | `test-writer` | Creates unit and integration tests | | | `test-coverage-planner` | Analyzes test coverage gaps | `/coverage-plan` | | `example-feature-generator` | Creates usage examples | | -| `git-committer` | Formats, tests, stages, generates commit message | `/prepare-commit` | +| `git-committer` | Formats, tests, generates commit message (does not stage) | `/prepare-commit` | | `code-detective` | Investigates bugs, traces history | | | `pr-code-reviewer` | Reviews PR changes against patterns | `/review` | | `eftdb-docs-writer` | Writes and updates documentation | | diff --git a/.claude/agents/code-detective.md b/.claude/agents/code-detective.md index 0d328d9..9b3ddbb 100644 --- a/.claude/agents/code-detective.md +++ b/.claude/agents/code-detective.md @@ -1,6 +1,57 @@ --- name: code-detective -description: Use this agent when the user needs to understand the root cause of a bug, trace when it was introduced, identify the last working commit, or analyze code behavior without making any changes. This agent is for investigation and analysis only, not for fixing bugs.\n\nExamples:\n\n\nContext: User is investigating why HypertableDiffer is not detecting chunk time interval changes.\n\nuser: "The HypertableDiffer doesn't seem to detect when I change the chunk time interval from 7 days to 1 day. Can you investigate when this broke?"\n\nassistant: "I'll use the code-detective agent to analyze the HypertableDiffer implementation and trace through the git history to find when this regression was introduced."\n\n\nThe user is asking to investigate a bug's root cause and history, which is exactly what code-detective does. Use the Agent tool to launch code-detective for deep analysis of the differ logic and git history.\n\n\n\n\nContext: User wants to understand why continuous aggregate scaffolding is generating incorrect column names.\n\nuser: "I scaffolded a continuous aggregate from the database and the column names in the generated entity don't match what's in the database. What's going on?"\n\nassistant: "Let me use the code-detective agent to trace through the scaffolding pipeline and identify where the column name resolution is going wrong."\n\n\nThis is an investigation task to understand existing behavior and identify the issue source. Code-detective should analyze ContinuousAggregateScaffoldingExtractor, AnnotationApplier, and related naming convention handling without making changes.\n\n\n\n\nContext: User is trying to understand when a feature stopped working.\n\nuser: "Reorder policies worked in version 1.2.0 but now in 1.3.0 they're not being applied during migrations. Can you find out what changed?"\n\nassistant: "I'll launch the code-detective agent to compare the git history between versions 1.2.0 and 1.3.0, focusing on ReorderPolicyDiffer and related migration code."\n\n\nThe user needs historical analysis across versions to identify a regression. Code-detective should examine git commits, diffs, and potentially GitHub issues between the two versions.\n\n\n\n\nContext: User wants to understand complex code flow before making changes.\n\nuser: "Before I add support for compression policies, I want to understand how the existing reorder policy implementation works end-to-end."\n\nassistant: "I'll use the code-detective agent to trace the complete flow of reorder policies from attribute/fluent API configuration through conventions, differs, generators, and scaffolding."\n\n\nThis is a code comprehension task requiring deep analysis of implementation patterns. Code-detective should provide a detailed walkthrough without modifying anything.\n\n +description: |- + Use this agent when the user needs to understand the root cause of a bug, trace when it was introduced, identify the last working commit, or analyze code behavior without making any changes. This agent is for investigation and analysis only, not for fixing bugs. + + Examples: + + + Context: User is investigating why HypertableDiffer is not detecting chunk time interval changes. + + user: "The HypertableDiffer doesn't seem to detect when I change the chunk time interval from 7 days to 1 day. Can you investigate when this broke?" + + assistant: "I'll use the code-detective agent to analyze the HypertableDiffer implementation and trace through the git history to find when this regression was introduced." + + + The user is asking to investigate a bug's root cause and history, which is exactly what code-detective does. Use the Agent tool to launch code-detective for deep analysis of the differ logic and git history. + + + + + Context: User wants to understand why continuous aggregate scaffolding is generating incorrect column names. + + user: "I scaffolded a continuous aggregate from the database and the column names in the generated entity don't match what's in the database. What's going on?" + + assistant: "Let me use the code-detective agent to trace through the scaffolding pipeline and identify where the column name resolution is going wrong." + + + This is an investigation task to understand existing behavior and identify the issue source. Code-detective should analyze ContinuousAggregateScaffoldingExtractor, AnnotationApplier, and related naming convention handling without making changes. + + + + + Context: User is trying to understand when a feature stopped working. + + user: "Reorder policies worked in version 1.2.0 but now in 1.3.0 they're not being applied during migrations. Can you find out what changed?" + + assistant: "I'll launch the code-detective agent to compare the git history between versions 1.2.0 and 1.3.0, focusing on ReorderPolicyDiffer and related migration code." + + + The user needs historical analysis across versions to identify a regression. Code-detective should examine git commits, diffs, and potentially GitHub issues between the two versions. + + + + + Context: User wants to understand complex code flow before making changes. + + user: "Before I add support for compression policies, I want to understand how the existing reorder policy implementation works end-to-end." + + assistant: "I'll use the code-detective agent to trace the complete flow of reorder policies from attribute/fluent API configuration through conventions, differs, generators, and scaffolding." + + + This is a code comprehension task requiring deep analysis of implementation patterns. Code-detective should provide a detailed walkthrough without modifying anything. + + tools: Bash, Glob, Grep, Read, WebSearch, AskUserQuestion model: sonnet color: red diff --git a/.claude/agents/eftdb-bug-fixer.md b/.claude/agents/eftdb-bug-fixer.md index 66d4e16..788bf65 100644 --- a/.claude/agents/eftdb-bug-fixer.md +++ b/.claude/agents/eftdb-bug-fixer.md @@ -1,6 +1,35 @@ --- 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 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 +description: |- + Use this agent when bugs are discovered in existing runtime or design-time code within the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes: + + + Context: User discovers a bug in the HypertableDiffer. + user: "The HypertableDiffer is not detecting changes to chunk time interval" + assistant: "I'll use the eftdb-bug-fixer agent to analyze and fix the HypertableDiffer issue." + + + + + Context: SQL generation is incorrect for reorder policies. + user: "The ReorderPolicySqlGenerator is generating invalid SQL with wrong schema qualification" + assistant: "I'll launch the eftdb-bug-fixer agent to fix the SQL generation bug in ReorderPolicySqlGenerator." + + + + + Context: Scaffolding extractor query is failing. + user: "The ContinuousAggregateScaffoldingExtractor is throwing NullReferenceException when extracting aggregate functions" + assistant: "Let me use the eftdb-bug-fixer agent to debug and fix the scaffolding extractor." + + + + + Context: Another agent reports a bug during its work. + user: "The eftdb-scaffold-support agent reported a mismatch between runtime annotations and scaffolding expectations" + assistant: "I'll use the eftdb-bug-fixer agent to resolve the annotation mismatch issue reported by the scaffolding agent." + + model: sonnet color: red --- diff --git a/.claude/agents/eftdb-docs-writer.md b/.claude/agents/eftdb-docs-writer.md index e7bc060..a67ed6f 100644 --- a/.claude/agents/eftdb-docs-writer.md +++ b/.claude/agents/eftdb-docs-writer.md @@ -1,6 +1,43 @@ --- name: eftdb-docs-writer -description: Use this agent when the user requests documentation for CmdScale.EntityFrameworkCore.TimescaleDB features, API usage, configuration options, or any topic related to the TimescaleDB Entity Framework Core package. Examples include:\n\n\nContext: User wants to document how to configure hypertables using the TimescaleDB EF Core library.\n\nuser: "I need documentation on how to set up hypertables in Entity Framework Core using the TimescaleDB package"\n\nassistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to research the latest implementation and create comprehensive documentation covering both FluentAPI and DataAnnotations approaches."\n\n\nThe user is requesting documentation on a specific TimescaleDB feature, which requires researching the current implementation and writing structured documentation.\n\n\n\n\nContext: User has implemented a new feature and wants it documented.\n\nuser: "I just added support for continuous aggregates. Can you document this?"\n\nassistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to analyze the implementation in the repository and create documentation for the continuous aggregates feature."\n\n\nThe user needs documentation for a newly implemented feature, requiring repository analysis and documentation generation.\n\n\n\n\nContext: User mentions updating or creating docs for TimescaleDB EF Core features.\n\nuser: "The compression settings documentation is outdated"\n\nassistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to research the current compression implementation and update the documentation accordingly."\n\n\nExisting documentation needs updating, requiring fresh analysis of the current implementation.\n\n +description: |- + Use this agent when the user requests documentation for CmdScale.EntityFrameworkCore.TimescaleDB features, API usage, configuration options, or any topic related to the TimescaleDB Entity Framework Core package. Examples include: + + + Context: User wants to document how to configure hypertables using the TimescaleDB EF Core library. + + user: "I need documentation on how to set up hypertables in Entity Framework Core using the TimescaleDB package" + + assistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to research the latest implementation and create comprehensive documentation covering both FluentAPI and DataAnnotations approaches." + + + The user is requesting documentation on a specific TimescaleDB feature, which requires researching the current implementation and writing structured documentation. + + + + + Context: User has implemented a new feature and wants it documented. + + user: "I just added support for continuous aggregates. Can you document this?" + + assistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to analyze the implementation in the repository and create documentation for the continuous aggregates feature." + + + The user needs documentation for a newly implemented feature, requiring repository analysis and documentation generation. + + + + + Context: User mentions updating or creating docs for TimescaleDB EF Core features. + + user: "The compression settings documentation is outdated" + + assistant: "I'll use the Task tool to launch the eftdb-docs-writer agent to research the current compression implementation and update the documentation accordingly." + + + Existing documentation needs updating, requiring fresh analysis of the current implementation. + + model: sonnet color: cyan --- diff --git a/.claude/agents/eftdb-feature-implementer.md b/.claude/agents/eftdb-feature-implementer.md index cea8e23..d826afc 100644 --- a/.claude/agents/eftdb-feature-implementer.md +++ b/.claude/agents/eftdb-feature-implementer.md @@ -1,6 +1,43 @@ --- name: eftdb-feature-implementer -description: Use this agent when the user requests implementation of TimescaleDB feature support in the CmdScale.EntityFrameworkCore.TimescaleDB library, specifically for migration-related code. This includes:\n\n\nContext: User wants to add support for a new TimescaleDB feature that already has operation classes defined.\n\nuser: "I need to implement migration support for the compression policy feature. The operations already exist."\n\nassistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to implement the migration support for compression policies."\n\n\nThe user is requesting implementation of migration support for an existing feature with operations already defined. Use the eftdb-feature-implementer agent to implement the Differ, Extractor, Generator, and update the necessary migration files.\n\n\n\n\nContext: User is working on TimescaleDB features and mentions implementing a new feature's migration logic.\n\nuser: "Can you help me add the data retention policy migration support? I've already created the operation classes."\n\nassistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to implement the complete migration support stack for data retention policies."\n\n\nThe user has operations ready and needs the full migration implementation. Use the eftdb-feature-implementer agent to create the Differ, Extractor, Generator classes and update migration generators.\n\n\n\n\nContext: User mentions needing to add a feature but hasn't created operations yet.\n\nuser: "I want to add support for TimescaleDB jobs in migrations."\n\nassistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to check if operations exist and either implement the feature or provide a plan."\n\n\nThe user wants feature support but may not have operations defined. The agent will check and either implement or abort with a plan for creating operations first.\n\n +description: |- + Use this agent when the user requests implementation of TimescaleDB feature support in the CmdScale.EntityFrameworkCore.TimescaleDB library, specifically for migration-related code. This includes: + + + Context: User wants to add support for a new TimescaleDB feature that already has operation classes defined. + + user: "I need to implement migration support for the compression policy feature. The operations already exist." + + assistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to implement the migration support for compression policies." + + + The user is requesting implementation of migration support for an existing feature with operations already defined. Use the eftdb-feature-implementer agent to implement the Differ, Extractor, Generator, and update the necessary migration files. + + + + + Context: User is working on TimescaleDB features and mentions implementing a new feature's migration logic. + + user: "Can you help me add the data retention policy migration support? I've already created the operation classes." + + assistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to implement the complete migration support stack for data retention policies." + + + The user has operations ready and needs the full migration implementation. Use the eftdb-feature-implementer agent to create the Differ, Extractor, Generator classes and update migration generators. + + + + + Context: User mentions needing to add a feature but hasn't created operations yet. + + user: "I want to add support for TimescaleDB jobs in migrations." + + assistant: "I'm going to use the Task tool to launch the eftdb-feature-implementer agent to check if operations exist and either implement the feature or provide a plan." + + + The user wants feature support but may not have operations defined. The agent will check and either implement or abort with a plan for creating operations first. + + model: sonnet color: green --- diff --git a/.claude/agents/eftdb-feature-initializer.md b/.claude/agents/eftdb-feature-initializer.md index c508ab5..111b9db 100644 --- a/.claude/agents/eftdb-feature-initializer.md +++ b/.claude/agents/eftdb-feature-initializer.md @@ -1,6 +1,25 @@ --- name: eftdb-feature-initializer -description: Use this agent when the user requests implementation of a new TimescaleDB feature or capability that needs to be integrated into the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes features like compression policies, retention policies, data retention, jobs, background workers, or any other TimescaleDB-specific functionality that requires EF Core integration.\n\nExamples of when to use this agent:\n\n- User: "I want to add support for TimescaleDB compression policies"\n Assistant: "I'm going to use the Task tool to launch the eftdb-feature-initializer agent to create the initial setup for compression policy support."\n \n\n- User: "Can we implement retention policies for hypertables?"\n Assistant: "Let me use the eftdb-feature-initializer agent to set up the foundation for retention policy support."\n \n\n- User: "We need to add support for TimescaleDB's data retention features"\n Assistant: "I'll launch the eftdb-feature-initializer agent to establish the initial structure for data retention functionality."\n \n\n- User: "Let's add support for TimescaleDB jobs and scheduled policies"\n Assistant: "I'm using the eftdb-feature-initializer agent to create the foundational files for job and policy scheduling support."\n +description: |- + Use this agent when the user requests implementation of a new TimescaleDB feature or capability that needs to be integrated into the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes features like compression policies, retention policies, data retention, jobs, background workers, or any other TimescaleDB-specific functionality that requires EF Core integration. + + Examples of when to use this agent: + + - User: "I want to add support for TimescaleDB compression policies" + Assistant: "I'm going to use the Task tool to launch the eftdb-feature-initializer agent to create the initial setup for compression policy support." + + + - User: "Can we implement retention policies for hypertables?" + Assistant: "Let me use the eftdb-feature-initializer agent to set up the foundation for retention policy support." + + + - User: "We need to add support for TimescaleDB's data retention features" + Assistant: "I'll launch the eftdb-feature-initializer agent to establish the initial structure for data retention functionality." + + + - User: "Let's add support for TimescaleDB jobs and scheduled policies" + Assistant: "I'm using the eftdb-feature-initializer agent to create the foundational files for job and policy scheduling support." + model: sonnet color: pink --- diff --git a/.claude/agents/eftdb-scaffold-support.md b/.claude/agents/eftdb-scaffold-support.md index 4662595..5ffefea 100644 --- a/.claude/agents/eftdb-scaffold-support.md +++ b/.claude/agents/eftdb-scaffold-support.md @@ -1,6 +1,28 @@ --- name: eftdb-scaffold-support -description: Use this agent when implementing scaffolding support for TimescaleDB features from an existing database. This includes creating new scaffolding infrastructure, extractors, and appliers in the Design project. Examples:\n\n\nContext: User wants to add scaffolding support for a new TimescaleDB feature like compression policies.\nuser: "I need to add scaffolding support for compression policies so that dotnet ef dbcontext scaffold generates the appropriate configuration code"\nassistant: "I'm going to use the Task tool to launch the eftdb-scaffold-support agent to implement the scaffolding infrastructure for compression policies."\n\n\n\n\nContext: User notices that hypertable scaffolding isn't generating the chunk time interval configuration.\nuser: "The scaffolded code for hypertables is missing the chunk time interval configuration. Can you fix the extractor?"\nassistant: "I'll use the eftdb-scaffold-support agent to update the hypertable scaffolding extractor to include chunk time interval."\n\n\n\n\nContext: User wants to improve the scaffolding for continuous aggregates.\nuser: "I need to enhance the continuous aggregate scaffolding to include the refresh policy configuration"\nassistant: "Let me use the eftdb-scaffold-support agent to add refresh policy extraction and application to the continuous aggregate scaffolding."\n\n +description: |- + Use this agent when implementing scaffolding support for TimescaleDB features from an existing database. This includes creating new scaffolding infrastructure, extractors, and appliers in the Design project. Examples: + + + Context: User wants to add scaffolding support for a new TimescaleDB feature like compression policies. + user: "I need to add scaffolding support for compression policies so that dotnet ef dbcontext scaffold generates the appropriate configuration code" + assistant: "I'm going to use the Task tool to launch the eftdb-scaffold-support agent to implement the scaffolding infrastructure for compression policies." + + + + + Context: User notices that hypertable scaffolding isn't generating the chunk time interval configuration. + user: "The scaffolded code for hypertables is missing the chunk time interval configuration. Can you fix the extractor?" + assistant: "I'll use the eftdb-scaffold-support agent to update the hypertable scaffolding extractor to include chunk time interval." + + + + + Context: User wants to improve the scaffolding for continuous aggregates. + user: "I need to enhance the continuous aggregate scaffolding to include the refresh policy configuration" + assistant: "Let me use the eftdb-scaffold-support agent to add refresh policy extraction and application to the continuous aggregate scaffolding." + + model: sonnet color: yellow --- diff --git a/.claude/agents/example-feature-generator.md b/.claude/agents/example-feature-generator.md index f0ebe54..e0fd82b 100644 --- a/.claude/agents/example-feature-generator.md +++ b/.claude/agents/example-feature-generator.md @@ -1,6 +1,28 @@ --- name: example-feature-generator -description: Use this agent when the user requests to add new examples, showcase specific TimescaleDB features, create sample models, or extend the Example.DataAccess project with demonstrations of library capabilities. This agent should be used proactively when:\n\n\nContext: User has just implemented a new TimescaleDB feature and wants to showcase it.\nuser: "I've added support for retention policies in the core library. Can you create an example showing how to use it?"\nassistant: "I'll use the Task tool to launch the example-feature-generator agent to create a comprehensive example of the retention policy feature."\n\n\n\n\nContext: User is working on documentation and needs practical examples.\nuser: "We need to add an example of a continuous aggregate with multiple aggregate functions for the README"\nassistant: "Let me use the example-feature-generator agent to create this example in the Example.DataAccess project."\n\n\n\n\nContext: User wants to demonstrate a specific use case.\nuser: "Can you show how to configure a hypertable with compression and reorder policies together?"\nassistant: "I'm going to use the example-feature-generator agent to create a comprehensive example demonstrating this configuration."\n\n +description: |- + Use this agent when the user requests to add new examples, showcase specific TimescaleDB features, create sample models, or extend the Example.DataAccess project with demonstrations of library capabilities. This agent should be used proactively when: + + + Context: User has just implemented a new TimescaleDB feature and wants to showcase it. + user: "I've added support for retention policies in the core library. Can you create an example showing how to use it?" + assistant: "I'll use the Task tool to launch the example-feature-generator agent to create a comprehensive example of the retention policy feature." + + + + + Context: User is working on documentation and needs practical examples. + user: "We need to add an example of a continuous aggregate with multiple aggregate functions for the README" + assistant: "Let me use the example-feature-generator agent to create this example in the Example.DataAccess project." + + + + + Context: User wants to demonstrate a specific use case. + user: "Can you show how to configure a hypertable with compression and reorder policies together?" + assistant: "I'm going to use the example-feature-generator agent to create a comprehensive example demonstrating this configuration." + + model: sonnet color: orange --- diff --git a/.claude/agents/git-committer.md b/.claude/agents/git-committer.md index 700427e..502add3 100644 --- a/.claude/agents/git-committer.md +++ b/.claude/agents/git-committer.md @@ -1,6 +1,43 @@ --- name: git-committer -description: Use this agent when the user has made code changes and is ready to prepare them for commit. This includes scenarios like:\n\n\nContext: User has just finished implementing a new feature and wants to prepare for commit.\nuser: "I've finished adding the new hypertable compression feature. Can you prepare this for commit?"\nassistant: "I'll use the git-committer agent to format the code, run tests, update READMEs, and prepare everything for you to commit."\n\nThe user is requesting commit preparation, so launch the git-committer agent to handle the formatting, testing, README updates, and staging workflow.\n\n\n\n\nContext: User has fixed a bug and wants to save their work.\nuser: "The schema check bug is fixed now. Please prepare this for commit."\nassistant: "Let me use the git-committer agent to handle the pre-commit process with proper formatting, testing, and documentation updates."\n\nSince the user wants to prepare changes for commit, use the git-committer agent to run the complete pre-commit workflow.\n\n\n\n\nContext: User mentions they're done with their changes.\nuser: "I'm done with the continuous aggregate refactoring."\nassistant: "I'll use the git-committer agent to format your code, run tests, update READMEs, and prepare a commit message for your review."\n\nThe user has indicated completion of work. Proactively use the git-committer agent to help them prepare changes for commit.\n\n\n\n\nContext: User has made several related changes and wants to save progress.\nuser: "prepare for commit"\nassistant: "I'll launch the git-committer agent to handle the pre-commit workflow."\n\nSimple commit preparation request - use the git-committer agent to execute the full pre-commit process.\n\n +description: |- + Use this agent when the user has made code changes and is ready to prepare them for commit. This includes scenarios like: + + + Context: User has just finished implementing a new feature and wants to prepare for commit. + user: "I've finished adding the new hypertable compression feature. Can you prepare this for commit?" + assistant: "I'll use the git-committer agent to format the code, run tests, update READMEs, and prepare everything for you to commit." + + The user is requesting commit preparation, so launch the git-committer agent to handle the formatting, testing, README updates, and commit message generation. It does not stage changes — the user does that themselves. + + + + + Context: User has fixed a bug and wants to save their work. + user: "The schema check bug is fixed now. Please prepare this for commit." + assistant: "Let me use the git-committer agent to handle the pre-commit process with proper formatting, testing, and documentation updates." + + Since the user wants to prepare changes for commit, use the git-committer agent to run the complete pre-commit workflow. + + + + + Context: User mentions they're done with their changes. + user: "I'm done with the continuous aggregate refactoring." + assistant: "I'll use the git-committer agent to format your code, run tests, update READMEs, and prepare a commit message for your review." + + The user has indicated completion of work. Proactively use the git-committer agent to help them prepare changes for commit. + + + + + Context: User has made several related changes and wants to save progress. + user: "prepare for commit" + assistant: "I'll launch the git-committer agent to handle the pre-commit workflow." + + Simple commit preparation request - use the git-committer agent to execute the full pre-commit process. + + tools: Bash, Glob, Grep, Read, Write, Edit, AskUserQuestion model: sonnet color: purple @@ -40,10 +77,10 @@ If files were added/removed/renamed in `src/`: 4. Use Edit tool to update the content appropriately 5. Document which READMEs were updated and what changes were made -### Step 5: Stage All Changes -1. Run `git add .` to stage all changes (code, formatting, README updates) -2. Verify staging succeeded with `git status` -3. Confirm all intended files are staged +### Step 5: Review Changes (DO NOT STAGE) +1. **NEVER stage changes.** Do not run `git add` in any form. The user stages files themselves so the working tree stays easy to review. +2. Run `git status` and `git diff` to understand the full set of changes +3. Identify which files are relevant to the commit and which (if any) should be excluded, but leave staging entirely to the user ### Step 6: Commit Message Generation 1. Analyze the git diff to understand what changed @@ -77,10 +114,11 @@ If files were added/removed/renamed in `src/`: - Files formatted (if any) - Test results summary - READMEs updated and what changed - - All staged files (`git status` output) + - The full set of changed files (`git status` output) — note that nothing has been staged 2. Present the proposed commit message in a clearly formatted code block that the user can easily copy and paste -3. Clearly state: "**Everything is ready for commit!** All changes are staged. Please:" - - "Review the staged changes" +3. Clearly state: "**Everything is ready for commit!** Nothing has been staged — you control what goes in. Please:" + - "Review the changes" + - "Stage the files you want to include (`git add`)" - "Copy the commit message above and edit it if needed" - "Commit manually using your preferred method (IDE, terminal, etc.)" 4. **CRITICAL: NEVER execute `git commit` under any circumstances** - the user MUST copy the message and commit manually @@ -91,6 +129,7 @@ If files were added/removed/renamed in `src/`: - Editing any code files except through `dotnet format` - Committing if tests fail - **EXECUTING `git commit` IN ANY FORM** - the user MUST copy the message and commit manually +- **STAGING CHANGES IN ANY FORM** - never run `git add`, `git add .`, `git add -A`, or `git stage`; the user stages files themselves - Running any git commit commands (git commit, git commit -m, etc.) - Proceeding past any failed step - Skipping any of the mandatory workflow steps (especially README updates) @@ -149,9 +188,9 @@ A successful preparation requires: ✓ Code formatted with dotnet format (exit code 0) ✓ All tests passing (exit code 0) ✓ All relevant READMEs updated appropriately -✓ All changes staged with `git add .` +✓ No changes staged — working tree left untouched for the user to stage ✓ Valid conventional commit message generated with footer ✓ Clear handoff summary presented to user -✓ User informed they can now review and commit manually +✓ User informed they can now stage, review, and commit manually You are the guardian of commit quality. Never compromise on these standards. Your thoroughness and adherence to this workflow ensures the repository maintains its integrity and quality standards. The user will perform the final review and execute the actual commit. NEVER commit anything yourself. diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md index 941c01c..c9f8817 100644 --- a/.claude/agents/pr-code-reviewer.md +++ b/.claude/agents/pr-code-reviewer.md @@ -1,6 +1,43 @@ --- name: pr-code-reviewer -description: Use this agent when the user has completed a logical chunk of work on a feature branch and wants to review their changes before merging to main. This agent should be triggered proactively when:\n\n\nContext: User has just finished implementing a new TimescaleDB feature with all required components (operations, differ, generator, tests).\n\nuser: "I've finished implementing the compression policy feature. Can you review my changes?"\n\nassistant: "I'll use the pr-code-reviewer agent to analyze all changes on your current branch compared to main and provide feedback on adherence to coding standards and architectural patterns."\n\n\nThe user is explicitly requesting a review of their completed work. Use the pr-code-reviewer agent to compare the current branch against main and provide comprehensive feedback.\n\n\n\n\nContext: User has committed several changes and mentions they're ready for review.\n\nuser: "Just committed the last of the scaffolding support. Ready for review."\n\nassistant: "Let me use the pr-code-reviewer agent to review all your branch changes against main and check compliance with the project's coding standards."\n\n\nThe user indicates completion and readiness for review. Launch pr-code-reviewer to analyze the entire PR.\n\n\n\n\nContext: User asks if their implementation follows the guidelines after making changes.\n\nuser: "Does my implementation of the retention policy differ follow the established patterns?"\n\nassistant: "I'll use the pr-code-reviewer agent to analyze your changes and verify they align with the architectural patterns and coding standards defined in CLAUDE.md."\n\n\nThe user is seeking validation of their implementation. Use pr-code-reviewer to provide detailed feedback on pattern compliance.\n\n +description: |- + Use this agent when the user has completed a logical chunk of work on a feature branch and wants to review their changes before merging to main. This agent should be triggered proactively when: + + + Context: User has just finished implementing a new TimescaleDB feature with all required components (operations, differ, generator, tests). + + user: "I've finished implementing the compression policy feature. Can you review my changes?" + + assistant: "I'll use the pr-code-reviewer agent to analyze all changes on your current branch compared to main and provide feedback on adherence to coding standards and architectural patterns." + + + The user is explicitly requesting a review of their completed work. Use the pr-code-reviewer agent to compare the current branch against main and provide comprehensive feedback. + + + + + Context: User has committed several changes and mentions they're ready for review. + + user: "Just committed the last of the scaffolding support. Ready for review." + + assistant: "Let me use the pr-code-reviewer agent to review all your branch changes against main and check compliance with the project's coding standards." + + + The user indicates completion and readiness for review. Launch pr-code-reviewer to analyze the entire PR. + + + + + Context: User asks if their implementation follows the guidelines after making changes. + + user: "Does my implementation of the retention policy differ follow the established patterns?" + + assistant: "I'll use the pr-code-reviewer agent to analyze your changes and verify they align with the architectural patterns and coding standards defined in CLAUDE.md." + + + The user is seeking validation of their implementation. Use pr-code-reviewer to provide detailed feedback on pattern compliance. + + tools: Bash, Glob, Grep, Read, WebSearch, AskUserQuestion model: sonnet color: cyan diff --git a/.claude/agents/test-coverage-planner.md b/.claude/agents/test-coverage-planner.md index d91b257..472db54 100644 --- a/.claude/agents/test-coverage-planner.md +++ b/.claude/agents/test-coverage-planner.md @@ -1,6 +1,34 @@ --- name: test-coverage-planner -description: Use this agent when you need to analyze test coverage and create a comprehensive testing strategy for the CmdScale.EntityFrameworkCore.TimescaleDB and CmdScale.EntityFrameworkCore.TimescaleDB.Design packages. This agent should be used:\n\n1. **After implementing new features** - Example:\n - user: "I've just finished implementing the compression policy feature"\n - assistant: "Let me use the test-coverage-planner agent to analyze what tests are needed for this new feature"\n - \n\n2. **After bug fixes** - Example:\n - user: "Fixed the issue with continuous aggregate diffing"\n - assistant: "I'll use the test-coverage-planner agent to ensure we have regression tests and full coverage for this fix"\n - \n\n3. **Before releases** - Example:\n - user: "We're preparing for the 2.0 release"\n - assistant: "Let me launch the test-coverage-planner agent to verify our test coverage is comprehensive before release"\n - \n\n4. **When explicitly requested** - Example:\n - user: "Can you check our test coverage?"\n - assistant: "I'll use the test-coverage-planner agent to analyze coverage and create a testing plan"\n - \n\n5. **Proactively during development cycles** - Example:\n - user: "What should we work on next?"\n - assistant: "Let me use the test-coverage-planner agent to identify any coverage gaps that need attention"\n - \n\nThis agent focuses ONLY on planning and does NOT write or implement any tests. It produces a detailed testing strategy document that other agents (like test-writer) can use to implement the actual tests. +description: |- + Use this agent when you need to analyze test coverage and create a comprehensive testing strategy for the CmdScale.EntityFrameworkCore.TimescaleDB and CmdScale.EntityFrameworkCore.TimescaleDB.Design packages. This agent should be used: + + 1. **After implementing new features** - Example: + - user: "I've just finished implementing the compression policy feature" + - assistant: "Let me use the test-coverage-planner agent to analyze what tests are needed for this new feature" + - + + 2. **After bug fixes** - Example: + - user: "Fixed the issue with continuous aggregate diffing" + - assistant: "I'll use the test-coverage-planner agent to ensure we have regression tests and full coverage for this fix" + - + + 3. **Before releases** - Example: + - user: "We're preparing for the 2.0 release" + - assistant: "Let me launch the test-coverage-planner agent to verify our test coverage is comprehensive before release" + - + + 4. **When explicitly requested** - Example: + - user: "Can you check our test coverage?" + - assistant: "I'll use the test-coverage-planner agent to analyze coverage and create a testing plan" + - + + 5. **Proactively during development cycles** - Example: + - user: "What should we work on next?" + - assistant: "Let me use the test-coverage-planner agent to identify any coverage gaps that need attention" + - + + This agent focuses ONLY on planning and does NOT write or implement any tests. It produces a detailed testing strategy document that other agents (like test-writer) can use to implement the actual tests. tools: Bash, Glob, Grep, Read, WebSearch, AskUserQuestion model: sonnet color: green diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 5109e76..287c88b 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -1,6 +1,35 @@ --- name: test-writer -description: Use this agent when the user requests help writing, updating, or creating unit tests or integration tests for the CmdScale.EntityFrameworkCore.TimescaleDB.Tests project. This includes:\n\n\nContext: User has just implemented a new feature for continuous aggregate compression policies and wants tests for it.\nuser: "I've added a new compression policy feature for continuous aggregates. Can you write tests for the CompressionPolicyDiffer class?"\nassistant: "I'll use the test-writer agent to create comprehensive unit tests for the CompressionPolicyDiffer class."\n\n\n\n\nContext: User has completed implementing a new reorder policy feature and wants to verify it works end-to-end.\nuser: "I've finished the reorder policy implementation. Let's add some integration tests to make sure migrations work correctly."\nassistant: "I'll launch the test-writer agent to create integration tests using Testcontainers to verify the reorder policy migrations work end-to-end."\n\n\n\n\nContext: User has just written code for a new hypertable differ and wants comprehensive test coverage.\nuser: "Here's the new HypertableDiffer implementation. I need tests for all the edge cases."\nassistant: "I'll use the test-writer agent to write comprehensive unit tests covering all edge cases for the HypertableDiffer."\n\n\n\n\nContext: Proactive use - assistant detects that new code was written without tests.\nuser: "I've implemented the new ContinuousAggregateDiffer class"\nassistant: "Great work on the implementation! Now let me use the test-writer agent to create comprehensive tests for this new class to ensure it works correctly."\n\n +description: |- + Use this agent when the user requests help writing, updating, or creating unit tests or integration tests for the CmdScale.EntityFrameworkCore.TimescaleDB.Tests project. This includes: + + + Context: User has just implemented a new feature for continuous aggregate compression policies and wants tests for it. + user: "I've added a new compression policy feature for continuous aggregates. Can you write tests for the CompressionPolicyDiffer class?" + assistant: "I'll use the test-writer agent to create comprehensive unit tests for the CompressionPolicyDiffer class." + + + + + Context: User has completed implementing a new reorder policy feature and wants to verify it works end-to-end. + user: "I've finished the reorder policy implementation. Let's add some integration tests to make sure migrations work correctly." + assistant: "I'll launch the test-writer agent to create integration tests using Testcontainers to verify the reorder policy migrations work end-to-end." + + + + + Context: User has just written code for a new hypertable differ and wants comprehensive test coverage. + user: "Here's the new HypertableDiffer implementation. I need tests for all the edge cases." + assistant: "I'll use the test-writer agent to write comprehensive unit tests covering all edge cases for the HypertableDiffer." + + + + + Context: Proactive use - assistant detects that new code was written without tests. + user: "I've implemented the new ContinuousAggregateDiffer class" + assistant: "Great work on the implementation! Now let me use the test-writer agent to create comprehensive tests for this new class to ensure it works correctly." + + model: sonnet color: green --- diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 1ca9ad8..68fbc70 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -61,14 +61,17 @@ This document provides detailed architectural information for the CmdScale.Entit - `RetentionPolicyAnnotations.cs` - Annotation constants - `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()` -#### ContinuousAggregate/ (8 files) +#### ContinuousAggregate/ (11 files) - `ContinuousAggregateAttribute.cs` - Entity-level attribute defining materialized view - `TimeBucketAttribute.cs` - Property-level attribute for time bucketing - `AggregateAttribute.cs` - Property-level attribute with `EAggregateFunction` enum -- `ContinuousAggregateConvention.cs` - Processes all three attributes above -- `ContinuousAggregateAnnotations.cs` - 13 annotation constants -- `ContinuousAggregateBuilder.cs` - Type-safe generic builder -- `ContinuousAggregateTypeBuilder.cs` - Fluent API extensions +- `GroupByColumnAttribute.cs` - Property-level attribute marking a property as a GROUP BY column +- `ContinuousAggregateConvention.cs` - Processes all attributes above +- `ContinuousAggregateAnnotations.cs` - Annotation constants +- `ContinuousAggregateBuilder.cs` - Type-safe generic builder for code-first configuration +- `ContinuousAggregateStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code +- `ContinuousAggregateBuilderCore.cs` - Internal shared annotation-writing logic for both builder types +- `ContinuousAggregateTypeBuilder.cs` - Fluent API extensions (`IsContinuousAggregate`) #### ContinuousAggregatePolicy/ (5 files) - `ContinuousAggregatePolicyAttribute.cs` - Data annotation: `[ContinuousAggregatePolicy]` @@ -144,6 +147,7 @@ Generated migrations call strongly-typed extension methods that construct a `Mig - `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 +- `ParentEntityTypeResolver.cs` - Resolves a continuous aggregate's parent `IEntityType` by matching CLR class name, EF Core short name, or database table name; handles both code-first and scaffolded models **Feature-specific:** - `Features/Hypertables/` - `HypertableDiffer.cs`, `HypertableModelExtractor.cs` @@ -215,6 +219,7 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a | `TimescaleCSharpHelper.cs` | Extends `ICSharpHelper.UnknownLiteral` to render `NameOfCodeFragment` and mixed `object?[]` arrays | | `AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface: `GenerateFluentApiCalls` + `GenerateDataAnnotationAttributes` | | `AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable and dimension annotations to fluent API or data annotation attributes | +| `AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations; parses the stored view definition via `ViewDefinitionParser` to reconstruct structured configuration | | `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | | `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | @@ -235,6 +240,9 @@ EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to con `TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. +**Additional Design-Time Utilities:** +- `Scaffolding/ViewDefinitionParser.cs` - Parses a continuous aggregate's stored view definition SQL (best-effort, cached) to extract `TimeBucketWidth`, `TimeBucketSourceColumn`, aggregate functions, GROUP BY columns, and WHERE clause; used by `ContinuousAggregateAnnotationRenderer` + **Scaffolding/ Interfaces:** - `ITimescaleFeatureExtractor.cs` - `Extract(DbConnection connection)` returns feature metadata - `IAnnotationApplier.cs` - `ApplyAnnotations(DatabaseTable table, object featureInfo)` diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index e507edf..cf65c53 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -66,15 +66,19 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | File | Purpose | |------|---------| -| `Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs` | Type-safe builder | +| `Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs` | Type-safe generic builder | +| `Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs` | Shared annotation-writing logic for both builder types | +| `Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs` | String-based builder used in scaffolded code | | `Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs` | Fluent API extensions | | `Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs` | Annotation constants | | `Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs` | Entity-level attribute | | `Configuration/ContinuousAggregate/TimeBucketAttribute.cs` | Property-level attribute | | `Configuration/ContinuousAggregate/AggregateAttribute.cs` | Property-level attribute | +| `Configuration/ContinuousAggregate/GroupByColumnAttribute.cs` | Property-level attribute for GROUP BY columns | | `Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs` | Convention processing | | `Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs` | Diffing logic | | `Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs` | Model extraction | +| `Internals/ParentEntityTypeResolver.cs` | Resolves a continuous aggregate's parent entity type by CLR name, EF short name, or table name | | `Generators/ContinuousAggregateSqlGenerator.cs` | Runtime SQL generation | | `MigrationExtensions/ContinuousAggregateMigrationExtensions.cs` | Typed migrationBuilder methods | | `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value | @@ -149,6 +153,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/TimescaleCSharpHelper.cs` | Extends `ICSharpHelper.UnknownLiteral` for `NameOfCodeFragment` and mixed arrays | | `Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface | | `Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable annotations to fluent API or data annotation C# | +| `Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations by parsing the view definition | | `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | | `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | @@ -167,6 +172,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Scaffolding/RetentionPolicyAnnotationApplier.cs` | Apply retention policy annotations | | `Scaffolding/ContinuousAggregateScaffoldingExtractor.cs` | Query continuous aggregates | | `Scaffolding/ContinuousAggregateAnnotationApplier.cs` | Apply continuous aggregate annotations | +| `Scaffolding/ViewDefinitionParser.cs` | Parses continuous aggregate view SQL to extract structured configuration for code generation | | `build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets` | MSBuild integration | ## Test Files diff --git a/.claude/skills/prepare-commit/SKILL.md b/.claude/skills/prepare-commit/SKILL.md index 84e46ca..8ffb506 100644 --- a/.claude/skills/prepare-commit/SKILL.md +++ b/.claude/skills/prepare-commit/SKILL.md @@ -1,6 +1,6 @@ --- name: prepare-commit -description: Prepare changes for commit. Formats code, runs tests, updates READMEs, stages files, and generates a commit message for review. +description: Prepare changes for commit. Formats code, runs tests, updates READMEs, and generates a commit message for review. Does not stage files. user-invocable: true --- @@ -18,14 +18,13 @@ Pass the full context of what needs to be done. 3. Run `dotnet test` to verify all tests pass 4. If files were added/removed/renamed in `src/`, update `.claude/reference/file-organization.md` and `.claude/reference/architecture.md` 5. Update relevant README.md files if features or APIs changed -6. Stage relevant files with `git add` (specific files, not `-A`) -7. Generate a conventional commit message based on the staged changes +6. Generate a conventional commit message based on the working tree changes ## Rules - **NEVER** execute `git commit` — the user reviews and commits manually +- **NEVER** stage changes — do not run `git add` in any form; the user stages files themselves so the working tree stays easy to review - **NEVER** push to remote -- Use specific file paths with `git add`, never `git add -A` or `git add .` -- Skip files that likely contain secrets (`.env`, credentials) +- Skip inspecting files that likely contain secrets (`.env`, credentials) - Follow the repository's existing commit message style (check `git log`) - Use conventional commits if you can infer the type (feat, fix, docs, etc.) from the changes and you think it would be helpful for the user to see that in the message. Note that conventional commits will be added to the release notes by the generate-changelog.yml workflow, so they should be used when the commit represents a meaningful change that should be highlighted in the changelog. However, if the changes are minor or don't fit well into a conventional commit type, it's better to write a clear, descriptive message without forcing a conventional format. diff --git a/docs/01-dotnet-tools.md b/docs/01-dotnet-tools.md index 87c55dc..d830a59 100644 --- a/docs/01-dotnet-tools.md +++ b/docs/01-dotnet-tools.md @@ -109,7 +109,7 @@ This command will: ### Example -The scaffolding process generates data annotations that represent the hypertable's configuration in the database. This allows your `DbContext` to be aware of the TimescaleDB features without manual configuration. +The scaffolding process reconstructs the library's Fluent API configuration from the database. This allows your `DbContext` to be aware of the TimescaleDB features without manual configuration. ```csharp modelBuilder.HasPostgresExtension("timescaledb"); @@ -117,16 +117,28 @@ modelBuilder.Entity(entity => { entity.HasKey(e => new { e.Id, e.Time }); - entity - .HasAnnotation("TimescaleDB:ChunkSkipColumns", "Time") - .HasAnnotation("TimescaleDB:ChunkTimeInterval", "86400000") - .HasAnnotation("TimescaleDB:EnableCompression", true) - .HasAnnotation("TimescaleDB:HasReorderPolicy", true) - .HasAnnotation("TimescaleDB:IsHypertable", true) - .HasAnnotation("TimescaleDB:ReorderPolicy:IndexName", "DeviceReadings_Time_idx") - .HasAnnotation("TimescaleDB:ReorderPolicy:ScheduleInterval", "12:00:00") - .HasAnnotation("TimescaleDB:TimeColumnName", "Time"); - - entity.HasIndex(e => e.Time, "DeviceReadings_Time_idx").IsDescending(); + entity.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day") + .EnableCompression() + .WithChunkSkipping(x => x.Time); }); -``` \ No newline at end of file +``` + +With the `--data-annotations` flag, the configuration is expressed as attributes on the entity classes instead (`[Hypertable]`, `[Dimension]`, `[ContinuousAggregate]`, etc.). + +```bash +dotnet ef dbcontext scaffold "" CmdScale.EntityFrameworkCore.TimescaleDB.Design --data-annotations ... +``` + +### Scaffolding Limitations + +Some configuration cannot be recovered from the database catalog, and some values come back in a normalized form. A scaffolded model is functionally equivalent to the database, but does not always textually match the hand-written configuration that created it: + +- **`WithNoData` and `CreateGroupIndexes`** are creation-time options of a continuous aggregate and are not queryable from the catalog. They scaffold as their defaults. +- **Implicitly enabled compression** is reported as explicit: enabling chunk skipping requires compression, so a hypertable configured only with chunk skipping scaffolds with `EnableCompression` set. +- **Calendar intervals are stored normalized** by TimescaleDB: a chunk interval of `"1 month"` is stored as a fixed duration and scaffolds as `"30 days"`. +- **Interval values are only humanized when exact**: `"01:00:00"` becomes `"1 hour"`, but a value that does not reduce to a single unit (e.g. `"01:30:00"`) is kept in its raw form rather than being rounded. +- **WHERE clauses come back PostgreSQL-normalized**, including casts and parentheses (e.g. `"ticker" = 'MCRS'` scaffolds as `(ticker = 'MCRS'::text)`). +- **TimescaleDB's auto-created indexes are suppressed**: the descending time index (`__idx`) and the per-dimension composites (`
___idx`) are recreated automatically by TimescaleDB and do not scaffold as explicit indexes. A user-defined, non-unique index that exactly matches this name and column pattern is indistinguishable from the auto-created one and is suppressed as well. +- **Raw GROUP BY expressions have no data-annotations representation**: in `--data-annotations` mode, a GROUP BY entry that is not a plain column (e.g. `EXTRACT(HOUR FROM time)`) is reported as a warning and must be configured via the Fluent API's `AddGroupByColumn(...)`. +- **Unparseable view definitions degrade gracefully**: when a continuous aggregate's view SQL cannot be parsed into the structured configuration, a warning is reported and the configuration is preserved as `.HasAnnotation(...)` calls; migrations recreate the view from the raw SQL definition. \ No newline at end of file diff --git a/docs/data-annotations/continuous-aggregates.md b/docs/data-annotations/continuous-aggregates.md index 40fbbc4..8c5f211 100644 --- a/docs/data-annotations/continuous-aggregates.md +++ b/docs/data-annotations/continuous-aggregates.md @@ -244,7 +244,30 @@ public class TradeHourlyAggregate ## Grouping by Additional Columns -Data Annotations do not support GROUP BY configuration beyond the time bucket. For grouping by additional columns from the source hypertable, use the [Fluent API](../fluent-api/continuous-aggregates#grouping-data) approach. +Use the `[GroupByColumn]` attribute on a property of the aggregate entity to add it to the GROUP BY clause. Without an argument, the property's own name is used as the source column; pass a source column explicitly when the names differ: + +```csharp +[Keyless] +[ContinuousAggregate( + MaterializedViewName = "trade_hourly_stats", + ParentName = nameof(Trade))] +[TimeBucket("1 hour", nameof(Trade.Timestamp))] +public class TradeHourlyAggregate +{ + // Grouped by the parent's Exchange column (same name as this property). + [GroupByColumn] + public string Exchange { get; set; } = string.Empty; + + // Grouped by the parent's Ticker column, exposed under a different property name. + [GroupByColumn(nameof(Trade.Ticker))] + public string Symbol { get; set; } = string.Empty; + + [Aggregate(EAggregateFunction.Avg, nameof(Trade.Price))] + public decimal AveragePrice { get; set; } +} +``` + +> :warning: **Note:** Raw SQL GROUP BY expressions (e.g. `EXTRACT(HOUR FROM time)`) cannot be expressed as an attribute. Use the [Fluent API](../fluent-api/continuous-aggregates#grouping-data)'s `AddGroupByColumn(string)` overload for those. ## Refresh Policies diff --git a/docs/fluent-api/continuous-aggregates.md b/docs/fluent-api/continuous-aggregates.md index c2fb5d9..869b63c 100644 --- a/docs/fluent-api/continuous-aggregates.md +++ b/docs/fluent-api/continuous-aggregates.md @@ -88,7 +88,7 @@ public void Configure(EntityTypeBuilder builder) ## Grouping Data -Beyond the time bucket, continuous aggregates can group data by additional columns from the source hypertable. +Beyond the time bucket, continuous aggregates can group data by additional columns from the source hypertable. In Data Annotations configuration, the same is expressed with the property-level [`[GroupByColumn]` attribute](../data-annotations/continuous-aggregates#grouping-by-additional-columns); raw SQL expressions are Fluent-API-only. ### Group By Column diff --git a/samples/Eftdb.Samples.CodeFirst/TimescaleDBDesignTimeService.cs b/samples/Eftdb.Samples.CodeFirst/TimescaleDBDesignTimeService.cs index 116343d..b88b1a6 100644 --- a/samples/Eftdb.Samples.CodeFirst/TimescaleDBDesignTimeService.cs +++ b/samples/Eftdb.Samples.CodeFirst/TimescaleDBDesignTimeService.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Migrations.Design; using Microsoft.EntityFrameworkCore.Scaffolding; @@ -16,7 +17,8 @@ public void ConfigureDesignTimeServices(IServiceCollection services) // The reason for this is, because the CmdScale.EntityFrameworkCore.TimescaleDB.Design project only copies the required assembly-attribute when being packaged. services.AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } } } diff --git a/samples/Eftdb.Samples.DatabaseFirst/README.md b/samples/Eftdb.Samples.DatabaseFirst/README.md index 81f5f8c..709fd1c 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/README.md +++ b/samples/Eftdb.Samples.DatabaseFirst/README.md @@ -35,6 +35,22 @@ This command will: > **Note**: When scaffolding your `DbContext`, use the `--schema` flag to target only your data schema (usually `public`). This prevents the tool from unintentionally including TimescaleDB's internal management tables from schemas like `_timescaledb_internal`. +### Data Annotations Variant + +Add `--data-annotations` to express the TimescaleDB configuration as attributes on the entity classes (`[Hypertable]`, `[ContinuousAggregate]`, `[TimeBucket]`, `[Aggregate]`, `[GroupByColumn]`, ...) instead of Fluent API calls in `OnModelCreating`: + +```bash +dotnet ef dbcontext scaffold \ + "Host=localhost;Database=cmdscale-ef-timescaledb;Username=timescale_admin;Password=R#!kro#GP43ra8Ae" \ + CmdScale.EntityFrameworkCore.TimescaleDB.Design \ + --data-annotations \ + --output-dir Models \ + --schema public \ + --context-dir . \ + --context MyTimescaleDbContext \ + --project samples/Eftdb.Samples.DatabaseFirst +``` + --- ## Project Structure diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs new file mode 100644 index 0000000..cdbad1d --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs @@ -0,0 +1,294 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; + +#pragma warning disable EF1001 // Suppress warning about internal APIs usage, common for providers/extensions +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders ContinuousAggregate annotations as IsContinuousAggregate(...) Fluent API chains or + /// [ContinuousAggregate] attributes. The ViewDefinition annotation is parsed to reconstruct + /// the structured configuration; on success it is consumed so no raw SQL leaks into the generated file. + /// When the view definition cannot be parsed, a warning is reported and the annotations are left in + /// place so the .HasAnnotation(...) fallback preserves the configuration. + /// + internal sealed class ContinuousAggregateAnnotationRenderer(IOperationReporter reporter) : IFeatureAnnotationRenderer + { + private static readonly MethodInfo IsContinuousAggregateMethod = + typeof(ContinuousAggregateTypeBuilder) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate) + && m.GetParameters().Length == 5); + + private static MethodInfo BuilderMethod(string name) => + typeof(ContinuousAggregateStringBuilder<>) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == name); + + private static readonly MethodInfo AddAggregateFunctionMethod = BuilderMethod("AddAggregateFunction"); + private static readonly MethodInfo AddGroupByColumnMethod = BuilderMethod("AddGroupByColumn"); + private static readonly MethodInfo WhereMethod = BuilderMethod("Where"); + private static readonly MethodInfo MaterializedOnlyMethod = BuilderMethod("MaterializedOnly"); + private static readonly MethodInfo WithNoDataMethod = BuilderMethod("WithNoData"); + private static readonly MethodInfo CreateGroupIndexesMethod = BuilderMethod("CreateGroupIndexes"); + private static readonly MethodInfo WithChunkIntervalMethod = BuilderMethod("WithChunkInterval"); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + string? materializedViewName = GetString(annotations, ContinuousAggregateAnnotations.MaterializedViewName); + if (materializedViewName is null) + { + return []; + } + + string? viewDefinition = GetString(annotations, ContinuousAggregateAnnotations.ViewDefinition); + string? parentName = GetString(annotations, ContinuousAggregateAnnotations.ParentName); + 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; + + ViewDefinitionParser.ParsedViewDefinition? parsed = viewDefinition is not null + ? ViewDefinitionParser.Parse(viewDefinition) + : null; + + if (parsed?.TimeBucketWidth is null || parsed.TimeBucketSourceColumn is null) + { + ReportUnparseableViewDefinition(materializedViewName); + return []; + } + + string humanizedWidth = IntervalParsingHelper.NormalizeInterval(parsed.TimeBucketWidth); + + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(entityType.Model, parentName); + string parentClrName = parentEntityType?.ShortName() ?? parentName ?? materializedViewName; + + object parentNameArg = parentEntityType is not null + ? new NameOfCodeFragment(parentClrName) + : (object)(parentName ?? string.Empty); + + object timeBucketArg = ResolveParentColumnArg(parentEntityType, parentClrName, parsed.TimeBucketSourceColumn); + + MethodCallCodeFragment call = new(IsContinuousAggregateMethod, materializedViewName, parentNameArg, humanizedWidth, timeBucketArg); + + if (materializedOnly) + { + call = call.Chain(MaterializedOnlyMethod, true); + } + + if (withNoData) + { + call = call.Chain(WithNoDataMethod, true); + } + + if (!createGroupIndexes) + { + call = call.Chain(CreateGroupIndexesMethod, false); + } + + string caEntityClrName = entityType.ShortName(); + + foreach (ViewDefinitionParser.ParsedAggregate agg in parsed.Aggregates) + { + object aliasArg = TryResolvePropertyName(entityType, agg.Alias, out string aliasProperty) + ? new NameOfCodeFragment($"{caEntityClrName}.{aliasProperty}") + : (object)agg.Alias; + + object sourceArg = agg.SourceColumn == "*" + ? (object)"*" + : ResolveParentColumnArg(parentEntityType, parentClrName, agg.SourceColumn); + + call = call.Chain(AddAggregateFunctionMethod, [aliasArg, sourceArg, agg.Function]); + } + + foreach (string col in parsed.GroupByColumns) + { + call = call.Chain(AddGroupByColumnMethod, ResolveParentColumnArg(parentEntityType, parentClrName, col)); + } + + if (!string.IsNullOrWhiteSpace(parsed.WhereClause)) + { + call = call.Chain(WhereMethod, parsed.WhereClause); + } + + if (!string.IsNullOrWhiteSpace(chunkInterval) && !IsDerivedDefaultChunkInterval(chunkInterval, parentEntityType)) + { + call = call.Chain(WithChunkIntervalMethod, IntervalParsingHelper.NormalizeInterval(chunkInterval)); + } + + ConsumeAllCaAnnotations(annotations); + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + string? materializedViewName = GetString(annotations, ContinuousAggregateAnnotations.MaterializedViewName); + if (materializedViewName is null) + { + return []; + } + + string? viewDefinition = GetString(annotations, ContinuousAggregateAnnotations.ViewDefinition); + string? parentName = GetString(annotations, ContinuousAggregateAnnotations.ParentName); + 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; + + ViewDefinitionParser.ParsedViewDefinition? parsed = viewDefinition is not null + ? ViewDefinitionParser.Parse(viewDefinition) + : null; + + if (parsed?.TimeBucketWidth is null || parsed.TimeBucketSourceColumn is null) + { + return []; + } + + ConsumeAllCaAnnotations(annotations); + + string humanizedWidth = IntervalParsingHelper.NormalizeInterval(parsed.TimeBucketWidth); + + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(entityType.Model, parentName); + string parentClrName = parentEntityType?.ShortName() ?? parentName ?? materializedViewName; + + object parentNameArg = parentEntityType is not null + ? new NameOfCodeFragment(parentClrName) + : (object)(parentName ?? string.Empty); + + object timeBucketArg = ResolveParentColumnArg(parentEntityType, parentClrName, parsed.TimeBucketSourceColumn); + + ReportUnrepresentableGroupByEntries(entityType, materializedViewName, parsed.GroupByColumns); + + Dictionary caNamedArgs = new() + { + [nameof(ContinuousAggregateAttribute.MaterializedViewName)] = materializedViewName, + [nameof(ContinuousAggregateAttribute.ParentName)] = parentNameArg, + }; + + if (!string.IsNullOrWhiteSpace(chunkInterval) && !IsDerivedDefaultChunkInterval(chunkInterval, parentEntityType)) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.ChunkInterval)] = IntervalParsingHelper.NormalizeInterval(chunkInterval); + } + + if (materializedOnly) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.MaterializedOnly)] = true; + } + + if (withNoData) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.WithNoData)] = true; + } + + if (!createGroupIndexes) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.CreateGroupIndexes)] = false; + } + + if (!string.IsNullOrWhiteSpace(parsed.WhereClause)) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.Where)] = parsed.WhereClause; + } + + return [ + new AttributeCodeFragment(typeof(ContinuousAggregateAttribute), [], caNamedArgs), + new AttributeCodeFragment(typeof(TimeBucketAttribute), humanizedWidth, timeBucketArg), + ]; + } + + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + string? materializedViewName = GetString(annotations, ContinuousAggregateAnnotations.MaterializedViewName); + if (materializedViewName is null) + { + return; + } + + string? viewDefinition = GetString(annotations, ContinuousAggregateAnnotations.ViewDefinition); + ViewDefinitionParser.ParsedViewDefinition? parsed = viewDefinition is not null + ? ViewDefinitionParser.Parse(viewDefinition) + : null; + + if (parsed?.TimeBucketWidth is null || parsed.TimeBucketSourceColumn is null) + { + ReportUnparseableViewDefinition(materializedViewName); + return; + } + + ConsumeAllCaAnnotations(annotations); + } + + /// + /// A continuous aggregate's chunk interval defaults to 10x the parent hypertable's chunk + /// interval; a value equal to that derived default is elided, symmetric with the hypertable's + /// 7-days elision. Calendar-unit intervals fail the microsecond conversion and are kept. + /// + private static bool IsDerivedDefaultChunkInterval(string chunkInterval, IEntityType? parentEntityType) + { + string parentChunkInterval = parentEntityType?.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value as string + ?? DefaultValues.ChunkTimeInterval; + + return IntervalParsingHelper.TryGetTotalMicroseconds(chunkInterval, out long caMicroseconds) + && IntervalParsingHelper.TryGetTotalMicroseconds(parentChunkInterval, out long parentMicroseconds) + && caMicroseconds == 10 * parentMicroseconds; + } + + private void ReportUnparseableViewDefinition(string materializedViewName) + => reporter.WriteWarning( + $"The view definition of continuous aggregate '{materializedViewName}' could not be parsed. " + + "Its configuration is preserved as .HasAnnotation(...) calls; migrations will recreate the " + + "view from the raw SQL definition."); + + /// + /// GROUP BY entries that match no property on the aggregate entity (raw SQL expressions or + /// unmapped columns) have no data-annotation representation and would be silently lost. + /// + private void ReportUnrepresentableGroupByEntries( + IEntityType entityType, string materializedViewName, IReadOnlyList groupByColumns) + { + foreach (string col in groupByColumns) + { + if (!TryResolvePropertyName(entityType, col, out _)) + { + reporter.WriteWarning( + $"The GROUP BY expression '{col}' of continuous aggregate '{materializedViewName}' " + + "cannot be represented as a data annotation. Scaffold without --data-annotations or " + + "configure it manually via AddGroupByColumn(...)."); + } + } + } + + private static object ResolveParentColumnArg(IEntityType? parentEntityType, string parentClrName, string columnName) + => parentEntityType is not null && TryResolvePropertyName(parentEntityType, columnName, out string propName) + ? new NameOfCodeFragment($"{parentClrName}.{propName}") + : (object)columnName; + + private static void ConsumeAllCaAnnotations(IDictionary annotations) + { + Consume(annotations, + ContinuousAggregateAnnotations.MaterializedViewName, + ContinuousAggregateAnnotations.ParentName, + ContinuousAggregateAnnotations.MaterializedOnly, + ContinuousAggregateAnnotations.ChunkInterval, + ContinuousAggregateAnnotations.ViewDefinition, + ContinuousAggregateAnnotations.TimeBucketWidth, + ContinuousAggregateAnnotations.TimeBucketSourceColumn, + ContinuousAggregateAnnotations.TimeBucketGroupBy, + ContinuousAggregateAnnotations.AggregateFunctions, + ContinuousAggregateAnnotations.GroupByColumns, + ContinuousAggregateAnnotations.WhereClause, + ContinuousAggregateAnnotations.WithNoData, + ContinuousAggregateAnnotations.CreateGroupIndexes); + } + } +} +#pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs index 28db366..ece1adf 100644 --- a/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs @@ -122,6 +122,25 @@ public IReadOnlyList GenerateDataAnnotationAttributes( : [hypertable, .. GenerateDimensionAttributes(entityType, annotations)]; } + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + if (Find(annotations, HypertableAnnotations.IsHypertable)?.Value is not true) + { + return; + } + + Consume(annotations, + HypertableAnnotations.IsHypertable, + HypertableAnnotations.HypertableTimeColumn, + HypertableAnnotations.ChunkTimeInterval, + HypertableAnnotations.EnableCompression, + HypertableAnnotations.CompressionSegmentBy, + HypertableAnnotations.CompressionOrderBy, + HypertableAnnotations.ChunkSkipColumns, + HypertableAnnotations.MigrateData, + HypertableAnnotations.AdditionalDimensions); + } + private static AttributeCodeFragment? GenerateHypertableAttribute(IEntityType entityType, IDictionary annotations) { if (Find(annotations, HypertableAnnotations.IsHypertable)?.Value is not true) diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs index 452d837..807ca68 100644 --- a/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs @@ -29,5 +29,14 @@ IReadOnlyList GenerateFluentApiCalls( /// IReadOnlyList GenerateDataAnnotationAttributes( IEntityType entityType, IDictionary annotations); + + /// + /// Consumes the feature's annotations without generating output. Used in data-annotations + /// scaffold mode where the feature is expressed as attributes on the entity file and must not + /// fall back to .HasAnnotation(...) in OnModelCreating. Annotations that cannot + /// be rendered (e.g. an unparseable view definition) are left in place so the fallback + /// preserves them. + /// + void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations); } } diff --git a/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs index 9bbd71d..9f4f1e0 100644 --- a/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs @@ -31,29 +31,62 @@ public class TimescaleCSharpModelGenerator( public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) { - ScaffoldedModel scaffoldedModel = base.GenerateModel(model, options); + TimescaleDbAnnotationCodeGenerator? tsGen = _annotationCodeGenerator as TimescaleDbAnnotationCodeGenerator; - if (!options.UseDataAnnotations) + try { - return scaffoldedModel; - } + if (tsGen is not null) + { + tsGen.ScaffoldMode = true; + tsGen.ScaffoldDataAnnotationsMode = options.UseDataAnnotations; + } - Dictionary entityFiles = []; - foreach (ScaffoldedFile file in scaffoldedModel.AdditionalFiles) - { - entityFiles.TryAdd(Path.GetFileNameWithoutExtension(file.Path), file); - } + ScaffoldedModel scaffoldedModel = base.GenerateModel(model, options); - foreach (IEntityType entityType in model.GetEntityTypes()) - { - List namespaces = CollectAttributeNamespaces(entityType); - if (namespaces.Count > 0 && entityFiles.TryGetValue(entityType.Name, out ScaffoldedFile? file)) + scaffoldedModel.ContextFile.Code = RemoveDesignUsings(scaffoldedModel.ContextFile.Code); + foreach (ScaffoldedFile additionalFile in scaffoldedModel.AdditionalFiles) + { + additionalFile.Code = RemoveDesignUsings(additionalFile.Code); + } + + if (!options.UseDataAnnotations) + { + return scaffoldedModel; + } + + Dictionary entityFiles = []; + foreach (ScaffoldedFile file in scaffoldedModel.AdditionalFiles) + { + entityFiles.TryAdd(Path.GetFileNameWithoutExtension(file.Path), file); + } + + foreach (IEntityType entityType in model.GetEntityTypes()) { - file.Code = AddMissingUsings(file.Code, namespaces); + List namespaces = CollectAttributeNamespaces(entityType); + if (namespaces.Count > 0 && entityFiles.TryGetValue(entityType.Name, out ScaffoldedFile? file)) + { + file.Code = AddMissingUsings(file.Code, namespaces); + } } + + return scaffoldedModel; + } + finally + { + tsGen?.ResetScaffoldState(); } + } + + private static string RemoveDesignUsings(string code) + { + const string designUsingPrefix = "using CmdScale.EntityFrameworkCore.TimescaleDB.Design"; - return scaffoldedModel; + string newLine = code.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + IEnumerable kept = code.Split(newLine).Where(line => + !line.StartsWith(designUsingPrefix + ";", StringComparison.Ordinal) + && !line.StartsWith(designUsingPrefix + ".", StringComparison.Ordinal)); + + return string.Join(newLine, kept); } /// @@ -80,6 +113,17 @@ private List CollectAttributeNamespaces(IEntityType entityType) } } + foreach (IProperty property in entityType.GetProperties()) + { + Dictionary propAnnotations = property.GetAnnotations().ToDictionary(a => a.Name, a => a); + foreach (AttributeCodeFragment fragment in _annotationCodeGenerator.GenerateDataAnnotationAttributes(property, propAnnotations)) + { + AddNamespace(namespaces, fragment.Type); + foreach (object? argument in fragment.Arguments) AddArgumentNamespace(namespaces, argument); + foreach (object? argument in fragment.NamedArguments.Values) AddArgumentNamespace(namespaces, argument); + } + } + return namespaces; } diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index 267e565..64eeb61 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -1,5 +1,11 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Design.Internal; @@ -17,22 +23,54 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators /// mirroring the per-feature extractor/applier pairs in TimescaleDatabaseModelFactory. Annotations /// no renderer consumes fall back to .HasAnnotation(...), keeping the generated model complete. /// - public sealed class TimescaleDbAnnotationCodeGenerator(AnnotationCodeGeneratorDependencies dependencies) + public sealed class TimescaleDbAnnotationCodeGenerator( + AnnotationCodeGeneratorDependencies dependencies, + IOperationReporter reporter) : NpgsqlAnnotationCodeGenerator(dependencies) { - private static readonly IReadOnlyList Renderers = + private readonly IReadOnlyList _renderers = [ new HypertableAnnotationRenderer(), + new ContinuousAggregateAnnotationRenderer(reporter), ]; + private readonly Dictionary> _entityAttributeCache = []; + private readonly Dictionary> _propertyAttributeCache = []; + + internal bool ScaffoldMode { get; set; } + internal bool ScaffoldDataAnnotationsMode { get; set; } + + internal void ResetScaffoldState() + { + ScaffoldMode = false; + ScaffoldDataAnnotationsMode = false; + _entityAttributeCache.Clear(); + _propertyAttributeCache.Clear(); + } + public override IReadOnlyList GenerateFluentApiCalls( IEntityType entityType, IDictionary annotations) { List calls = [.. base.GenerateFluentApiCalls(entityType, annotations)]; - foreach (IFeatureAnnotationRenderer renderer in Renderers) + if (!ScaffoldMode) + { + return calls; + } + + if (ScaffoldDataAnnotationsMode) { - calls.AddRange(renderer.GenerateFluentApiCalls(entityType, annotations)); + foreach (IFeatureAnnotationRenderer renderer in _renderers) + { + renderer.ConsumeFeatureAnnotations(entityType, annotations); + } + } + else + { + foreach (IFeatureAnnotationRenderer renderer in _renderers) + { + calls.AddRange(renderer.GenerateFluentApiCalls(entityType, annotations)); + } } return calls; @@ -43,13 +81,148 @@ public override IReadOnlyList GenerateDataAnnotationAttri { List attributes = [.. base.GenerateDataAnnotationAttributes(entityType, annotations)]; - foreach (IFeatureAnnotationRenderer renderer in Renderers) + if (!ScaffoldMode) + { + return attributes; + } + + if (_entityAttributeCache.TryGetValue(entityType, out IReadOnlyList? cached)) + { + attributes.AddRange(cached); + return attributes; + } + + List featureAttributes = []; + foreach (IFeatureAnnotationRenderer renderer in _renderers) { - attributes.AddRange(renderer.GenerateDataAnnotationAttributes(entityType, annotations)); + featureAttributes.AddRange(renderer.GenerateDataAnnotationAttributes(entityType, annotations)); } + _entityAttributeCache[entityType] = featureAttributes; + attributes.AddRange(featureAttributes); return attributes; } + + public override IReadOnlyList GenerateDataAnnotationAttributes( + IProperty property, IDictionary annotations) + { + List attributes = [.. base.GenerateDataAnnotationAttributes(property, annotations)]; + + if (!ScaffoldMode) + { + return attributes; + } + + if (_propertyAttributeCache.TryGetValue(property, out IReadOnlyList? cached)) + { + attributes.AddRange(cached); + return attributes; + } + + IReadOnlyList featureAttributes = GenerateContinuousAggregatePropertyAttributes(property); + _propertyAttributeCache[property] = featureAttributes; + attributes.AddRange(featureAttributes); + return attributes; + } + + /// + /// Renders the property-level [Aggregate] and [GroupByColumn] attributes of a + /// continuous aggregate entity. + /// + private static IReadOnlyList GenerateContinuousAggregatePropertyAttributes(IProperty property) + { + if (property.DeclaringType is not IEntityType entityType) + { + return []; + } + + if (entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value is not string) + { + return []; + } + + string? parentName = entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value as string; + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(entityType.Model, parentName); + + // Code-first: AggregateFunctions annotation is populated by the convention/builder. + // Format: "{CLR property name}:{EAggregateFunction}:{source CLR property name or *}" + if (entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value is List aggregateFunctions) + { + string? entry = aggregateFunctions.FirstOrDefault(e => e.StartsWith(property.Name + ":")); + if (entry is null) return []; + + string[] parts = entry.Split(':', 3); + if (parts.Length < 3 || !Enum.TryParse(parts[1], out EAggregateFunction function)) + return []; + + return [new AttributeCodeFragment(typeof(AggregateAttribute), function, + ResolveSourceArgByClrName(parts[2], parentEntityType))]; + } + + // Db-First: AggregateFunctions is not set; the applier only stores the raw ViewDefinition SQL. + // Match this property's DB column name against the parsed aggregates and GROUP BY columns. + if (entityType.FindAnnotation(ContinuousAggregateAnnotations.ViewDefinition)?.Value is not string viewDefinition) return []; + + ViewDefinitionParser.ParsedViewDefinition parsed = ViewDefinitionParser.Parse(viewDefinition); + + string viewName = entityType.GetViewName() ?? entityType.GetTableName() ?? entityType.Name; + string? viewSchema = entityType.GetViewSchema() ?? entityType.GetSchema(); + StoreObjectIdentifier caStoreId = StoreObjectIdentifier.View(viewName, viewSchema); + string columnName = property.GetColumnName(caStoreId) ?? property.Name; + + ViewDefinitionParser.ParsedAggregate? agg = parsed.Aggregates.FirstOrDefault(a => a.Alias == columnName); + if (agg is not null) + { + return [new AttributeCodeFragment(typeof(AggregateAttribute), agg.Function, + ResolveSourceArgByColumnName(agg.SourceColumn, parentEntityType))]; + } + + if (parsed.GroupByColumns.Contains(columnName)) + { + return [GenerateGroupByColumnAttribute(property, columnName, parentEntityType)]; + } + + return []; + } + + /// + /// Emits [GroupByColumn] without arguments when the parent's CLR property name matches the + /// aggregate property's own name (the attribute's default); otherwise the source is referenced + /// explicitly, as nameof(...) when it resolves on the parent entity. + /// + private static AttributeCodeFragment GenerateGroupByColumnAttribute( + IProperty property, string columnName, IEntityType? parentEntityType) + { + object sourceArg = ResolveSourceArgByColumnName(columnName, parentEntityType); + + return sourceArg is NameOfCodeFragment nameOf && nameOf.PropertyName.EndsWith("." + property.Name, StringComparison.Ordinal) + ? new AttributeCodeFragment(typeof(GroupByColumnAttribute)) + : new AttributeCodeFragment(typeof(GroupByColumnAttribute), sourceArg); + } + + private static object ResolveSourceArgByClrName(string clrName, IEntityType? parentEntityType) + { + if (clrName == "*") return "*"; + if (parentEntityType is null) return clrName; + IProperty? parentProp = parentEntityType.FindProperty(clrName); + return parentProp is not null + ? new NameOfCodeFragment($"{parentEntityType.ShortName()}.{parentProp.Name}") + : (object)clrName; + } + + private static object ResolveSourceArgByColumnName(string columnName, IEntityType? parentEntityType) + { + if (columnName == "*") return "*"; + if (parentEntityType is null) return columnName; + string parentTableName = parentEntityType.GetTableName() ?? parentEntityType.Name; + string? parentSchema = parentEntityType.GetSchema(); + StoreObjectIdentifier parentStoreId = StoreObjectIdentifier.Table(parentTableName, parentSchema); + IProperty? parentProp = parentEntityType.GetProperties() + .FirstOrDefault(p => (p.GetColumnName(parentStoreId) ?? p.Name) == columnName); + return parentProp is not null + ? new NameOfCodeFragment($"{parentEntityType.ShortName()}.{parentProp.Name}") + : (object)columnName; + } } } #pragma warning restore EF1001 diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs index 1187470..2dffebe 100644 --- a/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs @@ -42,22 +42,17 @@ public sealed record ContinuousAggregateInfo( ca.hypertable_schema, ca.hypertable_name, ca.materialized_only, - CASE - WHEN d.interval_length IS NOT NULL THEN - (INTERVAL '1 microsecond' * d.interval_length)::text - ELSE NULL - END AS chunk_interval + dim.time_interval::text AS chunk_interval FROM timescaledb_information.continuous_aggregates ca LEFT JOIN _timescaledb_catalog.continuous_agg cagg ON ca.view_schema = cagg.user_view_schema AND ca.view_name = cagg.user_view_name - LEFT JOIN _timescaledb_catalog.dimension d - ON cagg.mat_hypertable_id = d.hypertable_id - AND d.id = ( - SELECT MIN(d2.id) - FROM _timescaledb_catalog.dimension d2 - WHERE d2.hypertable_id = cagg.mat_hypertable_id - );"; + LEFT JOIN _timescaledb_catalog.hypertable mat_ht + ON cagg.mat_hypertable_id = mat_ht.id + LEFT JOIN timescaledb_information.dimensions dim + ON dim.hypertable_schema = mat_ht.schema_name + AND dim.hypertable_name = mat_ht.table_name + AND dim.dimension_number = 1;"; using DbDataReader reader = command.ExecuteReader(); while (reader.Read()) diff --git a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs index 7e24550..347c605 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using System.Text.Json; @@ -17,6 +18,8 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo) throw new ArgumentException($"Expected {nameof(HypertableInfo)}, got {featureInfo.GetType().Name}", nameof(featureInfo)); } + RemoveAutoCreatedIndexes(table, info); + table[HypertableAnnotations.IsHypertable] = true; table[HypertableAnnotations.HypertableTimeColumn] = info.TimeColumnName; table[HypertableAnnotations.ChunkTimeInterval] = info.ChunkTimeInterval; @@ -44,5 +47,49 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo) table[HypertableAnnotations.AdditionalDimensions] = JsonSerializer.Serialize(info.AdditionalDimensions); } } + + /// + /// Removes the indexes TimescaleDB creates automatically for a hypertable — the descending time + /// index ({table}_{timeColumn}_idx) and one composite per additional dimension + /// ({table}_{dimensionColumn}_{timeColumn}_idx) — so they do not scaffold as explicit + /// index attributes the hand-written model never declared. The shape check (exact name, exact + /// columns, non-unique) keeps user-defined indexes intact. + /// + private static void RemoveAutoCreatedIndexes(DatabaseTable table, HypertableInfo info) + { + List autoCreated = []; + foreach (DatabaseIndex index in table.Indexes) + { + if (index.IsUnique) + { + continue; + } + + if (index.Name == $"{table.Name}_{info.TimeColumnName}_idx" + && index.Columns.Count == 1 + && index.Columns[0].Name == info.TimeColumnName) + { + autoCreated.Add(index); + continue; + } + + foreach (Dimension dimension in info.AdditionalDimensions) + { + if (index.Name == $"{table.Name}_{dimension.ColumnName}_{info.TimeColumnName}_idx" + && index.Columns.Count == 2 + && index.Columns[0].Name == dimension.ColumnName + && index.Columns[1].Name == info.TimeColumnName) + { + autoCreated.Add(index); + break; + } + } + } + + foreach (DatabaseIndex index in autoCreated) + { + table.Indexes.Remove(index); + } + } } } diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index de460ed..63f1ed9 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -77,7 +77,7 @@ private static void GetHypertableSettings( column_name, dimension_number, num_partitions, - EXTRACT(EPOCH FROM time_interval) * 1000000 AS time_interval_microseconds, + time_interval::text AS time_interval_text, integer_interval FROM timescaledb_information.dimensions ORDER BY hypertable_schema, hypertable_name, dimension_number;"; @@ -98,7 +98,7 @@ FROM timescaledb_information.dimensions string chunkTimeInterval; if (!reader.IsDBNull(5)) { - chunkTimeInterval = HumanizeMicroseconds((long)reader.GetDouble(5)); + chunkTimeInterval = IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); } else if (!reader.IsDBNull(6)) { @@ -135,7 +135,7 @@ FROM timescaledb_information.dimensions } else if (!reader.IsDBNull(5)) { - dimension = Dimension.CreateRange(columnName, HumanizeMicroseconds((long)reader.GetDouble(5))); + dimension = Dimension.CreateRange(columnName, IntervalParsingHelper.NormalizeInterval(reader.GetString(5))); } else if (!reader.IsDBNull(6)) { @@ -229,32 +229,5 @@ FROM timescaledb_information.compression_settings } } - /// - /// Converts a microsecond chunk interval to the equivalent PostgreSQL interval string when it - /// divides cleanly into days, hours, minutes, or seconds (e.g. 86400000000 becomes - /// "1 day"), matching the hand-written configuration style. Uneven values keep the raw - /// microsecond form, which round-trips identically. - /// - private static string HumanizeMicroseconds(long microseconds) - { - (long Factor, string Unit)[] units = - [ - (86_400_000_000L, "day"), - (3_600_000_000L, "hour"), - (60_000_000L, "minute"), - (1_000_000L, "second"), - ]; - - foreach ((long factor, string unit) in units) - { - if (microseconds >= factor && microseconds % factor == 0) - { - long count = microseconds / factor; - return count == 1 ? $"1 {unit}" : $"{count} {unit}s"; - } - } - - return microseconds.ToString(); - } } } diff --git a/src/Eftdb.Design/Scaffolding/IntervalParsingHelper.cs b/src/Eftdb.Design/Scaffolding/IntervalParsingHelper.cs index 192f84c..a0eadd4 100644 --- a/src/Eftdb.Design/Scaffolding/IntervalParsingHelper.cs +++ b/src/Eftdb.Design/Scaffolding/IntervalParsingHelper.cs @@ -1,12 +1,15 @@ using System.Text.Json; +using System.Text.RegularExpressions; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding { /// /// Provides helper methods for parsing and normalizing TimescaleDB interval values. /// - public static class IntervalParsingHelper + public static partial class IntervalParsingHelper { + private const long MicrosecondsPerSecond = 1_000_000L; + /// /// Parses an interval or integer value from a JSON element. /// @@ -46,16 +49,6 @@ public static class IntervalParsingHelper /// /// The PostgreSQL interval string to normalize. /// A normalized interval string. - /// - /// PostgreSQL stores intervals in formats like: - /// - "1 mon" for 1 month - /// - "7 days" for 7 days - /// - "01:00:00" for 1 hour - /// This method normalizes these to match the format users would use in Fluent API: - /// - "1 month" - /// - "7 days" - /// - "1 hour" - /// public static string NormalizeInterval(string pgInterval) { if (string.IsNullOrWhiteSpace(pgInterval)) @@ -64,26 +57,159 @@ public static string NormalizeInterval(string pgInterval) } string normalized = pgInterval.Trim(); + normalized = MonthsRegex().Replace(normalized, "months"); + normalized = MonthRegex().Replace(normalized, "month"); + + // PostgreSQL renders sub-day intervals as HH:MM:SS and interval arithmetic + // (`INTERVAL '1 microsecond' * N`) as HHH:MM:SS where HHH can exceed 23. + // The optional D. prefix covers TimeSpan-style day components ("1.00:00:00"). + if (TryParseTimeParts(normalized, out long days, out long hours, out long minutes, out long seconds, out long fractionMicroseconds)) + { + if (fractionMicroseconds > 0) + { + return normalized; + } + + long totalSeconds = ((((days * 24) + hours) * 60 + minutes) * 60) + seconds; + if (totalSeconds == 0) + { + return normalized; + } + + return totalSeconds switch + { + _ when totalSeconds % 86_400 == 0 => Pluralize(totalSeconds / 86_400, "day"), + _ when totalSeconds % 3_600 == 0 => Pluralize(totalSeconds / 3_600, "hour"), + _ when totalSeconds % 60 == 0 => Pluralize(totalSeconds / 60, "minute"), + _ => Pluralize(totalSeconds, "second"), + }; + } - normalized = normalized.Replace(" mon", " month"); + return normalized; + } - if (TimeSpan.TryParse(normalized, out TimeSpan timeSpan)) + /// + /// Converts a fixed-duration interval to its total length in microseconds. + /// + /// The interval string ("30 minutes", "1 day", "168:00:00"). + /// The total number of microseconds when parsing succeeds. + /// + /// false for calendar units (month, year), composite forms ("2 days 03:00:00"), bare + /// integers, and anything else without a fixed microsecond duration. + /// + public static bool TryGetTotalMicroseconds(string interval, out long microseconds) + { + microseconds = 0; + if (string.IsNullOrWhiteSpace(interval)) + { + return false; + } + + string value = interval.Trim(); + + if (value.Contains(':')) { - if (timeSpan.TotalMinutes < 60 && timeSpan.Minutes > 0 && timeSpan.Hours == 0) + if (!TryParseTimeParts(value, out long days, out long hours, out long minutes, out long seconds, out long fractionMicroseconds)) { - return $"{timeSpan.Minutes} minute{(timeSpan.Minutes > 1 ? "s" : "")}"; + return false; } - if (timeSpan.TotalHours < 24 && timeSpan.Hours > 0) + + microseconds = (((((days * 24) + hours) * 60) + minutes) * 60 + seconds) * MicrosecondsPerSecond + fractionMicroseconds; + return true; + } + + Match match = NumberUnitRegex().Match(value); + if (!match.Success || !long.TryParse(match.Groups[1].Value, out long amount)) + { + return false; + } + + long? unitMicroseconds = match.Groups[2].Value.ToLowerInvariant() switch + { + "us" or "usec" or "usecs" or "microsecond" or "microseconds" => 1L, + "ms" or "msec" or "msecs" or "millisecond" or "milliseconds" => 1_000L, + "s" or "sec" or "secs" or "second" or "seconds" => MicrosecondsPerSecond, + "min" or "mins" or "minute" or "minutes" => 60L * MicrosecondsPerSecond, + "h" or "hr" or "hrs" or "hour" or "hours" => 3_600L * MicrosecondsPerSecond, + "d" or "day" or "days" => 86_400L * MicrosecondsPerSecond, + "w" or "week" or "weeks" => 604_800L * MicrosecondsPerSecond, + _ => null, // Calendar units (month, year) have no fixed duration. + }; + + if (unitMicroseconds is null) + { + return false; + } + + microseconds = amount * unitMicroseconds.Value; + return true; + } + + /// + /// Parses a [D.]H+:MM:SS[.ffffff] value into its components. The hour component may + /// exceed 23 when no day prefix is present. Returns false for anything else, including + /// composite forms with leading unit words. + /// + private static bool TryParseTimeParts(string value, out long days, out long hours, out long minutes, out long seconds, out long fractionMicroseconds) + { + days = hours = minutes = seconds = fractionMicroseconds = 0; + + string[] parts = value.Split(':'); + if (parts.Length != 3) + { + return false; + } + + string hourPart = parts[0]; + int dayDot = hourPart.IndexOf('.'); + if (dayDot >= 0) + { + if (!long.TryParse(hourPart[..dayDot], out days) || days < 0) { - return $"{timeSpan.Hours} hour{(timeSpan.Hours > 1 ? "s" : "")}"; + return false; } - if (timeSpan.Days > 0) + + hourPart = hourPart[(dayDot + 1)..]; + } + + if (!long.TryParse(hourPart, out hours) || hours < 0 || (dayDot >= 0 && hours > 23)) + { + return false; + } + + if (!long.TryParse(parts[1], out minutes) || minutes is < 0 or >= 60) + { + return false; + } + + string secondsPart = parts[2]; + int secondsDot = secondsPart.IndexOf('.'); + if (secondsDot >= 0) + { + string fraction = secondsPart[(secondsDot + 1)..]; + if (fraction.Length == 0 || !fraction.All(char.IsAsciiDigit)) { - return $"{timeSpan.Days} day{(timeSpan.Days > 1 ? "s" : "")}"; + return false; } + + string padded = fraction.Length >= 6 ? fraction[..6] : fraction.PadRight(6, '0'); + fractionMicroseconds = long.Parse(padded); + secondsPart = secondsPart[..secondsDot]; } - return normalized; + return long.TryParse(secondsPart, out seconds) && seconds is >= 0 and < 60; } + + private static string Pluralize(long amount, string unit) + => amount == 1 ? $"1 {unit}" : $"{amount} {unit}s"; + + [GeneratedRegex(@"\bmons\b")] + private static partial Regex MonthsRegex(); + + [GeneratedRegex(@"\bmon\b")] + private static partial Regex MonthRegex(); + + [GeneratedRegex(@"^(\d+)\s+([a-zA-Z]+)$")] + private static partial Regex NumberUnitRegex(); } } diff --git a/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs b/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs new file mode 100644 index 0000000..64b3e94 --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs @@ -0,0 +1,266 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Parses a TimescaleDB continuous aggregate view definition to extract structured configuration. + /// All parsing is best-effort: unrecognised patterns return null or an empty list rather than throwing. + /// + internal static partial class ViewDefinitionParser + { + internal sealed record ParsedAggregate( + string Alias, + EAggregateFunction Function, + string SourceColumn); + + /// + /// Complete parse result of a continuous aggregate view definition. + /// + internal sealed record ParsedViewDefinition( + string? TimeBucketWidth, + string? TimeBucketSourceColumn, + IReadOnlyList Aggregates, + IReadOnlyList GroupByColumns, + string? WhereClause); + + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Parses a view definition once and memoizes the result. Rendering runs per entity and per + /// property against the same SQL; the cache avoids re-parsing the full definition each time. + /// + public static ParsedViewDefinition Parse(string viewDefinition) + => Cache.GetOrAdd(viewDefinition, static vd => new ParsedViewDefinition( + ParseTimeBucketWidth(vd), + ParseTimeBucketSourceColumn(vd), + ParseAggregates(vd), + ParseGroupByColumns(vd), + ParseWhereClause(vd))); + + /// + /// Extracts the time bucket interval from a time_bucket('interval'::interval, col) call. + /// Returns the raw interval string (e.g. "01:00:00"); callers should normalise via + /// . + /// + public static string? ParseTimeBucketWidth(string viewDefinition) + { + Match m = TimeBucketWidthRegex().Match(viewDefinition); + return m.Success ? m.Groups[1].Value : null; + } + + /// + /// Extracts the source column name from the second argument of the time_bucket() call. + /// Table aliases and double-quote delimiters are stripped. + /// + public static string? ParseTimeBucketSourceColumn(string viewDefinition) + { + Match m = TimeBucketSourceColumnRegex().Match(viewDefinition); + return m.Success ? StripQuotes(m.Groups[1].Value) : null; + } + + /// + /// Extracts aggregate function definitions (avg, sum, min, max, + /// count, first, last) from the SELECT clause. + /// Table-alias qualifiers and double-quote delimiters are stripped from column references and aliases. + /// TimescaleDB's legacy internal finalized form of first/last is handled as a fallback for aliases + /// the exact parse did not cover. + /// + public static IReadOnlyList ParseAggregates(string viewDefinition) + { + // Only scan the SELECT clause — stop at FROM to avoid false positives in subqueries. + string selectClause = ExtractSelectClause(viewDefinition); + + List result = []; + foreach (Match m in AggregateRegex().Matches(selectClause)) + { + string functionName = m.Groups[1].Value.ToUpperInvariant(); + string rawArg = m.Groups[2].Value.Trim(); + string alias = StripQuotes(m.Groups[3].Value); + + EAggregateFunction? function = functionName switch + { + "AVG" => EAggregateFunction.Avg, + "SUM" => EAggregateFunction.Sum, + "MIN" => EAggregateFunction.Min, + "MAX" => EAggregateFunction.Max, + "COUNT" => EAggregateFunction.Count, + "FIRST" => EAggregateFunction.First, + "LAST" => EAggregateFunction.Last, + _ => null + }; + + if (function is null) + { + continue; + } + + string sourceColumn; + if (function is EAggregateFunction.First or EAggregateFunction.Last) + { + // first/last take (value column, time column); the source is the FIRST argument. + string firstArg = SplitTopLevel(rawArg).First().Trim(); + if (string.IsNullOrWhiteSpace(firstArg)) + { + continue; + } + + sourceColumn = StripQualifierAndQuotes(firstArg); + } + else + { + // COUNT(*) wildcard is kept as-is; other args strip table qualifier and quotes. + sourceColumn = rawArg == "*" ? "*" : StripQualifierAndQuotes(rawArg); + } + + result.Add(new ParsedAggregate(alias, function.Value, sourceColumn)); + } + + // Legacy fallback: before TimescaleDB 2.7 ("finalized" continuous aggregates), first()/last() + // appear as internal finalized aggregates in the view SQL: + // _timescaledb_internal.finalize_agg('first(double precision,...)'::text, ...) AS alias + // The source column is not recoverable from that form; it is inferred from the alias prefix. + HashSet parsedAliases = [.. result.Select(a => a.Alias)]; + foreach (Match m in FinalizeAggRegex().Matches(selectClause)) + { + string functionName = m.Groups[1].Value.ToUpperInvariant(); + string alias = StripQuotes(m.Groups[2].Value); + if (parsedAliases.Contains(alias)) + { + continue; + } + + EAggregateFunction function = functionName == "FIRST" ? EAggregateFunction.First : EAggregateFunction.Last; + + string prefix = functionName.ToLowerInvariant() + "_"; + string sourceColumn = alias.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? alias[prefix.Length..] + : alias; + + result.Add(new ParsedAggregate(alias, function, sourceColumn)); + } + + return result; + } + + /// + /// Extracts GROUP BY column references, excluding the time_bucket(...) expression. + /// Table-alias qualifiers and double-quote delimiters are stripped. + /// + public static IReadOnlyList ParseGroupByColumns(string viewDefinition) + { + Match groupByMatch = GroupByRegex().Match(viewDefinition); + if (!groupByMatch.Success) + { + return []; + } + + string groupByContent = groupByMatch.Groups[1].Value.TrimEnd(';', ' ', '\t', '\r', '\n'); + + List columns = []; + foreach (string token in SplitTopLevel(groupByContent)) + { + string trimmed = token.Trim(); + + // Skip the time_bucket(...) expression — it is represented by the timeBucketWidth/Column args. + if (trimmed.StartsWith("time_bucket", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("(time_bucket", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Skip numeric positional references (e.g. GROUP BY 1, 2). + if (int.TryParse(trimmed, out _)) + { + continue; + } + + string stripped = SimpleColumnReferenceRegex().IsMatch(trimmed) + ? StripQualifierAndQuotes(trimmed) + : trimmed; + if (!string.IsNullOrWhiteSpace(stripped)) + { + columns.Add(stripped); + } + } + + return columns; + } + + /// + /// Extracts the WHERE clause content. + /// Returns null when no WHERE clause is present. + /// + public static string? ParseWhereClause(string viewDefinition) + { + Match m = WhereClauseRegex().Match(viewDefinition); + return m.Success ? m.Groups[1].Value.Trim() : null; + } + + private static string ExtractSelectClause(string viewDefinition) + { + int fromIndex = FromRegex().Match(viewDefinition).Index; + return fromIndex > 0 ? viewDefinition[..fromIndex] : viewDefinition; + } + + private static string StripQualifierAndQuotes(string token) + { + int dot = token.LastIndexOf('.'); + string name = dot >= 0 ? token[(dot + 1)..] : token; + return StripQuotes(name); + } + + private static string StripQuotes(string name) + => name.Length >= 2 && name[0] == '"' && name[^1] == '"' + ? name[1..^1] + : name; + + /// Splits a comma-separated list while respecting parenthesised subexpressions. + private static List SplitTopLevel(string input) + { + List parts = []; + int depth = 0; + int start = 0; + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + if (c == '(') { depth++; } + else if (c == ')') { depth--; } + else if (c == ',' && depth == 0) + { + parts.Add(input[start..i]); + start = i + 1; + } + } + parts.Add(input[start..]); + return parts; + } + + [GeneratedRegex(@"time_bucket\s*\(\s*'([^']+)'", RegexOptions.IgnoreCase)] + private static partial Regex TimeBucketWidthRegex(); + + [GeneratedRegex(@"time_bucket\s*\([^,]+,\s*(?:(?:""[^""]+""|\w+)\.)*(""[^""]+""|\w+)\s*(?:::\w+(?:\s+\w+)*)?\s*[,)]", RegexOptions.IgnoreCase)] + private static partial Regex TimeBucketSourceColumnRegex(); + + [GeneratedRegex(@"\b(avg|sum|min|max|count|first|last)\s*\((\*|[^)]*?)\)\s+AS\s+(""[^""]+""|\w+)", RegexOptions.IgnoreCase)] + private static partial Regex AggregateRegex(); + + [GeneratedRegex(@"\bGROUP\s+BY\s+(.*?)(?:\s+HAVING\s+|\s*$)", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex GroupByRegex(); + + [GeneratedRegex(@"\bWHERE\s+(.*?)(?:\s+GROUP\s+BY\s+|\s+HAVING\s+|\s*$)", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex WhereClauseRegex(); + + [GeneratedRegex(@"\bFROM\b", RegexOptions.IgnoreCase)] + private static partial Regex FromRegex(); + + [GeneratedRegex(@"^(?:(?:""[^""]+""|\w+)\.)*(""[^""]+""|\w+)$")] + private static partial Regex SimpleColumnReferenceRegex(); + + [GeneratedRegex( + @"_timescaledb_internal\.finalize_agg\s*\(\s*'(first|last)\s*\([^']*\)'(?:[^)]*)\)\s+AS\s+(""[^""]+""|\w+)", + RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex FinalizeAggRegex(); + } +} diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs index dea7397..d8b5896 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs @@ -1,5 +1,4 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Linq.Expressions; @@ -8,6 +7,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggre /// /// Provides a fluent API for configuring a TimescaleDB continuous aggregate. /// This builder is aware of both the aggregate entity type and the source hypertable entity type. + /// Annotation writing is delegated to . /// /// The class representing the continuous aggregate view. /// The class representing the source hypertable. @@ -29,7 +29,7 @@ internal ContinuousAggregateBuilder(EntityTypeBuilder entityTypeBuilder /// The builder for method chaining. public ContinuousAggregateBuilder WithNoData(bool withNoData = true) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.WithNoData, withNoData); + ContinuousAggregateBuilderCore.WithNoData(EntityTypeBuilder, withNoData); return this; } @@ -40,7 +40,7 @@ public ContinuousAggregateBuilder WithNoData(bool withNo /// The builder for method chaining. public ContinuousAggregateBuilder CreateGroupIndexes(bool createGroupIndexes = true) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes, createGroupIndexes); + ContinuousAggregateBuilderCore.CreateGroupIndexes(EntityTypeBuilder, createGroupIndexes); return this; } @@ -51,7 +51,7 @@ public ContinuousAggregateBuilder CreateGroupIndexes(boo /// The builder for method chaining. public ContinuousAggregateBuilder MaterializedOnly(bool materializedOnly = true) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.MaterializedOnly, materializedOnly); + ContinuousAggregateBuilderCore.MaterializedOnly(EntityTypeBuilder, materializedOnly); return this; } @@ -70,18 +70,8 @@ public ContinuousAggregateBuilder AddAggregateFunction aggregateFunctions = annotation?.Value as List ?? []; - - if (aggregateFunctions.Any(x => x.StartsWith(propertyName + ":"))) - { - return this; - } - string sourceColumnName = GetPropertyName(sourceColumn); - - aggregateFunctions.Add($"{propertyName}:{function}:{sourceColumnName}"); - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions); + ContinuousAggregateBuilderCore.AddAggregateFunction(EntityTypeBuilder, propertyName, sourceColumnName, function); return this; } @@ -94,18 +84,7 @@ public ContinuousAggregateBuilder AddAggregateFunction AddGroupByColumn( Expression> propertyExpression) { - string propertyName = GetPropertyName(propertyExpression); - IAnnotation? annotation = EntityTypeBuilder.Metadata.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns); - List groupByColumns = annotation?.Value as List ?? []; - - if (groupByColumns.Contains(propertyName)) - { - return this; - } - - groupByColumns.Add(propertyName); - - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.GroupByColumns, groupByColumns); + ContinuousAggregateBuilderCore.AddGroupByColumn(EntityTypeBuilder, GetPropertyName(propertyExpression)); return this; } @@ -116,17 +95,7 @@ public ContinuousAggregateBuilder AddGroupByColumnThe builder for method chaining. public ContinuousAggregateBuilder AddGroupByColumn(string groupByExpression) { - IAnnotation? annotation = EntityTypeBuilder.Metadata.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns); - List groupByColumns = annotation?.Value as List ?? []; - - if (groupByColumns.Contains(groupByExpression)) - { - return this; - } - - groupByColumns.Add(groupByExpression); - - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.GroupByColumns, groupByColumns); + ContinuousAggregateBuilderCore.AddGroupByColumn(EntityTypeBuilder, groupByExpression); return this; } @@ -137,7 +106,7 @@ public ContinuousAggregateBuilder AddGroupByColumn(strin /// The builder for method chaining. public ContinuousAggregateBuilder Where(string whereClause) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, whereClause); + ContinuousAggregateBuilderCore.Where(EntityTypeBuilder, whereClause); return this; } diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs new file mode 100644 index 0000000..78219d4 --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs @@ -0,0 +1,65 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate +{ + /// + /// Shared annotation-writing logic for + /// and . Operates on the non-generic + /// so both public builders stay thin delegating wrappers that cannot + /// drift apart. + /// + internal static class ContinuousAggregateBuilderCore + { + public static void WithNoData(EntityTypeBuilder builder, bool withNoData) + => builder.HasAnnotation(ContinuousAggregateAnnotations.WithNoData, withNoData); + + public static void CreateGroupIndexes(EntityTypeBuilder builder, bool createGroupIndexes) + => builder.HasAnnotation(ContinuousAggregateAnnotations.CreateGroupIndexes, createGroupIndexes); + + public static void MaterializedOnly(EntityTypeBuilder builder, bool materializedOnly) + => builder.HasAnnotation(ContinuousAggregateAnnotations.MaterializedOnly, materializedOnly); + + public static void WithChunkInterval(EntityTypeBuilder builder, string chunkInterval) + => builder.HasAnnotation(ContinuousAggregateAnnotations.ChunkInterval, chunkInterval); + + public static void Where(EntityTypeBuilder builder, string whereClause) + => builder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, whereClause); + + /// + /// Appends an aggregate mapping in the "alias:function:sourceColumn" annotation format. + /// An alias already present is left unchanged. + /// + public static void AddAggregateFunction(EntityTypeBuilder builder, string alias, string sourceColumn, EAggregateFunction function) + { + IAnnotation? annotation = builder.Metadata.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions); + List aggregateFunctions = annotation?.Value as List ?? []; + + if (aggregateFunctions.Any(x => x.StartsWith(alias + ":"))) + { + return; + } + + aggregateFunctions.Add($"{alias}:{function}:{sourceColumn}"); + builder.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions); + } + + /// + /// Appends a GROUP BY column or raw SQL expression. Duplicates are ignored. + /// + public static void AddGroupByColumn(EntityTypeBuilder builder, string groupByExpression) + { + IAnnotation? annotation = builder.Metadata.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns); + List groupByColumns = annotation?.Value as List ?? []; + + if (groupByColumns.Contains(groupByExpression)) + { + return; + } + + groupByColumns.Add(groupByExpression); + builder.HasAnnotation(ContinuousAggregateAnnotations.GroupByColumns, groupByColumns); + } + } +} diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs index 0740d2e..14ef480 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs @@ -43,6 +43,7 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde // Discover property-level configurations List aggregateFunctions = []; + List groupByColumns = []; foreach (IConventionProperty property in entityType.GetProperties()) { @@ -59,6 +60,12 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde string sourceColumn = aggregateAttr.SourceColumn ?? property.Name; aggregateFunctions.Add($"{property.Name}:{aggregateAttr.Function}:{sourceColumn}"); } + + GroupByColumnAttribute? groupByAttr = propertyInfo.GetCustomAttribute(); + if (groupByAttr != null) + { + groupByColumns.Add(groupByAttr.SourceColumn ?? property.Name); + } } // Apply the discovered property-level annotations @@ -66,6 +73,11 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde { entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions); } + + if (groupByColumns.Count != 0) + { + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.GroupByColumns, groupByColumns); + } } } } diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs new file mode 100644 index 0000000..7f5a0dc --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs @@ -0,0 +1,101 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate +{ + /// + /// Provides a fluent API for configuring a TimescaleDB continuous aggregate using string column references. + /// Used by code generated by dotnet ef dbcontext scaffold where generic source-entity type parameters + /// are not available at generation time. + /// + /// The class representing the continuous aggregate view. + public sealed class ContinuousAggregateStringBuilder where TEntity : class + { + private readonly EntityTypeBuilder _builder; + + internal ContinuousAggregateStringBuilder(EntityTypeBuilder builder) + { + _builder = builder; + } + + /// + /// Configures whether to create the continuous aggregate with no data initially. + /// + /// True to create with no data; false to populate immediately. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder WithNoData(bool withNoData = true) + { + ContinuousAggregateBuilderCore.WithNoData(_builder, withNoData); + return this; + } + + /// + /// Configures whether to automatically create indexes on group by columns. + /// + /// True to create indexes; false otherwise. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder CreateGroupIndexes(bool createGroupIndexes = true) + { + ContinuousAggregateBuilderCore.CreateGroupIndexes(_builder, createGroupIndexes); + return this; + } + + /// + /// Configures whether the continuous aggregate returns only materialized data. + /// + /// True to return only materialized data; false to include real-time data. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder MaterializedOnly(bool materializedOnly = true) + { + ContinuousAggregateBuilderCore.MaterializedOnly(_builder, materializedOnly); + return this; + } + + /// + /// Configures the chunk interval for the continuous aggregate's materialized hypertable. + /// + /// The chunk interval (e.g., "7 days", "1 month"). + /// The builder for method chaining. + public ContinuousAggregateStringBuilder WithChunkInterval(string chunkInterval) + { + ContinuousAggregateBuilderCore.WithChunkInterval(_builder, chunkInterval); + return this; + } + + /// + /// Adds an aggregate function mapping using column names as strings. + /// + /// The output column name on the continuous aggregate view. + /// The source column name from the source hypertable. + /// The aggregate function to apply. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder AddAggregateFunction( + string alias, string sourceColumn, EAggregateFunction function) + { + ContinuousAggregateBuilderCore.AddAggregateFunction(_builder, alias, sourceColumn, function); + return this; + } + + /// + /// Adds a GROUP BY column from the source hypertable using its column name as a string. + /// + /// The column name or SQL expression to group by. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder AddGroupByColumn(string groupByExpression) + { + ContinuousAggregateBuilderCore.AddGroupByColumn(_builder, groupByExpression); + return this; + } + + /// + /// Adds a WHERE clause to filter data in the continuous aggregate. + /// + /// The SQL WHERE clause expression (without the WHERE keyword). + /// The builder for method chaining. + public ContinuousAggregateStringBuilder Where(string whereClause) + { + ContinuousAggregateBuilderCore.Where(_builder, whereClause); + return this; + } + } +} diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs index 0ea8c29..d97f5ad 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs @@ -116,6 +116,34 @@ public static ContinuousAggregateBuilder IsContinuousAgg where TSourceEntity : class => IsContinuousAggregateCore(entityTypeBuilder, materializedViewName, timeBucketWidth, propertyExpression, timeBucketGroupBy, chunkInterval); + /// + /// Configures the entity as a TimescaleDB continuous aggregate using string column references. + /// + /// The continuous aggregate entity type. + /// The entity type builder. + /// The name of the materialized view. + /// The name of the source hypertable entity or its database table name. + /// The time bucket width interval (e.g., "1 hour", "7 days"). + /// The source time column name on the source hypertable. + /// A builder for further continuous aggregate configuration. + public static ContinuousAggregateStringBuilder IsContinuousAggregate( + this EntityTypeBuilder entityTypeBuilder, + string materializedViewName, + string parentName, + string timeBucketWidth, + string timeBucketSourceColumn) + where TEntity : class + { + // Preserve any schema already set on the entity (e.g., by EF Core's standard scaffolding before this call). + entityTypeBuilder.ToView(materializedViewName, entityTypeBuilder.Metadata.GetViewSchema()); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, materializedViewName); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ParentName, parentName); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, timeBucketWidth); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, timeBucketSourceColumn); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy, true); + return new ContinuousAggregateStringBuilder(entityTypeBuilder); + } + private static ContinuousAggregateBuilder IsContinuousAggregateCore( EntityTypeBuilder entityTypeBuilder, string materializedViewName, diff --git a/src/Eftdb/Configuration/ContinuousAggregate/GroupByColumnAttribute.cs b/src/Eftdb/Configuration/ContinuousAggregate/GroupByColumnAttribute.cs new file mode 100644 index 0000000..06aaac2 --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregate/GroupByColumnAttribute.cs @@ -0,0 +1,22 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate +{ + /// + /// Marks a property of a continuous aggregate entity as an additional GROUP BY column. + /// + /// + /// Complements the Fluent API's AddGroupByColumn for data-annotations configuration. + /// Raw SQL GROUP BY expressions cannot be expressed as an attribute; use the Fluent API for those. + /// + /// + /// The source column on the parent hypertable to group by. Defaults to the annotated property's + /// own name. Accepts a CLR property name or a database column name. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class GroupByColumnAttribute(string? sourceColumn = null) : Attribute + { + /// + /// The source column to group by, or null to use the annotated property's name. + /// + public string? SourceColumn { get; } = sourceColumn; + } +} diff --git a/src/Eftdb/Eftdb.csproj b/src/Eftdb/Eftdb.csproj index ae343c7..c0cad48 100644 --- a/src/Eftdb/Eftdb.csproj +++ b/src/Eftdb/Eftdb.csproj @@ -39,6 +39,7 @@ + diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index 9e73c3c..fbb3219 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -116,7 +116,7 @@ private static bool AreGroupByColumnsEqual(IReadOnlyList? list1, IReadOn if (list1 == null || list2 == null) return false; if (list1.Count != list2.Count) return false; - return list1.SequenceEqual(list2); + return list1.ToHashSet().SetEquals(list2); } } } diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index d304159..95a5f65 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -32,11 +32,7 @@ public static IEnumerable GetContinuousAggre } // Find the parent entity type - IEntityType? parentEntityType = relationalModel.Model.GetEntityTypes() - .FirstOrDefault(e => - e.ClrType?.Name == parentModelName - || e.ShortName() == parentModelName - || e.GetTableName() == parentModelName); + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(relationalModel.Model, parentModelName); if (parentEntityType == null) { continue; diff --git a/src/Eftdb/Internals/ParentEntityTypeResolver.cs b/src/Eftdb/Internals/ParentEntityTypeResolver.cs new file mode 100644 index 0000000..4a9e83f --- /dev/null +++ b/src/Eftdb/Internals/ParentEntityTypeResolver.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals +{ + /// + /// Resolves a continuous aggregate's parent entity type from its ParentName annotation value. + /// The value may hold the CLR class name (code-first), the EF Core short name, or the database table + /// name (scaffolding), so all three are matched. + /// + internal static class ParentEntityTypeResolver + { + public static IEntityType? Resolve(IModel model, string? parentName) + => string.IsNullOrWhiteSpace(parentName) + ? null + : model.GetEntityTypes().FirstOrDefault(e => + e.ClrType?.Name == parentName + || e.ShortName() == parentName + || e.GetTableName() == parentName); + } +} diff --git a/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs b/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs new file mode 100644 index 0000000..1fbd46e --- /dev/null +++ b/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs @@ -0,0 +1,89 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using NpgsqlTypes; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Abstractions; + +/// +/// Unit tests for TimescaleCopyConfig<T> auto-discovery and type-mapping behaviour. +/// +public class TimescaleCopyConfigTests +{ + #region Constructor_ExcludesProperty_When_Type_Has_No_NpgsqlDbType_Mapping + + private class EntityWithUnsupportedType + { + public int KnownProp { get; set; } + public Uri? UnknownProp { get; set; } + } + + [Fact] + public void Constructor_ExcludesProperty_When_Type_Has_No_NpgsqlDbType_Mapping() + { + // Arrange & + TimescaleCopyConfig config = new(); + + // Assert + Assert.True(config.ColumnMappings.ContainsKey(nameof(EntityWithUnsupportedType.KnownProp))); + Assert.False(config.ColumnMappings.ContainsKey(nameof(EntityWithUnsupportedType.UnknownProp))); + } + + #endregion + + #region Constructor_MapsKnownTypes_ToCorrectNpgsqlDbTypes + + private class EntityWithKnownTypes + { + public short ShortProp { get; set; } + public int IntProp { get; set; } + public long LongProp { get; set; } + public float FloatProp { get; set; } + public double DoubleProp { get; set; } + public decimal DecimalProp { get; set; } + public string StringProp { get; set; } = ""; + public DateTime DateTimeProp { get; set; } + public bool BoolProp { get; set; } + public Guid GuidProp { get; set; } + } + + [Fact] + public void Constructor_MapsKnownTypes_ToCorrectNpgsqlDbTypes() + { + // Arrange & Act + TimescaleCopyConfig config = new(); + + // Assert + Assert.Equal(NpgsqlDbType.Smallint, config.ColumnMappings[nameof(EntityWithKnownTypes.ShortProp)].DbType); + Assert.Equal(NpgsqlDbType.Integer, config.ColumnMappings[nameof(EntityWithKnownTypes.IntProp)].DbType); + Assert.Equal(NpgsqlDbType.Bigint, config.ColumnMappings[nameof(EntityWithKnownTypes.LongProp)].DbType); + Assert.Equal(NpgsqlDbType.Real, config.ColumnMappings[nameof(EntityWithKnownTypes.FloatProp)].DbType); + Assert.Equal(NpgsqlDbType.Double, config.ColumnMappings[nameof(EntityWithKnownTypes.DoubleProp)].DbType); + Assert.Equal(NpgsqlDbType.Numeric, config.ColumnMappings[nameof(EntityWithKnownTypes.DecimalProp)].DbType); + Assert.Equal(NpgsqlDbType.Text, config.ColumnMappings[nameof(EntityWithKnownTypes.StringProp)].DbType); + Assert.Equal(NpgsqlDbType.TimestampTz, config.ColumnMappings[nameof(EntityWithKnownTypes.DateTimeProp)].DbType); + Assert.Equal(NpgsqlDbType.Boolean, config.ColumnMappings[nameof(EntityWithKnownTypes.BoolProp)].DbType); + Assert.Equal(NpgsqlDbType.Uuid, config.ColumnMappings[nameof(EntityWithKnownTypes.GuidProp)].DbType); + } + + #endregion + + #region Constructor_MapsNullableType_UsingUnderlyingType + + private class EntityWithNullable + { + public int? NullableInt { get; set; } + public DateTime? NullableDateTime { get; set; } + } + + [Fact] + public void Constructor_MapsNullableType_UsingUnderlyingType() + { + // Arrange & Act + TimescaleCopyConfig config = new(); + + // Assert + Assert.Equal(NpgsqlDbType.Integer, config.ColumnMappings[nameof(EntityWithNullable.NullableInt)].DbType); + Assert.Equal(NpgsqlDbType.TimestampTz, config.ColumnMappings[nameof(EntityWithNullable.NullableDateTime)].DbType); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs index 2574773..ec0eab9 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs @@ -837,4 +837,127 @@ public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() } #endregion + + #region Should_Process_GroupByColumn_Attributes + + [Hypertable("Timestamp")] + private class GroupBySourceEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string Exchange { get; set; } = null!; + public string Region { get; set; } = null!; + } + + [ContinuousAggregate(MaterializedViewName = "group_by_attr_view", ParentName = "GroupBySourceEntity")] + [TimeBucket("1 hour", "Timestamp")] + private class GroupByAggregateEntity + { + public DateTime TimeBucket { get; set; } + + [GroupByColumn] + public string Exchange { get; set; } = null!; + + [GroupByColumn(nameof(GroupBySourceEntity.Region))] + public string SourceRegion { get; set; } = null!; + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class GroupByAttributeContext : DbContext + { + public DbSet Sources => 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.HasNoKey(); + entity.ToTable("group_by_attr_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Process_GroupByColumn_Attributes() + { + using GroupByAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(GroupByAggregateEntity))!; + + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + + Assert.NotNull(groupByColumns); + Assert.Equal(2, groupByColumns.Count); + Assert.Contains("Exchange", groupByColumns); + Assert.Contains("Region", groupByColumns); + } + + #endregion + + #region Should_Not_Set_GroupByColumns_Annotation_Without_Attributes + + [Hypertable("Timestamp")] + private class NoGroupBySourceEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "no_group_by_view", ParentName = "NoGroupBySourceEntity")] + [TimeBucket("1 hour", "Timestamp")] + private class NoGroupByAggregateEntity + { + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class NoGroupByAttributeContext : DbContext + { + public DbSet Sources => 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.HasNoKey(); + entity.ToTable("no_group_by_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_GroupByColumns_Annotation_Without_Attributes() + { + using NoGroupByAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoGroupByAggregateEntity))!; + + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs index 9d0f391..a6232fb 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs @@ -848,4 +848,61 @@ public void Should_Require_ContinuousAggregate_Attribute() } #endregion + + #region Should_Not_Set_BucketsPerBatch_Annotation_When_Equal_To_Default_One + + private class MetricEntity16 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics_16", ParentName = "Metrics16")] + [ContinuousAggregatePolicy(StartOffset = "1 month", EndOffset = "1 hour", ScheduleInterval = "1 hour")] + private class AggregateEntity16 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketsPerBatchDefaultContext16 : 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("Metrics16"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_BucketsPerBatch_Annotation_When_Equal_To_Default_One() + { + // Arrange + using BucketsPerBatchDefaultContext16 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntity16))!; + + // Assert + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs index 8ea4700..2613bc6 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs @@ -1,4 +1,3 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -329,6 +328,49 @@ public void TryResolvePropertyName_Returns_False_When_Not_Found() #endregion + #region TryResolvePropertyName_Fallback_FindProperty_When_No_StoreObject + + private class NoMappingFallbackEntity + { + public int Timestamp { get; set; } + public double SomeValue { get; set; } + } + + private class NoMappingFallbackContext : DbContext + { + public DbSet Items => 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.ToSqlQuery("SELECT 0 AS \"Timestamp\", 0.0 AS \"SomeValue\""); + }); + } + } + + [Fact] + public void TryResolvePropertyName_Fallback_FindProperty_When_No_StoreObject() + { + // Arrange + using NoMappingFallbackContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "SomeValue", out string propertyName); + + // Assert + Assert.True(found); + Assert.Equal("SomeValue", propertyName); + } + + #endregion + #region TryResolvePropertyName_View_Mapped_Entity_Returns_True_On_Match private class ViewMappedEntity diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs new file mode 100644 index 0000000..299dcdc --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs @@ -0,0 +1,1510 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for ContinuousAggregateAnnotationRenderer exercised through the public +/// surface. +/// +public class ContinuousAggregateAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private const string StandardViewDef = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " api_log.service_name AS service_name," + + " avg(api_log.duration_ms) AS avg_duration_ms" + + " FROM api_log" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\"), api_log.service_name"; + + private class ApiLogEntity + { + public DateTime Time { get; set; } + public double DurationMs { get; set; } + public string ServiceName { get; set; } = ""; + } + + private class HourlyStatsEntity + { + public DateTime Bucket { get; set; } + public double AvgDurationMs { get; set; } + public string ServiceName { get; set; } = ""; + } + + private class CaTestContext : DbContext + { + public DbSet ApiLogs => Set(); + public DbSet HourlyStats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("api_log"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.DurationMs).HasColumnName("duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("hourly_stats"); + e.Property(x => x.AvgDurationMs).HasColumnName("avg_duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + } + } + + // ── GenerateFluentApiCalls ───────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_NoMaterializedViewName + + private class NoAnnotationEntity { public DateTime Ts { get; set; } } + + private class NoAnnotationContext : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => { e.HasNoKey(); e.ToTable("ca_no_ann"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_NoMaterializedViewName() + { + // Arrange + using NoAnnotationContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations(); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_ViewDefinitionUnparseable + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_ViewDefinitionUnparseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + } + + #endregion + + #region GenerateFluentApiCalls_Minimal_Returns_IsContinuousAggregate + + [Fact] + public void GenerateFluentApiCalls_Minimal_Returns_IsContinuousAggregate() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? call = result.FirstOrDefault(f => + CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + Assert.NotNull(call); + Assert.Equal(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate), call.Method); + } + + #endregion + + #region GenerateFluentApiCalls_HumanizesInterval + + [Fact] + public void GenerateFluentApiCalls_HumanizesInterval() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? call = result.First(f => + CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + + Assert.Equal("1 hour", call.Arguments[2]); + } + + #endregion + + #region GenerateFluentApiCalls_Includes_MaterializedOnly + + [Fact] + public void GenerateFluentApiCalls_Includes_MaterializedOnly() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("MaterializedOnly")); + } + + #endregion + + #region GenerateFluentApiCalls_Includes_AddAggregateFunction + + [Fact] + public void GenerateFluentApiCalls_Includes_AddAggregateFunction() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("AddAggregateFunction")); + } + + #endregion + + #region GenerateFluentApiCalls_Includes_AddGroupByColumn + + [Fact] + public void GenerateFluentApiCalls_Includes_AddGroupByColumn() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("AddGroupByColumn")); + } + + #endregion + + #region GenerateFluentApiCalls_Includes_Where + + [Fact] + public void GenerateFluentApiCalls_Includes_Where() + { + // Arrange + const string viewDefWithWhere = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " avg(api_log.duration_ms) AS avg_duration_ms" + + " FROM api_log" + + " WHERE api_log.service_name = 'payments'" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\")"; + + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefWithWhere)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("Where")); + } + + #endregion + + #region GenerateFluentApiCalls_ConsumesAllCaAnnotations + + [Fact] + public void GenerateFluentApiCalls_ConsumesAllCaAnnotations() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ChunkInterval, "7 days"), + (ContinuousAggregateAnnotations.WithNoData, false), + (ContinuousAggregateAnnotations.CreateGroupIndexes, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + } + + #endregion + + #region GenerateFluentApiCalls_LeavesAnnotationsUnconsumed_WhenViewDefUnparseable + + [Fact] + public void GenerateFluentApiCalls_LeavesAnnotationsUnconsumed_WhenViewDefUnparseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("IsContinuousAggregate")); + Assert.Contains(ContinuousAggregateAnnotations.MaterializedViewName, annotations.Keys); + Assert.Contains(ContinuousAggregateAnnotations.ParentName, annotations.Keys); + Assert.Contains(ContinuousAggregateAnnotations.ViewDefinition, annotations.Keys); + } + + #endregion + + // ── GenerateDataAnnotationAttributes ────────────────────────────────── + + #region GenerateDataAnnotationAttributes_Returns_BasicAttribute + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_BasicAttribute() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(attr); + } + + #endregion + + #region GenerateDataAnnotationAttributes_HumanizesInterval + + [Fact] + public void GenerateDataAnnotationAttributes_HumanizesInterval() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(TimeBucketAttribute)); + Assert.NotNull(attr); + Assert.Equal("1 hour", attr.Arguments[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_TimeBucketAttribute + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_TimeBucketAttribute() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? timeBucketAttr = result.FirstOrDefault(a => a.Type == typeof(TimeBucketAttribute)); + Assert.NotNull(timeBucketAttr); + Assert.DoesNotContain(result, a => + a.Type == typeof(ContinuousAggregateAttribute) && + a.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.TimeBucketWidth))); + } + + #endregion + + #region GenerateFluentApiCalls_MaterializedOnly_HasExplicitTrueArg + + [Fact] + public void GenerateFluentApiCalls_MaterializedOnly_HasExplicitTrueArg() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .Select(f => + { + for (MethodCallCodeFragment? current = f; current != null; current = current.ChainedCall) + { + if (current.Method == "MaterializedOnly") return current; + } + return null; + }) + .FirstOrDefault(f => f != null); + Assert.NotNull(fragment); + Assert.Equal(true, fragment.Arguments.FirstOrDefault()); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Includes_WhereClause + + [Fact] + public void GenerateDataAnnotationAttributes_Includes_WhereClause() + { + // Arrange + const string viewDefWithWhere = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " avg(api_log.duration_ms) AS avg_duration_ms" + + " FROM api_log" + + " WHERE api_log.service_name = 'payments'" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\")"; + + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefWithWhere)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(attr); + Assert.True(attr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.Where))); + Assert.NotNull(attr.NamedArguments[nameof(ContinuousAggregateAttribute.Where)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ConsumesAllCaAnnotations + + [Fact] + public void GenerateDataAnnotationAttributes_ConsumesAllCaAnnotations() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ChunkInterval, "7 days"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + } + + #endregion + + // ── DA mode suppression ─────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_InDataAnnotationsMode_DoesNotEmitCaFragments + + [Fact] + public void GenerateFluentApiCalls_InDataAnnotationsMode_DoesNotEmitCaFragments() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + Assert.DoesNotContain(result, f => f.Method == "AddAggregateFunction"); + Assert.DoesNotContain(result, f => f.Method == "MaterializedOnly"); + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + } + + #endregion + + // ── Property-level [Aggregate] attribute ───────────────────────────────── + + private class AggSourceEntity + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class AggCaEntity + { + public double AvgValue { get; set; } + public long TradeCount { get; set; } + } + + private class AggTestContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaEntities => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("agg_source"); + e.Property(x => x.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("agg_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "agg_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "AvgValue:Avg:Value", "TradeCount:Count:*" }); + }); + } + } + + #region GenerateDataAnnotationAttributes_Property_Returns_AggregateAttribute + + [Fact] + public void GenerateDataAnnotationAttributes_Property_Returns_AggregateAttribute() + { + // Arrange + using AggTestContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(AggCaEntity.AvgValue))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Avg, attr.Arguments[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_CountStar_UsesWildcardSourceColumn + + [Fact] + public void GenerateDataAnnotationAttributes_Property_CountStar_UsesWildcardSourceColumn() + { + // Arrange + using AggTestContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(AggCaEntity.TradeCount))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Count, attr.Arguments[0]); + Assert.Equal("*", attr.Arguments[1]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_NoAggregateForNonCaProperty + + private class PlainEntity { public int Id { get; set; } } + + private class PlainContext : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => { e.HasKey(x => x.Id); e.ToTable("plain_agg_test"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Property_NoAggregateForNonCaProperty() + { + // Arrange + using PlainContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(PlainEntity.Id))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + } + + #endregion + + // ── Property-level [Aggregate] — scaffold path (no AggregateFunctions, uses ViewDefinition) ── + + private class ScaffoldSourceEntity + { + public DateTime Time { get; set; } + public double Price { get; set; } + } + + private class ScaffoldCaEntity + { + public double MaxPrice { get; set; } + public long TradeCount { get; set; } + } + + private class ScaffoldAggTestContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("scaffold_source"); + e.Property(x => x.Price).HasColumnName("price"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("scaffold_ca", "public"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "scaffold_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "scaffold_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " max(s.price) AS max_price," + + " count(*) AS trade_count" + + " FROM scaffold_source s GROUP BY 1"); + e.Property(x => x.MaxPrice).HasColumnName("max_price"); + e.Property(x => x.TradeCount).HasColumnName("trade_count"); + }); + } + } + + #region GenerateDataAnnotationAttributes_Property_ScaffoldPath_UsesViewDefinition + + [Fact] + public void GenerateDataAnnotationAttributes_Property_ScaffoldPath_UsesViewDefinition() + { + // Arrange + using ScaffoldAggTestContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(ScaffoldCaEntity.MaxPrice))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Max, attr.Arguments[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_ScaffoldPath_CountStar + + [Fact] + public void GenerateDataAnnotationAttributes_Property_ScaffoldPath_CountStar() + { + // Arrange + using ScaffoldAggTestContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(ScaffoldCaEntity.TradeCount))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Count, attr.Arguments[0]); + Assert.Equal("*", attr.Arguments[1]); + } + + #endregion + + // ── Reporter warnings and graceful degradation ───────────────────────── + + private sealed class RecordingReporter : IOperationReporter + { + public List Warnings { get; } = []; + public void WriteError(string message) { } + public void WriteWarning(string message) => Warnings.Add(message); + public void WriteInformation(string message) { } + public void WriteVerbose(string message) { } + } + + private static (TimescaleDbAnnotationCodeGenerator Generator, RecordingReporter Reporter) CreateGeneratorWithReporter() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + ServiceProvider sp = services.BuildServiceProvider(); + RecordingReporter reporter = new(); + TimescaleDbAnnotationCodeGenerator generator = new( + sp.GetRequiredService(), reporter) + { + ScaffoldMode = true, + }; + return (generator, reporter); + } + + #region GenerateFluentApiCalls_ReportsWarning_WhenViewDefUnparseable + + [Fact] + public void GenerateFluentApiCalls_ReportsWarning_WhenViewDefUnparseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL")); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + + // Act + generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + string warning = Assert.Single(reporter.Warnings); + Assert.Contains("hourly_stats", warning); + } + + #endregion + + #region ConsumeFeatureAnnotations_ReportsWarning_And_Keeps_Annotations_WhenUnparseable + + [Fact] + public void ConsumeFeatureAnnotations_ReportsWarning_And_Keeps_Annotations_WhenUnparseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL")); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Single(reporter.Warnings); + Assert.Contains(ContinuousAggregateAnnotations.ViewDefinition, annotations.Keys); + } + + #endregion + + #region ConsumeFeatureAnnotations_Consumes_All_WhenParseable + + [Fact] + public void ConsumeFeatureAnnotations_Consumes_All_WhenParseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("IsContinuousAggregate")); + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + Assert.Empty(reporter.Warnings); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ReportsWarning_ForUnrepresentableGroupBy + + private class RawGroupByCaEntity + { + public DateTime Bucket { get; set; } + public double AvgDurationMs { get; set; } + } + + private class RawGroupByContext : DbContext + { + public DbSet ApiLogs => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("api_log"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.DurationMs).HasColumnName("duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("raw_group_by_stats"); + e.Property(x => x.AvgDurationMs).HasColumnName("avg_duration_ms"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_ReportsWarning_ForUnrepresentableGroupBy() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " avg(api_log.duration_ms) AS avg_duration_ms" + + " FROM api_log" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\"), EXTRACT(HOUR FROM api_log.\"time\")"; + + using RawGroupByContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "raw_group_by_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + generator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + string warning = Assert.Single(reporter.Warnings); + Assert.Contains("EXTRACT", warning); + Assert.Contains("raw_group_by_stats", warning); + } + + #endregion + + // ── Chunk-interval 10x-parent elision ────────────────────────────────── + + #region ChunkInterval_Elided_WhenEqualTo10xParentDefault + + private static Dictionary ElisionAnnotations(string chunkInterval) => Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ChunkInterval, chunkInterval), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + [Fact] + public void ChunkInterval_Elided_WhenEqualTo10xParentDefault() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, ElisionAnnotations("70 days")); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithChunkInterval")); + } + + #endregion + + #region ChunkInterval_Kept_WhenNot10xParentDefault + + [Fact] + public void ChunkInterval_Kept_WhenNot10xParentDefault() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, ElisionAnnotations("30 days")); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithChunkInterval")); + } + + #endregion + + #region ChunkInterval_Kept_ForCalendarUnits + + [Fact] + public void ChunkInterval_Kept_ForCalendarUnits() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, ElisionAnnotations("1 month")); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithChunkInterval")); + } + + #endregion + + #region ChunkInterval_Elided_InDataAnnotationsMode + + [Fact] + public void ChunkInterval_Elided_InDataAnnotationsMode() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, ElisionAnnotations("70 days")); + + // Assert + AttributeCodeFragment attr = Assert.Single(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.DoesNotContain(nameof(ContinuousAggregateAttribute.ChunkInterval), attr.NamedArguments.Keys); + } + + #endregion + + #region GenerateDataAnnotationAttributes_MaterializedOnly_True_Sets_NamedArg + + [Fact] + public void GenerateDataAnnotationAttributes_MaterializedOnly_True_Sets_NamedArg() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.MaterializedOnly, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(attr); + Assert.True(attr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.MaterializedOnly))); + Assert.Equal(true, attr.NamedArguments[nameof(ContinuousAggregateAttribute.MaterializedOnly)]); + } + + #endregion + + // ── First/Last aggregates and parent resolution ──────────────────────── + + #region GenerateFluentApiCalls_Includes_FirstLast_Aggregates + + private class FirstLastCaEntity + { + public DateTime Bucket { get; set; } + public double FirstDurationMs { get; set; } + public double LastDurationMs { get; set; } + } + + private class FirstLastContext : DbContext + { + public DbSet ApiLogs => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("api_log"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.DurationMs).HasColumnName("duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("first_last_stats"); + e.Property(x => x.FirstDurationMs).HasColumnName("first_duration_ms"); + e.Property(x => x.LastDurationMs).HasColumnName("last_duration_ms"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_Includes_FirstLast_Aggregates() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " first(api_log.duration_ms, api_log.\"time\") AS first_duration_ms," + + " last(api_log.duration_ms, api_log.\"time\") AS last_duration_ms" + + " FROM api_log" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\")"; + + using FirstLastContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "first_last_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => CollectMethodChain(f).Contains("IsContinuousAggregate")); + List functions = []; + for (MethodCallCodeFragment? current = root; current != null; current = current.ChainedCall) + { + if (current.Method == "AddAggregateFunction" && current.Arguments[2] is EAggregateFunction f) + { + functions.Add(f); + } + } + + Assert.Contains(EAggregateFunction.First, functions); + Assert.Contains(EAggregateFunction.Last, functions); + } + + #endregion + + #region ParentResolution_Matches_ClrName_WhenTableNameDiffers + + [Fact] + public void ParentResolution_Matches_ClrName_WhenTableNameDiffers() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, nameof(ApiLogEntity)), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == "IsContinuousAggregate"); + + // The parent resolved by CLR name renders as nameof(ApiLogEntity), not a raw string. + Assert.IsType(root.Arguments[1]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_ComplexType_DoesNotThrow + + private class ComplexOwnerEntity + { + public int Id { get; set; } + public AddressComplex Address { get; set; } = null!; + } + + private class AddressComplex + { + public string City { get; set; } = ""; + } + + private class ComplexTypeContext : DbContext + { + public DbSet Owners => 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(e => + { + e.HasKey(x => x.Id); + e.ToTable("complex_owner"); + e.ComplexProperty(x => x.Address); + }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Property_ComplexType_DoesNotThrow() + { + // Arrange + using ComplexTypeContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty cityProperty = entityType.GetComplexProperties().Single().ComplexType.GetProperties() + .Single(p => p.Name == nameof(AddressComplex.City)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(cityProperty, new Dictionary()); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region GenerateFluentApiCalls_WithNoData_True_Chains_WithNoData_Method + + [Fact] + public void GenerateFluentApiCalls_WithNoData_True_Chains_WithNoData_Method() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.WithNoData, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .Select(f => + { + for (MethodCallCodeFragment? current = f; current != null; current = current.ChainedCall) + { + if (current.Method == "WithNoData") return current; + } + return null; + }) + .FirstOrDefault(f => f != null); + Assert.NotNull(fragment); + Assert.Equal(true, fragment.Arguments.FirstOrDefault()); + } + + #endregion + + #region GenerateFluentApiCalls_CreateGroupIndexes_False_Chains_CreateGroupIndexes_Method + + [Fact] + public void GenerateFluentApiCalls_CreateGroupIndexes_False_Chains_CreateGroupIndexes_Method() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.CreateGroupIndexes, false), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .Select(f => + { + for (MethodCallCodeFragment? current = f; current != null; current = current.ChainedCall) + { + if (current.Method == "CreateGroupIndexes") return current; + } + return null; + }) + .FirstOrDefault(f => f != null); + Assert.NotNull(fragment); + Assert.Equal(false, fragment.Arguments.FirstOrDefault()); + } + + #endregion + + #region GenerateDataAnnotationAttributes_WithNoData_True_Sets_NamedArg + + [Fact] + public void GenerateDataAnnotationAttributes_WithNoData_True_Sets_NamedArg() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.WithNoData, true), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(attr); + Assert.True(attr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.WithNoData))); + Assert.Equal(true, attr.NamedArguments[nameof(ContinuousAggregateAttribute.WithNoData)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CreateGroupIndexes_False_Sets_NamedArg + + [Fact] + public void GenerateDataAnnotationAttributes_CreateGroupIndexes_False_Sets_NamedArg() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.CreateGroupIndexes, false), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(attr); + Assert.True(attr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.CreateGroupIndexes))); + Assert.Equal(false, attr.NamedArguments[nameof(ContinuousAggregateAttribute.CreateGroupIndexes)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_Emits_GroupByColumnAttribute + + private class GroupByPropCaEntity + { + public DateTime Bucket { get; set; } + public double AvgDurationMs { get; set; } + public string ServiceName { get; set; } = ""; + } + + private class GroupByPropContext : DbContext + { + public DbSet ApiLogs => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("api_log"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.DurationMs).HasColumnName("duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("group_by_prop_stats"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "group_by_prop_stats"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "api_log"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef); + e.Property(x => x.AvgDurationMs).HasColumnName("avg_duration_ms"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_Property_Emits_GroupByColumnAttribute() + { + // Arrange + using GroupByPropContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(GroupByPropCaEntity.ServiceName))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment attr = Assert.Single(result, a => a.Type == typeof(GroupByColumnAttribute)); + + Assert.Empty(attr.Arguments); + } + + #endregion + + // ── TimescaleDbAnnotationCodeGenerator scaffold-mode guard ───────────── + + #region GenerateFluentApiCalls_NonScaffoldMode_Returns_No_TimescaleFragments + + [Fact] + public void GenerateFluentApiCalls_NonScaffoldMode_Returns_No_TimescaleFragments() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("IsContinuousAggregate")); + } + + #endregion + + #region GenerateDataAnnotationAttributes_NonScaffoldMode_Returns_No_TimescaleAttributes + + [Fact] + public void GenerateDataAnnotationAttributes_NonScaffoldMode_Returns_No_TimescaleAttributes() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList result = generator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Entity_CacheHit_Returns_Identical_Result + + [Fact] + public void GenerateDataAnnotationAttributes_Entity_CacheHit_Returns_Identical_Result() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + // Act + IReadOnlyList first = generator.GenerateDataAnnotationAttributes(entityType, annotations); + + Dictionary annotations2 = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ParentName, "api_log"), + (ContinuousAggregateAnnotations.ViewDefinition, StandardViewDef)); + + IReadOnlyList second = generator.GenerateDataAnnotationAttributes(entityType, annotations2); + + // Assert + AttributeCodeFragment? firstCa = first.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + AttributeCodeFragment? secondCa = second.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(firstCa); + Assert.NotNull(secondCa); + Assert.Equal(firstCa.Type, secondCa.Type); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Property_CacheHit_Returns_Identical_Result + + [Fact] + public void GenerateDataAnnotationAttributes_Property_CacheHit_Returns_Identical_Result() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + + using ScaffoldAggTestContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(ScaffoldCaEntity.MaxPrice))!; + + // Act + IReadOnlyList first = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + IReadOnlyList second = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.Equal( + first.FirstOrDefault(a => a.Type == typeof(AggregateAttribute))?.Type, + second.FirstOrDefault(a => a.Type == typeof(AggregateAttribute))?.Type); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs index a877865..48d173d 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs @@ -13,8 +13,8 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; /// -/// Tests for HypertableAnnotationRenderer exercised through the public -/// surface (no InternalsVisibleTo on the design project). +/// Tests for HypertableAnnotationRenderer exercised through the +/// surface. /// public class HypertableAnnotationRendererTests { @@ -28,7 +28,11 @@ private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator() ServiceCollection services = new(); services.AddEntityFrameworkDesignTimeServices(); new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); - return services.BuildServiceProvider().GetRequiredService(); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; } private static IEntityType GetEntityType(DbContext context) @@ -1046,4 +1050,48 @@ public void GenerateFluentApiCalls_WhitespaceOnly_TimeColumn_Returns_Empty() } #endregion + + #region ConsumeFeatureAnnotations_Consumes_All_Hypertable_Annotations_In_DaMode + + private class ConsumeDaEntity { public DateTime Ts { get; set; } public string Device { get; set; } = ""; } + + private class ConsumeDaContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("consume_da_ht"); }); + } + + [Fact] + public void ConsumeFeatureAnnotations_Consumes_All_Hypertable_Annotations_In_DaMode() + { + using ConsumeDaContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, "1 day"), + (HypertableAnnotations.EnableCompression, true), + (HypertableAnnotations.CompressionSegmentBy, "Device"), + (HypertableAnnotations.CompressionOrderBy, "Ts DESC"), + (HypertableAnnotations.ChunkSkipColumns, "Ts"), + (HypertableAnnotations.MigrateData, true), + (HypertableAnnotations.AdditionalDimensions, DimensionsJson(new Dimension + { + ColumnName = "Device", + Type = EDimensionType.Hash, + NumberOfPartitions = 4, + }))); + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs b/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs new file mode 100644 index 0000000..06e9bb8 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs @@ -0,0 +1,97 @@ +#pragma warning disable EF1001 +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +/// +/// Migration-snapshot generation must preserve TimescaleDB feature annotations verbatim. The design-time +/// IAnnotationCodeGenerator is also active during dotnet ef migrations add for package +/// consumers; the feature renderers may only rewrite annotations during scaffolding. Outside scaffolding +/// the .HasAnnotation(...) fallback keeps the snapshot complete and compilable (builder extension +/// methods require the generic EntityTypeBuilder<T>, which snapshots do not use). +/// +public class MigrationSnapshotGenerationTests +{ + private static string GenerateSnapshot(DbContext context) + { + IModel model = context.GetService().Model; + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + using ServiceProvider sp = services.BuildServiceProvider(); + IMigrationsCodeGenerator generator = sp.GetRequiredService().Select("C#"); + return generator.GenerateSnapshot("TestModels", context.GetType(), "TestSnapshot", model); + } + + #region Should_Preserve_Feature_Annotations_In_CodeFirst_Snapshot + + private class SnapshotSource + { + public DateTime Time { get; set; } + public double Price { get; set; } + public string Exchange { get; set; } = null!; + } + + private class SnapshotCa + { + public DateTime Bucket { get; set; } + public double MaxPrice { get; set; } + public string Exchange { get; set; } = null!; + } + + private class SnapshotCodeFirstContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Cas => 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(e => + { + e.HasNoKey(); + e.ToTable("snapshot_cf_source"); + e.IsHypertable(x => x.Time); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("snapshot_cf_ca", "1 hour", x => x.Time) + .AddAggregateFunction(x => x.MaxPrice, x => x.Price, EAggregateFunction.Max) + .AddGroupByColumn(x => x.Exchange); + }); + } + } + + [Fact] + public void Should_Preserve_Feature_Annotations_In_CodeFirst_Snapshot() + { + using SnapshotCodeFirstContext context = new(); + + string snapshot = GenerateSnapshot(context); + + Assert.Contains($"HasAnnotation(\"{HypertableAnnotations.IsHypertable}\"", snapshot); + Assert.Contains($"HasAnnotation(\"{HypertableAnnotations.HypertableTimeColumn}\"", snapshot); + Assert.Contains($"HasAnnotation(\"{ContinuousAggregateAnnotations.MaterializedViewName}\"", snapshot); + Assert.Contains($"HasAnnotation(\"{ContinuousAggregateAnnotations.AggregateFunctions}\"", snapshot); + Assert.Contains($"HasAnnotation(\"{ContinuousAggregateAnnotations.GroupByColumns}\"", snapshot); + + // Snapshots use the non-generic EntityTypeBuilder; the library's fluent extensions would not compile + // there and must never be emitted outside scaffolding. + Assert.DoesNotContain(".IsHypertable(", snapshot); + Assert.DoesNotContain(".IsContinuousAggregate(", snapshot); + Assert.DoesNotContain(".AddAggregateFunction(", snapshot); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs new file mode 100644 index 0000000..ba94d5b --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs @@ -0,0 +1,211 @@ +#pragma warning disable EF1001 +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +/// +/// Full-pipeline rendering tests: models carrying scaffolding annotations are run through the real +/// design-time service chain and assertions are made on the RENDERED C# output, not on code fragments. +/// These pin the chaining strategy of the annotation renderers (a chained root must render). +/// +public class ScaffoldedModelRenderingTests +{ + private static ServiceProvider CreateDesignServices() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider(); + } + + private static ModelCodeGenerationOptions DefaultOptions(bool useDataAnnotations = false) => new() + { + Language = "C#", + UseDataAnnotations = useDataAnnotations, + ProjectDir = ".", + ModelNamespace = "TestModels", + ContextName = "TestDbContext", + ContextNamespace = "TestModels", + ConnectionString = "Host=localhost;Database=test" + }; + + private static ScaffoldedModel Generate(DbContext context, bool useDataAnnotations) + { + IModel model = context.GetService().Model; + using ServiceProvider sp = CreateDesignServices(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations); + IModelCodeGenerator generator = sp.GetRequiredService().Select(options); + return generator.GenerateModel(model, options); + } + + #region Should_Render_IsHypertable_Root_And_Chained_Calls_In_Fluent_Scaffold + + private class RenderHtEntity { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RenderHtContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("render_ht_entity"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(HypertableAnnotations.ChunkTimeInterval, "1 day"); + }); + } + + [Fact] + public void Should_Render_IsHypertable_Root_And_Chained_Calls_In_Fluent_Scaffold() + { + using RenderHtContext context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".IsHypertable(x => x.Time)", code); + Assert.Contains(".WithChunkTimeInterval(\"1 day\")", code); + Assert.True( + code.IndexOf(".IsHypertable(", StringComparison.Ordinal) < + code.IndexOf(".WithChunkTimeInterval(", StringComparison.Ordinal), + "IsHypertable must precede WithChunkTimeInterval in the rendered chain."); + Assert.DoesNotContain("using CmdScale.EntityFrameworkCore.TimescaleDB.Design", code); + } + + #endregion + + #region Should_Render_IsContinuousAggregate_Before_Builder_Calls_In_Fluent_Scaffold + + private class RenderCaSource { public DateTime Time { get; set; } public double Price { get; set; } public string Region { get; set; } = null!; } + private class RenderCaView { public DateTime Bucket { get; set; } public double MaxPrice { get; set; } public string Region { get; set; } = null!; } + + private class RenderCaContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_ca_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Price).HasColumnName("price"); + e.Property(x => x.Region).HasColumnName("region"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_ca_view"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.MaxPrice).HasColumnName("max_price"); + e.Property(x => x.Region).HasColumnName("region"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_ca_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_ca_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + " SELECT time_bucket('01:00:00'::interval, \"time\") AS bucket, region, max(price) AS max_price" + + " FROM render_ca_source WHERE (price > (0)::double precision)" + + " GROUP BY (time_bucket('01:00:00'::interval, \"time\")), region"); + }); + } + } + + [Fact] + public void Should_Render_IsContinuousAggregate_Before_Builder_Calls_In_Fluent_Scaffold() + { + using RenderCaContext context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".IsContinuousAggregate(", code); + Assert.Contains(".AddAggregateFunction(", code); + Assert.Contains(".AddGroupByColumn(", code); + Assert.Contains(".Where(", code); + + int rootIndex = code.IndexOf(".IsContinuousAggregate(", StringComparison.Ordinal); + Assert.True(rootIndex < code.IndexOf(".AddAggregateFunction(", StringComparison.Ordinal), + "IsContinuousAggregate must precede AddAggregateFunction; the builder methods are only reachable from its return value."); + Assert.True(rootIndex < code.IndexOf(".AddGroupByColumn(", StringComparison.Ordinal), + "IsContinuousAggregate must precede AddGroupByColumn."); + } + + #endregion + + #region Should_Render_Attributes_And_Usings_In_DataAnnotations_Scaffold + + private class RenderDaSource { public DateTime Time { get; set; } public double Price { get; set; } } + private class RenderDaView { public DateTime Bucket { get; set; } public double MaxPrice { get; set; } } + + private class RenderDaContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_da_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Price).HasColumnName("price"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_da_view"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.MaxPrice).HasColumnName("max_price"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_da_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_da_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + " SELECT time_bucket('01:00:00'::interval, \"time\") AS bucket, max(price) AS max_price" + + " FROM render_da_source GROUP BY (time_bucket('01:00:00'::interval, \"time\"))"); + }); + } + } + + [Fact] + public void Should_Render_Attributes_And_Usings_In_DataAnnotations_Scaffold() + { + using RenderDaContext context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? htFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RenderDaSource))); + ScaffoldedFile? caFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RenderDaView))); + Assert.NotNull(htFile); + Assert.NotNull(caFile); + + Assert.Contains("[Hypertable(", htFile.Code); + Assert.Contains($"using {typeof(HypertableAttribute).Namespace};", htFile.Code); + + Assert.Contains("[ContinuousAggregate(", caFile.Code); + Assert.Contains("[TimeBucket(", caFile.Code); + Assert.Contains($"using {typeof(ContinuousAggregateAttribute).Namespace};", caFile.Code); + Assert.DoesNotContain(".IsHypertable(", result.ContextFile.Code); + Assert.DoesNotContain(".IsContinuousAggregate(", result.ContextFile.Code); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs index 5c804d5..1170230 100644 --- a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs @@ -1,4 +1,6 @@ #pragma warning disable EF1001 +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Design; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; @@ -134,5 +136,98 @@ public void GenerateModel_UseDataAnnotations_True_Injects_Hypertable_Namespace() } #endregion + + // ── DA mode: suppress hypertable fluent API in context file ─────────────── + + #region GenerateModel_UseDataAnnotations_True_DoesNotEmitHypertableFluentApiCalls + + [Hypertable(nameof(Ts))] + private class HypertableWithChunkEntity { public DateTime Ts { get; set; } } + + private class HtChunkContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("ht_chunk_da_test"); + e.WithChunkTimeInterval("1 day"); + }); + } + + [Fact] + public void GenerateModel_UseDataAnnotations_True_DoesNotEmitHypertableFluentApiCalls() + { + using HtChunkContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + IModelCodeGenerator generator = selector.Select(options); + + ScaffoldedModel result = generator.GenerateModel(model, options); + Assert.DoesNotContain("WithChunkTimeInterval", result.ContextFile.Code); + } + + #endregion + + // ── Property-level namespace injection for CA entities ──────────────────── + + #region GenerateModel_UseDataAnnotations_True_Injects_Aggregate_Namespaces + + private class CaAggSourceEntity { public DateTime Time { get; set; } public double Price { get; set; } } + + private class CaAggViewEntity { public double MaxPrice { get; set; } } + + private class CaAggNamespaceContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("ca_agg_ns_source"); + e.Property(x => x.Price).HasColumnName("price"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("ca_agg_ns_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "ca_agg_ns_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "ca_agg_ns_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "MaxPrice:Max:Price" }); + e.Property(x => x.MaxPrice).HasColumnName("max_price"); + }); + } + } + + [Fact] + public void GenerateModel_UseDataAnnotations_True_Injects_Aggregate_Namespaces() + { + using CaAggNamespaceContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + IModelCodeGenerator generator = selector.Select(options); + + ScaffoldedModel result = generator.GenerateModel(model, options); + + bool hasAbstractionsNs = result.AdditionalFiles.Any(f => + f.Code.Contains($"using {typeof(EAggregateFunction).Namespace};")); + bool hasAggregateAttrNs = result.AdditionalFiles.Any(f => + f.Code.Contains($"using {typeof(AggregateAttribute).Namespace};")); + + Assert.True(hasAbstractionsNs, "Expected EAggregateFunction namespace in entity file"); + Assert.True(hasAggregateAttrNs, "Expected AggregateAttribute namespace in entity file"); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs b/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs new file mode 100644 index 0000000..f6d2aa7 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs @@ -0,0 +1,591 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +public class ViewDefinitionParserTests +{ + // A representative TimescaleDB CA view definition with several aggregate functions. + private const string FullViewDef = + "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + + " api_log.service_name AS service_name," + + " avg(api_log.duration_ms) AS avg_duration_ms," + + " max(api_log.duration_ms) AS max_duration_ms," + + " sum(api_log.request_count) AS total_requests," + + " count(*) AS request_count" + + " FROM api_log" + + " GROUP BY time_bucket('01:00:00'::interval, api_log.\"time\"), api_log.service_name"; + + private const string ViewDefWithWhere = + "SELECT time_bucket('1 day'::interval, readings.\"time\") AS bucket," + + " avg(readings.value) AS avg_value" + + " FROM readings" + + " WHERE readings.device_id = 'sensor-1'" + + " GROUP BY time_bucket('1 day'::interval, readings.\"time\")"; + + // ── ParseTimeBucketWidth ──────────────────────────────────────────────── + + #region ParseTimeBucketWidth_Extracts_Interval + + [Fact] + public void ParseTimeBucketWidth_Extracts_Interval() + { + string? result = ViewDefinitionParser.ParseTimeBucketWidth(FullViewDef); + + Assert.Equal("01:00:00", result); + } + + #endregion + + #region ParseTimeBucketWidth_ReturnsNull_WhenNoTimeBucket + + [Fact] + public void ParseTimeBucketWidth_ReturnsNull_WhenNoTimeBucket() + { + string? result = ViewDefinitionParser.ParseTimeBucketWidth("SELECT 1 AS x FROM y"); + + Assert.Null(result); + } + + #endregion + + #region ParseTimeBucketWidth_Extracts_DayInterval + + [Fact] + public void ParseTimeBucketWidth_Extracts_DayInterval() + { + string? result = ViewDefinitionParser.ParseTimeBucketWidth(ViewDefWithWhere); + + Assert.Equal("1 day", result); + } + + #endregion + + // ── ParseTimeBucketSourceColumn ───────────────────────────────────────── + + #region ParseTimeBucketSourceColumn_Extracts_Column + + [Fact] + public void ParseTimeBucketSourceColumn_Extracts_Column() + { + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(FullViewDef); + + // Should strip table qualifier (api_log.) and quotes from "time" + Assert.Equal("time", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_ReturnsNull_WhenNoTimeBucket + + [Fact] + public void ParseTimeBucketSourceColumn_ReturnsNull_WhenNoTimeBucket() + { + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn("SELECT avg(x) AS y FROM t GROUP BY t.z"); + + Assert.Null(result); + } + + #endregion + + // ── ParseAggregates ───────────────────────────────────────────────────── + + #region ParseAggregates_Extracts_CommonFunctions + + [Fact] + public void ParseAggregates_Extracts_CommonFunctions() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FullViewDef); + + Assert.Contains(result, a => a.Function == EAggregateFunction.Avg && a.Alias == "avg_duration_ms"); + Assert.Contains(result, a => a.Function == EAggregateFunction.Max && a.Alias == "max_duration_ms"); + Assert.Contains(result, a => a.Function == EAggregateFunction.Sum && a.Alias == "total_requests"); + } + + #endregion + + #region ParseAggregates_CountStar_ProducesWildcardSourceColumn + + [Fact] + public void ParseAggregates_CountStar_ProducesWildcardSourceColumn() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FullViewDef); + + ViewDefinitionParser.ParsedAggregate? countAgg = result.FirstOrDefault(a => a.Function == EAggregateFunction.Count); + Assert.NotNull(countAgg); + Assert.Equal("request_count", countAgg.Alias); + Assert.Equal("*", countAgg.SourceColumn); + } + + #endregion + + #region ParseAggregates_StripsTableQualifier + + [Fact] + public void ParseAggregates_StripsTableQualifier() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FullViewDef); + + // api_log.duration_ms → duration_ms + Assert.All(result.Where(a => a.SourceColumn != "*"), a => + Assert.DoesNotContain(".", a.SourceColumn)); + } + + #endregion + + #region ParseAggregates_EmptyList_WhenNoAggregates + + [Fact] + public void ParseAggregates_EmptyList_WhenNoAggregates() + { + IReadOnlyList result = + ViewDefinitionParser.ParseAggregates("SELECT time_bucket('1h'::interval, t.ts) AS b FROM t GROUP BY 1"); + + Assert.Empty(result); + } + + #endregion + + // ── ParseGroupByColumns ───────────────────────────────────────────────── + + #region ParseGroupByColumns_ExcludesTimeBucket + + [Fact] + public void ParseGroupByColumns_ExcludesTimeBucket() + { + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(FullViewDef); + Assert.DoesNotContain(result, c => c.StartsWith("time_bucket", StringComparison.OrdinalIgnoreCase)); + Assert.Contains("service_name", result); + } + + #endregion + + #region ParseGroupByColumns_StripsTableQualifier + + [Fact] + public void ParseGroupByColumns_StripsTableQualifier() + { + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(FullViewDef); + + Assert.All(result, c => Assert.DoesNotContain(".", c)); + } + + #endregion + + #region ParseGroupByColumns_SkipsPositionalReferences + + [Fact] + public void ParseGroupByColumns_SkipsPositionalReferences() + { + string viewDef = + "SELECT time_bucket('1h'::interval, t.ts) AS b, t.zone AS zone" + + " FROM t GROUP BY 1, t.zone"; + + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(viewDef); + + Assert.DoesNotContain(result, c => int.TryParse(c, out _)); + Assert.Contains("zone", result); + } + + #endregion + + #region ParseGroupByColumns_EmptyList_WhenNoGroupBy + + [Fact] + public void ParseGroupByColumns_EmptyList_WhenNoGroupBy() + { + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns("SELECT avg(x) AS y FROM t"); + + Assert.Empty(result); + } + + #endregion + + #region ParseGroupByColumns_StripsTrailingSemicolon + + [Fact] + public void ParseGroupByColumns_StripsTrailingSemicolon() + { + string viewDef = + "SELECT time_bucket('1h'::interval, t.ts) AS b, t.region AS region" + + " FROM t GROUP BY time_bucket('1h'::interval, t.ts), t.region;"; + + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(viewDef); + + Assert.Single(result); + Assert.Equal("region", result[0]); + } + + #endregion + + // ── ParseWhereClause ──────────────────────────────────────────────────── + + #region ParseWhereClause_Extracts_Content + + [Fact] + public void ParseWhereClause_Extracts_Content() + { + string? result = ViewDefinitionParser.ParseWhereClause(ViewDefWithWhere); + + Assert.NotNull(result); + Assert.Contains("device_id", result); + Assert.DoesNotContain("GROUP BY", result, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region ParseWhereClause_ReturnsNull_WhenAbsent + + [Fact] + public void ParseWhereClause_ReturnsNull_WhenAbsent() + { + string? result = ViewDefinitionParser.ParseWhereClause(FullViewDef); + + Assert.Null(result); + } + + #endregion + + // ── ParseAggregates — first/last (finalize_agg internal form) ────────── + + private const string FinalizeAggFirst = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " _timescaledb_internal.finalize_agg('first(double precision,timestamp with time zone)'::text," + + " NULL::name, NULL::name, x.agg_1_1, x.\"time\") AS first_temperature" + + " FROM t GROUP BY 1"; + + private const string FinalizeAggLast = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " _timescaledb_internal.finalize_agg('last(double precision,timestamp with time zone)'::text," + + " NULL::name, NULL::name, x.agg_2_2, x.\"time\") AS last_temperature" + + " FROM t GROUP BY 1"; + + #region ParseAggregates_FinalizeAgg_First_Produces_FirstFunction + + [Fact] + public void ParseAggregates_FinalizeAgg_First_Produces_FirstFunction() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FinalizeAggFirst); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "first_temperature"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.First, agg.Function); + } + + #endregion + + #region ParseAggregates_FinalizeAgg_Last_Produces_LastFunction + + [Fact] + public void ParseAggregates_FinalizeAgg_Last_Produces_LastFunction() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FinalizeAggLast); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "last_temperature"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Last, agg.Function); + } + + #endregion + + #region ParseAggregates_FinalizeAgg_StripsPrefix_FromSourceColumn + + [Fact] + public void ParseAggregates_FinalizeAgg_StripsPrefix_FromSourceColumn() + { + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FinalizeAggFirst); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "first_temperature"); + Assert.NotNull(agg); + Assert.Equal("temperature", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_FinalizeAgg_FallsBackToAlias_WhenNoPrefix + + [Fact] + public void ParseAggregates_FinalizeAgg_FallsBackToAlias_WhenNoPrefix() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " _timescaledb_internal.finalize_agg('first(double precision,timestamp with time zone)'::text," + + " NULL::name, NULL::name, x.agg_1_1, x.\"time\") AS opening_price" + + " FROM t GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "opening_price"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.First, agg.Function); + Assert.Equal("opening_price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_Mixed_StandardAndFinalizeAgg_BothExtracted + + [Fact] + public void ParseAggregates_Mixed_StandardAndFinalizeAgg_BothExtracted() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " avg(t.value) AS avg_value," + + " _timescaledb_internal.finalize_agg('first(double precision,timestamp with time zone)'::text," + + " NULL::name, NULL::name, x.agg_1_1, x.\"time\") AS first_value" + + " FROM t GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + Assert.Contains(result, a => a.Function == EAggregateFunction.Avg && a.Alias == "avg_value"); + Assert.Contains(result, a => a.Function == EAggregateFunction.First && a.Alias == "first_value"); + } + + #endregion + + // ── ParseTimeBucketSourceColumn — quoted qualifiers and 3-arg forms ───── + + #region ParseTimeBucketSourceColumn_QuotedTableQualifier + + [Fact] + public void ParseTimeBucketSourceColumn_QuotedTableQualifier() + { + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, \"DeviceReadings\".\"Timestamp\") AS bucket," + + " avg(\"DeviceReadings\".\"Value\") AS avg_value" + + " FROM \"DeviceReadings\" GROUP BY 1"; + + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(viewDef); + + Assert.Equal("Timestamp", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_SchemaQualified + + [Fact] + public void ParseTimeBucketSourceColumn_SchemaQualified() + { + const string viewDef = + "SELECT time_bucket('1 day'::interval, public.\"Readings\".\"Time\") AS bucket" + + " FROM public.\"Readings\" GROUP BY 1"; + + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(viewDef); + + Assert.Equal("Time", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_ThreeArg_Timezone + + [Fact] + public void ParseTimeBucketSourceColumn_ThreeArg_Timezone() + { + // TimescaleDB >= 2.8 supports time_bucket(width, ts, timezone). + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, t.\"time\", 'Europe/Berlin'::text) AS bucket" + + " FROM t GROUP BY 1"; + + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(viewDef); + + Assert.Equal("time", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_ThreeArg_Origin + + [Fact] + public void ParseTimeBucketSourceColumn_ThreeArg_Origin() + { + const string viewDef = + "SELECT time_bucket('1 week'::interval, t.ts, origin => '2000-01-03 00:00:00'::timestamp without time zone) AS bucket" + + " FROM t GROUP BY 1"; + + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(viewDef); + + Assert.Equal("ts", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_MultiWordCast + + [Fact] + public void ParseTimeBucketSourceColumn_MultiWordCast() + { + const string viewDef = + "SELECT time_bucket('1 day'::interval, t.ts::timestamp with time zone) AS bucket" + + " FROM t GROUP BY 1"; + + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(viewDef); + + Assert.Equal("ts", result); + } + + #endregion + + // ── ParseAggregates — quoted aliases and plain first/last ─────────────── + + #region ParseAggregates_QuotedAlias_And_QuotedColumn + + [Fact] + public void ParseAggregates_QuotedAlias_And_QuotedColumn() + { + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, \"Readings\".\"Time\") AS \"Bucket\"," + + " avg(\"Readings\".\"Temperature\") AS \"AvgTemperature\"" + + " FROM \"Readings\" GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "AvgTemperature"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Avg, agg.Function); + Assert.Equal("Temperature", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_PlainFirst_UsesFirstArgumentAsSource + + [Fact] + public void ParseAggregates_PlainFirst_UsesFirstArgumentAsSource() + { + // Finalized continuous aggregates (TimescaleDB >= 2.7, the default) store first/last in plain form. + const string viewDef = + "SELECT time_bucket('1 day'::interval, t.\"time\") AS bucket," + + " first(t.temperature, t.\"time\") AS first_temperature" + + " FROM t GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "first_temperature"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.First, agg.Function); + Assert.Equal("temperature", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_PlainLast_QuotedArgumentsAndAlias + + [Fact] + public void ParseAggregates_PlainLast_QuotedArgumentsAndAlias() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, \"Trades\".\"Timestamp\") AS \"Bucket\"," + + " last(\"Trades\".\"Price\", \"Trades\".\"Timestamp\") AS \"LastPrice\"" + + " FROM \"Trades\" GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "LastPrice"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Last, agg.Function); + Assert.Equal("Price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_FinalizeAgg_SkippedWhenExactParseCoversAlias + + [Fact] + public void ParseAggregates_FinalizeAgg_SkippedWhenExactParseCoversAlias() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " first(t.temperature, t.\"time\") AS first_temp," + + " _timescaledb_internal.finalize_agg('first(double precision,timestamp with time zone)'::text," + + " NULL::name, NULL::name, x.agg_1_1, x.\"time\") AS first_temp" + + " FROM t GROUP BY 1"; + + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + ViewDefinitionParser.ParsedAggregate single = Assert.Single(result, a => a.Alias == "first_temp"); + Assert.Equal("temperature", single.SourceColumn); + } + + #endregion + + #region ParseGroupByColumns_PreservesRawExpressions + + [Fact] + public void ParseGroupByColumns_PreservesRawExpressions() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket, avg(t.v) AS avg_v" + + " FROM t GROUP BY time_bucket('1 hour'::interval, t.\"time\"), EXTRACT(HOUR FROM t.\"time\"), t.region"; + + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(viewDef); + + Assert.Contains("EXTRACT(HOUR FROM t.\"time\")", result); + Assert.Contains("region", result); + } + + #endregion + + // ── Edge cases ───────────────────────────────────────────────────────── + + #region ParseAggregates_UnknownFunctionName_Skipped + + [Fact] + public void ParseAggregates_UnknownFunctionName_Skipped() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " array_agg(t.value) AS values_array," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + // Assert + Assert.DoesNotContain(result, a => a.Alias == "values_array"); + Assert.Contains(result, a => a.Alias == "avg_value" && a.Function == EAggregateFunction.Avg); + } + + #endregion + + #region ParseAggregates_First_With_Empty_FirstArg_Skipped + + [Fact] + public void ParseAggregates_First_With_Empty_FirstArg_Skipped() + { + const string viewDef = + "SELECT time_bucket('1 hour'::interval, t.\"time\") AS bucket," + + " first(,t.\"time\") AS bad_first" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(viewDef); + + // Assert + Assert.DoesNotContain(result, a => a.Alias == "bad_first"); + } + + #endregion + + // ── Parse (memoized full result) ──────────────────────────────────────── + + #region Parse_Returns_CompleteResult_And_Memoizes + + [Fact] + public void Parse_Returns_CompleteResult_And_Memoizes() + { + ViewDefinitionParser.ParsedViewDefinition first = ViewDefinitionParser.Parse(FullViewDef); + ViewDefinitionParser.ParsedViewDefinition second = ViewDefinitionParser.Parse(FullViewDef); + + Assert.Equal("01:00:00", first.TimeBucketWidth); + Assert.Equal("time", first.TimeBucketSourceColumn); + Assert.Contains(first.Aggregates, a => a.Function == EAggregateFunction.Count && a.SourceColumn == "*"); + Assert.Contains("service_name", first.GroupByColumns); + Assert.Null(first.WhereClause); + Assert.Same(first, second); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index 9f90105..cfd7da9 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -1652,4 +1652,80 @@ public void Should_Detect_ChunkSkipColumns_Removed() } #endregion + + #region Should_Detect_AdditionalDimensions_ColumnName_Changed + + private class MetricEntity23 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + public int WarehouseId { get; set; } + } + + private class DimensionColumnOldContext23 : 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("dim_colname_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + private class DimensionColumnNewContext23 : 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("dim_colname_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("WarehouseId", 4)); + }); + } + } + + [Fact] + public void Should_Detect_AdditionalDimensions_ColumnName_Changed() + { + // Arrange + using DimensionColumnOldContext23 sourceContext = new(); + using DimensionColumnNewContext23 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.NotNull(alterOp.AdditionalDimensions); + Assert.Equal("DeviceId", alterOp.OldAdditionalDimensions![0].ColumnName); + Assert.Equal("WarehouseId", alterOp.AdditionalDimensions![0].ColumnName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs index 5da7775..2b42909 100644 --- a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs +++ b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs @@ -1,7 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore; -using Npgsql; using NpgsqlTypes; using Testcontainers.PostgreSql; diff --git a/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs new file mode 100644 index 0000000..b15f425 --- /dev/null +++ b/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs @@ -0,0 +1,263 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extensions; + +/// +/// Tests for TimescaleDbContextOptionsBuilderExtensions. +/// +public class TimescaleDbContextOptionsBuilderExtensionsTests +{ + // ── Generic overload ────────────────────────────────────────────────────── + + #region Should_Return_Typed_Builder_From_Generic_UseTimescaleDb + + private class GenericOverloadEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class GenericOverloadContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { } + } + + [Fact] + public void Should_Return_Typed_Builder_From_Generic_UseTimescaleDb() + { + // Arrange + DbContextOptionsBuilder typedBuilder = new(); + typedBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + DbContextOptionsBuilder returned = typedBuilder.UseTimescaleDb(); + + // Assert + Assert.Same(typedBuilder, returned); + } + + #endregion + + #region Should_Register_TimescaleDb_Extension_Via_Generic_UseTimescaleDb + + private class ExtensionRegistrationEntity + { + public DateTime Timestamp { get; set; } + } + + private class ExtensionRegistrationContext : DbContext + { + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + } + + [Fact] + public void Should_Register_TimescaleDb_Extension_Via_Generic_UseTimescaleDb() + { + // Arrange + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + builder.UseTimescaleDb(); + + // Assert + Dictionary debugInfo = []; + foreach (IDbContextOptionsExtension ext in builder.Options.Extensions) + { + ext.Info.PopulateDebugInfo(debugInfo); + } + + Assert.True(debugInfo.ContainsKey("TimescaleDB:Enabled")); + Assert.Equal("True", debugInfo["TimescaleDB:Enabled"]); + } + + #endregion + + // ── ExtensionInfo.PopulateDebugInfo ─────────────────────────────────────── + + #region Should_PopulateDebugInfo_With_Enabled_True + + private class DebugInfoEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DebugInfoContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + } + + [Fact] + public void Should_PopulateDebugInfo_With_Enabled_True() + { + // Arrange + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + builder.UseTimescaleDb(); + Dictionary debugInfo = []; + + // Act + foreach (IDbContextOptionsExtension ext in builder.Options.Extensions) + { + ext.Info.PopulateDebugInfo(debugInfo); + } + + // Assert + Assert.True(debugInfo.TryGetValue("TimescaleDB:Enabled", out string? value)); + Assert.Equal("True", value); + } + + #endregion + + // ── Service registration integration ───────────────────────────────────── + + #region Should_Register_TimescaleMigrationsModelDiffer + + private class ServiceRegMigrateEntity + { + public DateTime Timestamp { get; set; } + } + + private class ServiceRegMigrateContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("svc_reg_migrate"); + e.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Register_TimescaleMigrationsModelDiffer() + { + // Arrange & Act + using ServiceRegMigrateContext context = new(); + IMigrationsModelDiffer differ = context.GetService(); + + // Assert + Assert.IsType(differ); + } + + #endregion + + #region Should_Register_TimescaleDbMigrationsSqlGenerator + + private class ServiceRegSqlGenEntity + { + public DateTime Timestamp { get; set; } + } + + private class ServiceRegSqlGenContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("svc_reg_sqlgen"); + e.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Register_TimescaleDbMigrationsSqlGenerator() + { + // Arrange & Act + using ServiceRegSqlGenContext context = new(); + + IMigrationsSqlGenerator generator = context.GetService(); + + // Assert + Assert.IsType(generator); + } + + #endregion + + #region Should_Support_Multiple_UseTimescaleDb_Calls_Idempotently + + private class IdempotentEntity + { + public DateTime Timestamp { get; set; } + } + + private class IdempotentContext : DbContext + { + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToTable("idempotent_entity"); + e.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Support_Multiple_UseTimescaleDb_Calls_Idempotently() + { + // Arrange + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + builder.UseTimescaleDb(); + builder.UseTimescaleDb(); + + // Assert + Dictionary debugInfo = []; + foreach (IDbContextOptionsExtension ext in builder.Options.Extensions) + { + ext.Info.PopulateDebugInfo(debugInfo); + } + + Assert.Equal("True", debugInfo["TimescaleDB:Enabled"]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index 9dbf883..0e25aa1 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -1497,4 +1497,91 @@ public void Should_Extract_AdditionalDimensions_When_Json_Holds_Column_Names_Und } #endregion + + #region Should_Skip_Entity_When_TimeColumn_Annotation_Is_Missing + + private class NoTimeColumnAnnotationMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoTimeColumnAnnotationContext : 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.HasNoKey(); + entity.ToTable("no_time_col_ann_metrics"); + entity.HasAnnotation(HypertableAnnotations.IsHypertable, true); + }); + } + } + + [Fact] + public void Should_Skip_Entity_When_TimeColumn_Annotation_Is_Missing() + { + // Arrange + using NoTimeColumnAnnotationContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + Assert.Empty(operations); + } + + #endregion + + #region Should_Skip_Entity_When_TimeColumn_Cannot_Be_Resolved + + private class UnresolvableColumnMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class UnresolvableColumnContext : 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.HasNoKey(); + entity.ToTable("unresolvable_col_metrics"); + entity.HasAnnotation(HypertableAnnotations.IsHypertable, true); + entity.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "ghost_column"); + }); + } + } + + [Fact] + public void Should_Skip_Entity_When_TimeColumn_Cannot_Be_Resolved() + { + // Arrange + using UnresolvableColumnContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + Assert.Empty(operations); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs index c6c5215..7c3b08c 100644 --- a/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ReorderPolicyModelExtractorTests.cs @@ -815,4 +815,47 @@ public void Should_Extract_Custom_Schema() } #endregion + + #region Should_Skip_Entity_When_IndexName_Is_Missing + + private class MissingIndexNameMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MissingIndexNameContext : 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.HasNoKey(); + entity.ToTable("missing_idx_name_metrics"); + entity.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + }); + } + } + + [Fact] + public void Should_Skip_Entity_When_IndexName_Is_Missing() + { + // Arrange + using MissingIndexNameContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. ReorderPolicyModelExtractor.GetReorderPolicies(relationalModel)]; + + // Assert + Assert.Empty(operations); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index f80f7d2..76d5b6c 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -1,7 +1,6 @@ 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 { diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index 6903b82..2efab4a 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.EntityFrameworkCore.Migrations.Operations; using Moq; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators @@ -781,6 +782,34 @@ public void Generate_DropRetentionPolicy_GeneratesValidCSharp() #endregion + #region Generate_StandardCreateTableOperation_Falls_Through_To_Base + + [Fact] + public void Generate_StandardCreateTableOperation_Falls_Through_To_Base() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateTableOperation operation = new() + { + Name = "standard_table", + Schema = "public", + Columns = { new AddColumnOperation { Name = "id", Schema = "public", Table = "standard_table", ClrType = typeof(int) } } + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".CreateTable(", result); + } + + #endregion + #region Helper Methods private static CSharpMigrationOperationGeneratorDependencies CreateDependencies() diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs index 88590f9..fbfe3e1 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -381,7 +381,8 @@ public async Task Should_Extract_ChunkInterval() (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hourly_metrics_chunk")]; Assert.NotNull(info.ChunkInterval); - Assert.Contains("24:00:00", info.ChunkInterval); + Assert.True(IntervalParsingHelper.TryGetTotalMicroseconds(info.ChunkInterval, out long chunkMicroseconds)); + Assert.Equal(86_400_000_000L, chunkMicroseconds); } #endregion @@ -561,4 +562,80 @@ public async Task Should_Extract_Multiple_ContinuousAggregates() } #endregion + + #region Should_Extract_MaterializedOnly_True_From_ContinuousAggregate + + private class MaterializedOnlySourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnlyAggMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyAggregateContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("mat_only_source"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("mat_only_hourly"); + entity.IsContinuousAggregate( + "mat_only_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ).MaterializedOnly(true); + }); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_MaterializedOnly_True_From_ContinuousAggregate() + { + // Arrange + string testConnectionString = await GetTestConnectionStringAsync(); + await using MaterializedOnlyAggregateContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "mat_only_hourly")), + "Expected 'mat_only_hourly' in extracted results"); + + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "mat_only_hourly")]; + + Assert.True(info.MaterializedOnly, + "Expected MaterializedOnly=true but extractor returned false"); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 10a54c9..9ebf873 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -1184,4 +1184,55 @@ public async Task Should_Humanize_ChunkTimeInterval_NonRound_Falls_Back_To_Secon } #endregion + + #region Should_Extract_BigInt_ChunkTimeInterval_For_Integer_TimeColumn + + private class BigIntTimeMetric + { + public long EventTime { get; set; } + public double Value { get; set; } + } + + private class BigIntHypertableContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("bigint_time_metrics"); + entity.IsHypertable(x => x.EventTime).WithChunkTimeInterval("86400000000"); + }); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_BigInt_ChunkTimeInterval_For_Integer_TimeColumn() + { + // Arrange + await using BigIntHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "bigint_time_metrics"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "bigint_time_metrics")]; + + Assert.True(long.TryParse(info.ChunkTimeInterval, out _), + $"Expected an integer chunk interval; got '{info.ChunkTimeInterval}'"); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/MigrationTestBase.cs b/tests/Eftdb.Tests/Integration/MigrationTestBase.cs index 6aebb57..3ab0187 100644 --- a/tests/Eftdb.Tests/Integration/MigrationTestBase.cs +++ b/tests/Eftdb.Tests/Integration/MigrationTestBase.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs index ae63769..afc24d1 100644 --- a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs @@ -1,10 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Migrations.Operations; using Npgsql; using Testcontainers.PostgreSql; diff --git a/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs index f32a6c8..1d7f3ae 100644 --- a/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs +++ b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs @@ -1,4 +1,3 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; diff --git a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs index 51cd50b..b5d3421 100644 --- a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs @@ -688,4 +688,121 @@ public void Should_Handle_Special_Characters_In_Column_Names() } #endregion + + // ── Auto-created index suppression ────────────────────────────────────── + + private static HypertableInfo MinimalInfo(string timeColumn = "time", List? dimensions = null) => new( + TimeColumnName: timeColumn, + ChunkTimeInterval: "604800000000", + CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], + ChunkSkipColumns: [], + AdditionalDimensions: dimensions ?? [] + ); + + private static DatabaseIndex AddIndex(DatabaseTable table, string name, bool isUnique, params string[] columns) + { + DatabaseIndex index = new() { Name = name, Table = table, IsUnique = isUnique }; + foreach (string column in columns) + { + index.Columns.Add(new DatabaseColumn { Name = column, Table = table, StoreType = "timestamptz" }); + } + + table.Indexes.Add(index); + return index; + } + + #region Should_Remove_AutoCreated_Time_Index + + [Fact] + public void Should_Remove_AutoCreated_Time_Index() + { + // Arrange + DatabaseTable table = CreateTable("weather_data"); + AddIndex(table, "weather_data_time_idx", isUnique: false, "time"); + + // Act + _applier.ApplyAnnotations(table, MinimalInfo()); + + // Assert + Assert.Empty(table.Indexes); + } + + #endregion + + #region Should_Remove_AutoCreated_Dimension_Index + + [Fact] + public void Should_Remove_AutoCreated_Dimension_Index() + { + // Arrange + DatabaseTable table = CreateTable("order_events"); + AddIndex(table, "order_events_time_idx", isUnique: false, "time"); + AddIndex(table, "order_events_region_time_idx", isUnique: false, "region", "time"); + HypertableInfo info = MinimalInfo(dimensions: + [new Dimension { ColumnName = "region", Type = EDimensionType.Hash, NumberOfPartitions = 4 }]); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Empty(table.Indexes); + } + + #endregion + + #region Should_Keep_UserDefined_Index_On_Time_Column + + [Fact] + public void Should_Keep_UserDefined_Index_On_Time_Column() + { + // Arrange + DatabaseTable table = CreateTable("weather_data"); + AddIndex(table, "ix_weather_by_time", isUnique: false, "time"); + + // Act + _applier.ApplyAnnotations(table, MinimalInfo()); + + // Assert + Assert.Single(table.Indexes); + } + + #endregion + + #region Should_Keep_Unique_Index_Even_When_Name_Matches + + [Fact] + public void Should_Keep_Unique_Index_Even_When_Name_Matches() + { + // Arrange + DatabaseTable table = CreateTable("weather_data"); + AddIndex(table, "weather_data_time_idx", isUnique: true, "time"); + + // Act + _applier.ApplyAnnotations(table, MinimalInfo()); + + // Assert + Assert.Single(table.Indexes); + } + + #endregion + + #region Should_Keep_Index_When_Columns_Do_Not_Match_Pattern + + [Fact] + public void Should_Keep_Index_When_Columns_Do_Not_Match_Pattern() + { + // Arrange + DatabaseTable table = CreateTable("weather_data"); + AddIndex(table, "weather_data_time_idx", isUnique: false, "time", "station_id"); + + // Act + _applier.ApplyAnnotations(table, MinimalInfo()); + + // Assert + Assert.Single(table.Indexes); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs b/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs index e014e66..10a3c11 100644 --- a/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs @@ -362,6 +362,41 @@ public void Should_Convert_TimeSpan_To_Days(string input, string expected) #endregion + #region Should_Convert_Oversize_Hours_To_Days + + [Theory] + [InlineData("24:00:00", "1 day")] + [InlineData("48:00:00", "2 days")] + [InlineData("168:00:00", "7 days")] + [InlineData("240:00:00", "10 days")] + [InlineData("720:00:00", "30 days")] + public void Should_Convert_Oversize_Hours_To_Days(string input, string expected) + { + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + #region Should_Convert_Oversize_Non_Day_Aligned_Hours + + [Theory] + [InlineData("25:00:00", "25 hours")] + [InlineData("36:00:00", "36 hours")] + public void Should_Convert_Oversize_Non_Day_Aligned_Hours(string input, string expected) + { + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + #region Should_Use_Singular_For_One_Unit [Theory] @@ -442,9 +477,7 @@ public void Should_Handle_TimeSpan_With_Seconds_Only() string result = IntervalParsingHelper.NormalizeInterval(input); // Assert - // When timespan has only seconds (no minutes or hours), it doesn't match any condition - // so it should return the normalized string (with "mon" replaced if any, otherwise trimmed original) - Assert.Equal("00:00:30", result); + Assert.Equal("30 seconds", result); } #endregion @@ -461,8 +494,7 @@ public void Should_Handle_TimeSpan_With_Hours_And_Minutes() string result = IntervalParsingHelper.NormalizeInterval(input); // Assert - // When timespan has both hours and minutes, hours take precedence if totalHours < 24 - Assert.Equal("1 hour", result); + Assert.Equal("90 minutes", result); } #endregion @@ -496,8 +528,7 @@ public void Should_Handle_Zero_Minutes_With_NonZero_Seconds() string result = IntervalParsingHelper.NormalizeInterval(input); // Assert - // Minutes is 0, so the minutes condition won't match - Assert.Equal("00:00:45", result); + Assert.Equal("45 seconds", result); } #endregion @@ -532,11 +563,210 @@ public void Should_Handle_Mixed_TimeSpan_With_Days_Hours_Minutes() string result = IntervalParsingHelper.NormalizeInterval(input); // Assert - // Days > 0, so it should return days only - Assert.Equal("1 day", result); + Assert.Equal("1770 minutes", result); } #endregion + #region Should_Keep_Fractional_Seconds_Raw + + [Theory] + [InlineData("48:00:00.5")] + [InlineData("168:00:00.5")] + [InlineData("01:00:00.000001")] + public void Should_Keep_Fractional_Seconds_Raw(string input) + { + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal(input, result); + } + + #endregion + + #region Should_Humanize_When_Fraction_Is_Zero + + [Fact] + public void Should_Humanize_When_Fraction_Is_Zero() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("48:00:00.000000"); + + // Assert + Assert.Equal("2 days", result); + } + + #endregion + + #region Should_Expand_Mons_TokenSafe + + [Theory] + [InlineData("2 mons", "2 months")] + [InlineData("1 mon", "1 month")] + [InlineData("1 month", "1 month")] + [InlineData("6 months", "6 months")] + public void Should_Expand_Mons_TokenSafe(string input, string expected) + { + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + #region Should_Keep_Composite_Interval_Raw + + [Fact] + public void Should_Keep_Composite_Interval_Raw() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("2 days 03:00:00"); + + // Assert + Assert.Equal("2 days 03:00:00", result); + } + + #endregion + + // ── TryGetTotalMicroseconds ───────────────────────────────────────────── + + #region TryGetTotalMicroseconds_Parses_FixedDuration_Units + + [Theory] + [InlineData("1 day", 86_400_000_000L)] + [InlineData("7 days", 604_800_000_000L)] + [InlineData("30 minutes", 1_800_000_000L)] + [InlineData("1 hour", 3_600_000_000L)] + [InlineData("2 weeks", 1_209_600_000_000L)] + [InlineData("500 milliseconds", 500_000L)] + [InlineData("10 seconds", 10_000_000L)] + public void TryGetTotalMicroseconds_Parses_FixedDuration_Units(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_TimeOfDay_Form + + [Theory] + [InlineData("01:00:00", 3_600_000_000L)] + [InlineData("168:00:00", 604_800_000_000L)] + [InlineData("00:00:30.5", 30_500_000L)] + [InlineData("1.00:00:00", 86_400_000_000L)] + public void TryGetTotalMicroseconds_Parses_TimeOfDay_Form(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Rejects_NonFixed_Durations + + [Theory] + [InlineData("1 month")] + [InlineData("2 years")] + [InlineData("2 days 03:00:00")] + [InlineData("12345")] + [InlineData("")] + [InlineData("garbage")] + public void TryGetTotalMicroseconds_Rejects_NonFixed_Durations(string input) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.False(success); + Assert.Equal(0L, result); + } + + #endregion + + #endregion + + // ── Additional edge cases ─────────────────────────────────────────────── + + #region NormalizeInterval_Returns_Unchanged_For_Zero_Time + + [Fact] + public void NormalizeInterval_Returns_Unchanged_For_Zero_Time() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("00:00:00"); + + // Assert + Assert.Equal("00:00:00", result); + } + + #endregion + + #region NormalizeInterval_Returns_Correct_Value_For_Negative_Day_Prefix + + [Fact] + public void NormalizeInterval_Returns_Correct_Value_For_Negative_Day_Prefix() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("-1.00:00:00"); + + // Assert — cannot be parsed; returned unchanged + Assert.Equal("-1.00:00:00", result); + } + + #endregion + + #region TryParseTimeParts_Returns_False_For_Minutes_Out_Of_Range + + [Fact] + public void TryParseTimeParts_Returns_False_For_Minutes_Out_Of_Range() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("01:75:00"); + + // Assert + Assert.Equal("01:75:00", result); + } + + #endregion + + #region TryParseTimeParts_Returns_False_For_Non_Digit_Fraction + + [Fact] + public void TryParseTimeParts_Returns_False_For_Non_Digit_Fraction() + { + // Act + string result = IntervalParsingHelper.NormalizeInterval("00:00:01.abc"); + + // Assert + Assert.Equal("00:00:01.abc", result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Week_Unit + + [Fact] + public void TryGetTotalMicroseconds_Parses_Week_Unit() + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds("2 weeks", out long microseconds); + + // Assert + Assert.True(success); + Assert.Equal(2L * 604_800L * 1_000_000L, microseconds); + } + #endregion } diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs index aa8ff9e..1422ef6 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs @@ -1992,4 +1992,339 @@ public void IsContinuousAggregate_Should_Accept_Custom_TimeColumn_Type() } #endregion + + #region GetPropertyName_Should_Handle_UnaryExpression_Cast + + private class CastExpressionMetricEntity + { + public DateTime Timestamp { get; set; } + public int Value { get; set; } + } + + private class CastExpressionAggEntity + { + public DateTime TimeBucket { get; set; } + public long LongValue { get; set; } + } + + private class CastExpressionContext : 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.HasNoKey(); + entity.ToTable("cast_expr_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cast_expr_hourly", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.LongValue, x => (object)x.Value, EAggregateFunction.Sum); + }); + } + } + + [Fact] + public void GetPropertyName_Should_Handle_UnaryExpression_Cast() + { + // Arrange + using CastExpressionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CastExpressionAggEntity))!; + + // Act + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + + // Assert + Assert.NotNull(aggregateFunctions); + Assert.Single(aggregateFunctions); + Assert.Contains("LongValue:Sum:Value", aggregateFunctions); + } + + #endregion + + #region GetPropertyName_Should_Throw_For_Non_Member_Expression + + private class ThrowNonMemberMetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ThrowNonMemberAggEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class ThrowNonMemberContext : 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.HasNoKey(); + entity.ToTable("throw_non_member_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "throw_non_member_hourly", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => (double)(object)42, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void GetPropertyName_Should_Throw_For_Non_Member_Expression() + { + // Arrange & Act + ArgumentException exception = Assert.Throws(() => + { + using ThrowNonMemberContext context = new(); + _ = GetModel(context); + }); + + // Assert + Assert.Contains("simple property access expression", exception.Message); + } + + #endregion + + #region StringBuilder_WithChunkInterval_Should_Set_ChunkInterval_Annotation + + private class StringBuilder_WithChunkInterval_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilder_WithChunkInterval_AggEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilder_WithChunkInterval_Context : 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("sb_chunk_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_chunk_hourly", "StringBuilder_WithChunkInterval_MetricEntity", "1 hour", "Timestamp") + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .WithChunkInterval("30 days"); + }); + } + } + + [Fact] + public void StringBuilder_WithChunkInterval_Should_Set_ChunkInterval_Annotation() + { + // Arrange + using StringBuilder_WithChunkInterval_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilder_WithChunkInterval_AggEntity))!; + + // Act + object? annotationValue = entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value; + + // Assert + Assert.Equal("30 days", annotationValue); + } + + #endregion + + #region StringBuilder_WithNoData_Should_Set_WithNoData_Annotation + + private class StringBuilder_WithNoData_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilder_WithNoData_AggEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilder_WithNoData_Context : 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("sb_nodata_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_nodata_hourly", "StringBuilder_WithNoData_MetricEntity", "1 hour", "Timestamp") + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .WithNoData(true); + }); + } + } + + [Fact] + public void StringBuilder_WithNoData_Should_Set_WithNoData_Annotation() + { + // Arrange + using StringBuilder_WithNoData_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilder_WithNoData_AggEntity))!; + + // Act + object? annotationValue = entityType.FindAnnotation(ContinuousAggregateAnnotations.WithNoData)?.Value; + + // Assert + Assert.Equal(true, annotationValue); + } + + #endregion + + #region GenericBuilder_And_StringBuilder_Should_Produce_Identical_Annotations + + private class BuilderParity_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string Region { get; set; } = null!; + } + + private class BuilderParity_GenericAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BuilderParity_StringAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BuilderParity_Context : DbContext + { + public DbSet Metrics => Set(); + public DbSet GenericAggregates => Set(); + public DbSet StringAggregates => 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("builder_parity_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "builder_parity_generic", "1 hour", x => x.Timestamp) + .WithNoData() + .CreateGroupIndexes(false) + .MaterializedOnly() + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.Region) + .Where("value > 0"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("builder_parity_string", "BuilderParity_MetricEntity", "1 hour", "Timestamp") + .WithNoData() + .CreateGroupIndexes(false) + .MaterializedOnly() + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .AddGroupByColumn("Region") + .Where("value > 0"); + }); + } + } + + [Fact] + public void GenericBuilder_And_StringBuilder_Should_Produce_Identical_Annotations() + { + using BuilderParity_Context context = new(); + IModel model = GetModel(context); + IEntityType genericEntity = model.FindEntityType(typeof(BuilderParity_GenericAggregate))!; + IEntityType stringEntity = model.FindEntityType(typeof(BuilderParity_StringAggregate))!; + + string[] sharedAnnotations = + [ + ContinuousAggregateAnnotations.WithNoData, + ContinuousAggregateAnnotations.CreateGroupIndexes, + ContinuousAggregateAnnotations.MaterializedOnly, + ContinuousAggregateAnnotations.WhereClause, + ]; + + foreach (string annotation in sharedAnnotations) + { + Assert.Equal(genericEntity.FindAnnotation(annotation)?.Value, stringEntity.FindAnnotation(annotation)?.Value); + } + + Assert.Equal( + genericEntity.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List, + stringEntity.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List); + Assert.Equal( + genericEntity.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List, + stringEntity.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List); + } + + #endregion } diff --git a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs index d244469..e0743f0 100644 --- a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -1440,6 +1440,57 @@ public void HasHashDimension_Should_Add_Hash_Dimension_To_AdditionalDimensions_A #endregion + #region WithCompressionOrderBy_ParamsFuncSelector_Should_Set_Annotation_With_Direction_Suffixes + + private class ParamsFuncSelectorOrderByEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class ParamsFuncSelectorOrderByContext : 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.HasNoKey(); + entity.ToTable("params_func_selector_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy( + s => s.ByDescending(x => x.Timestamp), + s => s.ByAscending(x => x.DeviceId)); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_ParamsFuncSelector_Should_Set_Annotation_With_Direction_Suffixes() + { + // Arrange + using ParamsFuncSelectorOrderByContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ParamsFuncSelectorOrderByEntity))!; + + // Act + string? annotationValue = entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + + // Assert + Assert.NotNull(annotationValue); + Assert.Contains("Timestamp DESC", annotationValue); + Assert.Contains("DeviceId ASC", annotationValue); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + #region HasRangeDimension_And_HasHashDimension_Can_Be_Combined private class CombinedDimensionEntity From e3a6b1aea636006bbc23ca841bf55a182855c8e6 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 21 Jul 2026 13:53:37 +0200 Subject: [PATCH 06/12] feat: scaffold continuous aggregate refresh policies to typed fluent API and data annotations --- .claude/reference/architecture.md | 17 +- .claude/reference/file-organization.md | 10 +- samples/Eftdb.Samples.DatabaseFirst/README.md | 2 +- ...inuousAggregatePolicyAnnotationRenderer.cs | 200 +++++ .../PolicyJobRendererHelper.cs | 96 +++ .../TimescaleDbAnnotationCodeGenerator.cs | 1 + ...uousAggregatePolicyScaffoldingExtractor.cs | 2 +- .../ReorderPolicyScaffoldingExtractor.cs | 6 +- .../RetentionPolicyScaffoldingExtractor.cs | 6 +- .../ContinuousAggregateStringBuilder.cs | 2 + ...tinuousAggregateBuilderPolicyExtensions.cs | 47 +- .../ContinuousAggregatePolicyBuilder.cs | 18 +- .../ContinuousAggregatePolicyBuilderCore.cs | 56 ++ .../ContinuousAggregatePolicyStringBuilder.cs | 86 ++ .../Configuration/PolicyJobBuilderCore.cs | 90 +++ ...sAggregatePolicyAnnotationRendererTests.cs | 757 ++++++++++++++++++ .../PolicyJobRendererHelperTests.cs | 269 +++++++ .../ScaffoldedModelRenderingTests.cs | 431 ++++++++++ ...ggregatePolicyScaffoldingExtractorTests.cs | 4 +- .../ReorderPolicyScaffoldingExtractorTests.cs | 12 +- ...etentionPolicyScaffoldingExtractorTests.cs | 11 +- .../TimescaleDatabaseModelFactoryTests.cs | 2 +- .../ContinuousAggregatePolicyBuilderTests.cs | 703 ++++++++++++++++ 23 files changed, 2773 insertions(+), 55 deletions(-) create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/PolicyJobRendererHelper.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilderCore.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs create mode 100644 src/Eftdb/Configuration/PolicyJobBuilderCore.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/PolicyJobRendererHelperTests.cs diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 68fbc70..b37b8b1 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -73,13 +73,18 @@ This document provides detailed architectural information for the CmdScale.Entit - `ContinuousAggregateBuilderCore.cs` - Internal shared annotation-writing logic for both builder types - `ContinuousAggregateTypeBuilder.cs` - Fluent API extensions (`IsContinuousAggregate`) -#### ContinuousAggregatePolicy/ (5 files) +#### ContinuousAggregatePolicy/ (7 files) - `ContinuousAggregatePolicyAttribute.cs` - Data annotation: `[ContinuousAggregatePolicy]` - `ContinuousAggregatePolicyConvention.cs` - IEntityTypeAddedConvention implementation - `ContinuousAggregatePolicyAnnotations.cs` - Annotation constants -- `ContinuousAggregatePolicyBuilder.cs` - Fluent API builder +- `ContinuousAggregatePolicyBuilder.cs` - Typed fluent API builder (code-first) +- `ContinuousAggregatePolicyBuilderCore.cs` - Shared annotation-writing logic for both builder types (mirrors `ContinuousAggregateBuilderCore`) +- `ContinuousAggregatePolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code - `ContinuousAggregateBuilderPolicyExtensions.cs` - Extension methods for builder +#### Cross-cutting (ContinuousAggregatePolicy + ReorderPolicy + RetentionPolicy) +- `PolicyJobBuilderCore.cs` - Shared base class providing annotation helpers for policy-job fields common to all three policy builder cores (ScheduleInterval, MaxRuntime, MaxRetries, RetryPeriod, InitialStart) + #### Cross-cutting - `TimeColumnStoreTypeValidationConvention.cs` - IModelFinalizedConvention validating that hypertable and continuous-aggregate time columns resolve to a PostgreSQL time-dimension store type (timestamp/timestamptz/date/integer); backed by `Internals/TimeColumnStoreTypeValidator.cs` @@ -220,6 +225,8 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a | `AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface: `GenerateFluentApiCalls` + `GenerateDataAnnotationAttributes` | | `AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable and dimension annotations to fluent API or data annotation attributes | | `AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations; parses the stored view definition via `ViewDefinitionParser` to reconstruct structured configuration | +| `AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | +| `AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared static helpers for rendering optional policy-job fields (InitialStart, ScheduleInterval, MaxRuntime, etc.) shared across all policy renderers | | `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | | `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | @@ -228,16 +235,20 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a `dotnet ef dbcontext scaffold` runs in two phases: **Phase 1 — Database extraction** (`TimescaleDatabaseModelFactory.cs` + `Scaffolding/`): -`TimescaleDatabaseModelFactory` overrides NpgsqlDatabaseModelFactory. After the base factory builds the `DatabaseModel` from the database schema, it calls each extractor/applier pair to layer TimescaleDB metadata on top as annotations: +`TimescaleDatabaseModelFactory` overrides NpgsqlDatabaseModelFactory. After the base factory builds the `DatabaseModel` from the database schema, it calls each extractor/applier pair to layer TimescaleDB metadata on top as annotations. All interval fields are normalized to humanized units (e.g. `"1 hour"`) via `IntervalParsingHelper.NormalizeInterval` to avoid phantom migrations from PostgreSQL's `HH:MM:SS` rendering: - `HypertableScaffoldingExtractor` + `HypertableAnnotationApplier` — hypertable config, dimensions, chunk time interval - `ReorderPolicyScaffoldingExtractor` + `ReorderPolicyAnnotationApplier` +- `RetentionPolicyScaffoldingExtractor` + `RetentionPolicyAnnotationApplier` - `ContinuousAggregateScaffoldingExtractor` + `ContinuousAggregateAnnotationApplier` +- `ContinuousAggregatePolicyScaffoldingExtractor` + `ContinuousAggregatePolicyAnnotationApplier` **Phase 2 — Annotation code generation** (`TimescaleDbAnnotationCodeGenerator` + `AnnotationRenderers/`): EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to convert those annotations into C# code. The dispatcher iterates its registered `IFeatureAnnotationRenderer` implementations: - When `UseDataAnnotations = false` → `GenerateFluentApiCalls` → fluent API method chains in `OnModelCreating` - When `UseDataAnnotations = true` → `GenerateDataAnnotationAttributes` → `[Attribute]` declarations on entity classes +Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`. + `TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. **Additional Design-Time Utilities:** diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index cf65c53..7025aa3 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -93,8 +93,11 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyAnnotations.cs` | Annotation constants | | `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyAttribute.cs` | Data annotation | | `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs` | Convention processing | -| `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs` | Fluent API builder | +| `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs` | Typed fluent API builder (code-first) | +| `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilderCore.cs` | Shared annotation-writing logic for both builder types | +| `Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs` | String-based builder used in scaffolded code | | `Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs` | Builder extensions | +| `Configuration/PolicyJobBuilderCore.cs` | Shared base class for policy job builder cores (reorder, retention, CA policy) | | `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs` | Diffing logic | | `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs` | Model extraction | | `Generators/ContinuousAggregatePolicySqlGenerator.cs` | Runtime SQL generation | @@ -154,6 +157,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/AnnotationRenderers/IFeatureAnnotationRenderer.cs` | Per-feature renderer interface | | `Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable annotations to fluent API or data annotation C# | | `Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations by parsing the view definition | +| `Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | +| `Generators/AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared helpers for emitting policy-job optional arguments (`InitialStart`, `WithScheduleInterval`, etc.) | | `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | | `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | @@ -172,6 +177,9 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Scaffolding/RetentionPolicyAnnotationApplier.cs` | Apply retention policy annotations | | `Scaffolding/ContinuousAggregateScaffoldingExtractor.cs` | Query continuous aggregates | | `Scaffolding/ContinuousAggregateAnnotationApplier.cs` | Apply continuous aggregate annotations | +| `Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs` | Query continuous aggregate refresh policies from database | +| `Scaffolding/ContinuousAggregatePolicyAnnotationApplier.cs` | Apply continuous aggregate policy annotations | +| `Scaffolding/IntervalParsingHelper.cs` | Parses and normalizes PostgreSQL interval strings (e.g. `"01:00:00"` → `"1 hour"`) and integer offsets | | `Scaffolding/ViewDefinitionParser.cs` | Parses continuous aggregate view SQL to extract structured configuration for code generation | | `build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets` | MSBuild integration | diff --git a/samples/Eftdb.Samples.DatabaseFirst/README.md b/samples/Eftdb.Samples.DatabaseFirst/README.md index 709fd1c..820fd13 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/README.md +++ b/samples/Eftdb.Samples.DatabaseFirst/README.md @@ -37,7 +37,7 @@ This command will: ### Data Annotations Variant -Add `--data-annotations` to express the TimescaleDB configuration as attributes on the entity classes (`[Hypertable]`, `[ContinuousAggregate]`, `[TimeBucket]`, `[Aggregate]`, `[GroupByColumn]`, ...) instead of Fluent API calls in `OnModelCreating`: +Add `--data-annotations` to express the TimescaleDB configuration as attributes on the entity classes (`[Hypertable]`, `[ContinuousAggregate]`, ...) instead of Fluent API calls in `OnModelCreating`: ```bash dotnet ef dbcontext scaffold \ diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs new file mode 100644 index 0000000..5c70267 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs @@ -0,0 +1,200 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.PolicyJobRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders TimescaleDB:ContinuousAggregatePolicy:* annotations as a + /// .WithRefreshPolicy(...) Fluent API chain or a [ContinuousAggregatePolicy(...)] + /// attribute. + /// + internal sealed class ContinuousAggregatePolicyAnnotationRenderer : IFeatureAnnotationRenderer + { + private static readonly MethodInfo WithRefreshPolicyMethod = + typeof(ContinuousAggregateBuilderPolicyExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(ContinuousAggregateBuilderPolicyExtensions.WithRefreshPolicy) + && m.GetParameters().Length == 4 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(ContinuousAggregateStringBuilder<>)); + + private static MethodInfo PolicyBuilderMethod(string name) => + typeof(ContinuousAggregatePolicyStringBuilder<>) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == name); + + private static readonly MethodInfo WithInitialStartMethod = PolicyBuilderMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithInitialStart)); + private static readonly MethodInfo WithIncludeTieredDataMethod = PolicyBuilderMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithIncludeTieredData)); + private static readonly MethodInfo WithBucketsPerBatchMethod = PolicyBuilderMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithBucketsPerBatch)); + private static readonly MethodInfo WithMaxBatchesPerExecutionMethod = PolicyBuilderMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithMaxBatchesPerExecution)); + private static readonly MethodInfo WithRefreshNewestFirstMethod = PolicyBuilderMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithRefreshNewestFirst)); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? startOffset = GetString(annotations, ContinuousAggregatePolicyAnnotations.StartOffset); + string? endOffset = GetString(annotations, ContinuousAggregatePolicyAnnotations.EndOffset); + string? scheduleInterval = GetString(annotations, ContinuousAggregatePolicyAnnotations.ScheduleInterval); + + object?[] entryArgs = BuildRefreshPolicyArgs(startOffset, endOffset, scheduleInterval); + MethodCallCodeFragment call = new(WithRefreshPolicyMethod, entryArgs); + + // Chain InitialStart via the shared job helper + call = ChainInitialStart(call, annotations, ContinuousAggregatePolicyAnnotations.InitialStart, WithInitialStartMethod); + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.IncludeTieredData)?.Value is bool includeTieredData) + { + call = call.Chain(WithIncludeTieredDataMethod, includeTieredData); + } + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.BucketsPerBatch)?.Value is int bucketsPerBatch) + { + call = call.Chain(WithBucketsPerBatchMethod, bucketsPerBatch); + } + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)?.Value is int maxBatches) + { + call = call.Chain(WithMaxBatchesPerExecutionMethod, maxBatches); + } + + // RefreshNewestFirst is only stored when false (the default is true), so emit WithRefreshNewestFirst(false). + if (Find(annotations, ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)?.Value is bool refreshNewestFirst) + { + call = call.Chain(WithRefreshNewestFirstMethod, refreshNewestFirst); + } + + ConsumeAllPolicyAnnotations(annotations); + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + Dictionary namedArgs = []; + + string? startOffset = GetString(annotations, ContinuousAggregatePolicyAnnotations.StartOffset); + if (!string.IsNullOrWhiteSpace(startOffset)) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.StartOffset)] = startOffset; + } + + string? endOffset = GetString(annotations, ContinuousAggregatePolicyAnnotations.EndOffset); + if (!string.IsNullOrWhiteSpace(endOffset)) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.EndOffset)] = endOffset; + } + + string? scheduleInterval = GetString(annotations, ContinuousAggregatePolicyAnnotations.ScheduleInterval); + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.ScheduleInterval)] = scheduleInterval; + } + + // InitialStart is stored as DateTime in the annotation; the attribute expects an ISO 8601 string. + AddInitialStartNamedArg( + annotations, + ContinuousAggregatePolicyAnnotations.InitialStart, + nameof(ContinuousAggregatePolicyAttribute.InitialStart), + namedArgs); + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.IncludeTieredData)?.Value is bool includeTieredData) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.IncludeTieredData)] = includeTieredData; + } + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.BucketsPerBatch)?.Value is int bucketsPerBatch) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.BucketsPerBatch)] = bucketsPerBatch; + } + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)?.Value is int maxBatches) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.MaxBatchesPerExecution)] = maxBatches; + } + + if (Find(annotations, ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)?.Value is bool refreshNewestFirst) + { + namedArgs[nameof(ContinuousAggregatePolicyAttribute.RefreshNewestFirst)] = refreshNewestFirst; + } + + ConsumeAllPolicyAnnotations(annotations); + return [new AttributeCodeFragment(typeof(ContinuousAggregatePolicyAttribute), [], namedArgs)]; + } + + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return; + } + + ConsumeAllPolicyAnnotations(annotations); + } + + /// + /// Guards all three rendering paths: a refresh policy annotation is only emitted when the CA + /// renderer already succeeded (it consumes MaterializedViewName on success). If + /// MaterializedViewName is still in the dictionary the CA render failed and the policy + /// annotations must be left for the .HasAnnotation fallback. + /// + private static bool ShouldRender(IDictionary annotations) + => Find(annotations, ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value is true + && Find(annotations, ContinuousAggregateAnnotations.MaterializedViewName) is null; + + /// + /// Builds positional arguments for WithRefreshPolicy(startOffset, endOffset, scheduleInterval), + /// trimming trailing null arguments when the non-null values are in leading positions only. + /// All three must be emitted when scheduleInterval is non-null because it is third-positional. + /// + private static object?[] BuildRefreshPolicyArgs(string? startOffset, string? endOffset, string? scheduleInterval) + { + if (scheduleInterval is not null) + { + return [startOffset, endOffset, scheduleInterval]; + } + + if (endOffset is not null) + { + return [startOffset, endOffset]; + } + + if (startOffset is not null) + { + return [startOffset]; + } + + return []; + } + + private static void ConsumeAllPolicyAnnotations(IDictionary annotations) + { + Consume(annotations, + ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, + ContinuousAggregatePolicyAnnotations.StartOffset, + ContinuousAggregatePolicyAnnotations.EndOffset, + ContinuousAggregatePolicyAnnotations.ScheduleInterval, + ContinuousAggregatePolicyAnnotations.InitialStart, + ContinuousAggregatePolicyAnnotations.IfNotExists, + ContinuousAggregatePolicyAnnotations.IncludeTieredData, + ContinuousAggregatePolicyAnnotations.BucketsPerBatch, + ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, + ContinuousAggregatePolicyAnnotations.RefreshNewestFirst); + } + } +} diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/PolicyJobRendererHelper.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/PolicyJobRendererHelper.cs new file mode 100644 index 0000000..7525167 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/PolicyJobRendererHelper.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Shared rendering helpers for policy job parameters that appear across multiple TimescaleDB + /// policy types (e.g. refresh, reorder, retention). + /// + internal static class PolicyJobRendererHelper + { + /// + /// Chains a WithInitialStart(DateTime)-style call when the annotation is present. + /// The annotation value must be a ; other types are silently ignored. + /// + /// The current method-call fragment to chain onto. + /// The entity's live annotation dictionary. + /// The annotation key storing the value. + /// + /// Reflection handle for the WithInitialStart method on the policy builder. + /// + /// The fragment, extended with the chain when the annotation is present. + public static MethodCallCodeFragment ChainInitialStart( + MethodCallCodeFragment call, + IDictionary annotations, + string initialStartKey, + MethodInfo withInitialStartMethod) + { + IAnnotation? annotation = Find(annotations, initialStartKey); + if (annotation?.Value is not DateTime initialStart) + { + return call; + } + + return call.Chain(withInitialStartMethod, initialStart); + } + + /// + /// Chains a WithScheduleInterval(string)-style call when the annotation is present + /// and the policy builder exposes schedule interval as a separate chain method. Callers that + /// pass schedule interval as an argument to the policy entry call should use the annotation + /// value directly and skip this helper. + /// + /// The current method-call fragment to chain onto. + /// The entity's live annotation dictionary. + /// The annotation key storing the interval string. + /// + /// Reflection handle for the WithScheduleInterval (or equivalent) method on the + /// policy builder. + /// + /// The fragment, extended with the chain when the annotation is present. + public static MethodCallCodeFragment ChainScheduleInterval( + MethodCallCodeFragment call, + IDictionary annotations, + string scheduleIntervalKey, + MethodInfo withScheduleIntervalMethod) + { + string? value = GetString(annotations, scheduleIntervalKey); + if (string.IsNullOrWhiteSpace(value)) + { + return call; + } + + return call.Chain(withScheduleIntervalMethod, value); + } + + /// + /// Builds the named-argument dictionary entry for InitialStart in data-annotation + /// mode, converting the stored to an ISO 8601 UTC round-trip string + /// as expected by . + /// + /// The entity's live annotation dictionary. + /// The annotation key storing the value. + /// + /// The name to use for the attribute named argument (typically nameof(Attribute.InitialStart)). + /// + /// The dictionary to add the argument to when the annotation is present. + public static void AddInitialStartNamedArg( + IDictionary annotations, + string initialStartKey, + string namedArgumentKey, + Dictionary namedArgs) + { + IAnnotation? annotation = Find(annotations, initialStartKey); + if (annotation?.Value is not DateTime initialStart) + { + return; + } + + // ISO 8601 round-trip UTC string — matches ContinuousAggregatePolicyAttribute.InitialStart type (string). + namedArgs[namedArgumentKey] = initialStart.ToUniversalTime().ToString("O"); + } + } +} diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index 64eeb61..0576879 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -32,6 +32,7 @@ public sealed class TimescaleDbAnnotationCodeGenerator( [ new HypertableAnnotationRenderer(), new ContinuousAggregateAnnotationRenderer(reporter), + new ContinuousAggregatePolicyAnnotationRenderer(), ]; private readonly Dictionary> _entityAttributeCache = []; diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs index 9c3b8c0..e38a196 100644 --- a/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs @@ -53,7 +53,7 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca string viewSchema = reader.GetString(0); string viewName = reader.GetString(1); string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2); - string? scheduleInterval = reader.IsDBNull(3) ? null : reader.GetString(3); + string? scheduleInterval = reader.IsDBNull(3) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(3)); DateTime? initialStart = reader.IsDBNull(4) ? null : reader.GetDateTime(4); // Parse the JSONB config to extract policy parameters diff --git a/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs index e331a92..df8b38b 100644 --- a/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs @@ -51,10 +51,10 @@ FROM timescaledb_information.jobs AS j string indexName = reader.GetString(2); DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); - string? scheduleInterval = reader.IsDBNull(4) ? null : reader.GetString(4); - string? maxRuntime = reader.IsDBNull(5) ? null : reader.GetString(5); + string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); + string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); - string? retryPeriod = reader.IsDBNull(7) ? null : reader.GetString(7); + string? retryPeriod = reader.IsDBNull(7) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(7)); if (!string.IsNullOrEmpty(indexName)) { diff --git a/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs index 2e46976..3de9152 100644 --- a/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs @@ -53,10 +53,10 @@ FROM timescaledb_information.jobs AS j string name = reader.GetString(1); string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2); DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); - string? scheduleInterval = reader.IsDBNull(4) ? null : reader.GetString(4); - string? maxRuntime = reader.IsDBNull(5) ? null : reader.GetString(5); + string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); + string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); - string? retryPeriod = reader.IsDBNull(7) ? null : reader.GetString(7); + string? retryPeriod = reader.IsDBNull(7) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(7)); // Parse the JSONB config to extract drop_after or drop_created_before string? dropAfter = null; diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs index 7f5a0dc..e9886b4 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs @@ -13,6 +13,8 @@ public sealed class ContinuousAggregateStringBuilder where TEntity : cl { private readonly EntityTypeBuilder _builder; + internal EntityTypeBuilder EntityTypeBuilder => _builder; + internal ContinuousAggregateStringBuilder(EntityTypeBuilder builder) { _builder = builder; diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs index 8c716b5..dd25f7b 100644 --- a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs @@ -17,13 +17,6 @@ public static class ContinuousAggregateBuilderPolicyExtensions /// Window end as interval relative to execution time. NULL equals latest data. /// Interval between refresh executions. Defaults to "24 hours" if not specified. /// A policy builder for configuring additional refresh policy options. - /// - /// - /// builder.IsContinuousAggregate<HourlyMetric, Metric>("hourly_metrics", "1 hour", x => x.Timestamp) - /// .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - /// .WithRefreshNewestFirst(true); - /// - /// public static ContinuousAggregatePolicyBuilder WithRefreshPolicy( this ContinuousAggregateBuilder builder, string? startOffset = null, @@ -32,18 +25,40 @@ public static ContinuousAggregatePolicyBuilder WithRefre where TEntity : class where TSourceEntity : class { - builder.EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + ContinuousAggregatePolicyBuilderCore.WriteRefreshPolicy( + builder.EntityTypeBuilder, + startOffset, + endOffset, + scheduleInterval); - if (!string.IsNullOrWhiteSpace(startOffset)) - builder.EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, startOffset); - - if (!string.IsNullOrWhiteSpace(endOffset)) - builder.EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, endOffset); + return new ContinuousAggregatePolicyBuilder(builder); + } - if (!string.IsNullOrWhiteSpace(scheduleInterval)) - builder.EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, scheduleInterval); + /// + /// Configures a continuous aggregate refresh policy using string column references. + /// Used by code generated by dotnet ef dbcontext scaffold where generic source-entity + /// type parameters are not available at generation time. + /// + /// The continuous aggregate entity type. + /// The string-context continuous aggregate builder. + /// Window start as interval relative to execution time. NULL equals earliest data. + /// Window end as interval relative to execution time. NULL equals latest data. + /// Interval between refresh executions. Defaults to "24 hours" if not specified. + /// A string-context policy builder for configuring additional refresh policy options. + public static ContinuousAggregatePolicyStringBuilder WithRefreshPolicy( + this ContinuousAggregateStringBuilder builder, + string? startOffset = null, + string? endOffset = null, + string? scheduleInterval = null) + where TEntity : class + { + ContinuousAggregatePolicyBuilderCore.WriteRefreshPolicy( + builder.EntityTypeBuilder, + startOffset, + endOffset, + scheduleInterval); - return new ContinuousAggregatePolicyBuilder(builder); + return new ContinuousAggregatePolicyStringBuilder(builder); } } } diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs index fa3764b..091786f 100644 --- a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs @@ -36,7 +36,7 @@ internal ContinuousAggregatePolicyBuilder(ContinuousAggregateBuilderThe builder for method chaining. public ContinuousAggregatePolicyBuilder WithInitialStart(DateTime initialStart) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, initialStart); + ContinuousAggregatePolicyBuilderCore.WithInitialStart(EntityTypeBuilder, initialStart); return this; } @@ -47,7 +47,7 @@ public ContinuousAggregatePolicyBuilder WithInitialStart /// The builder for method chaining. public ContinuousAggregatePolicyBuilder WithIfNotExists(bool ifNotExists = true) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists, ifNotExists); + ContinuousAggregatePolicyBuilderCore.WithIfNotExists(EntityTypeBuilder, ifNotExists); return this; } @@ -58,7 +58,7 @@ public ContinuousAggregatePolicyBuilder WithIfNotExists( /// The builder for method chaining. public ContinuousAggregatePolicyBuilder WithIncludeTieredData(bool includeTieredData) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData, includeTieredData); + ContinuousAggregatePolicyBuilderCore.WithIncludeTieredData(EntityTypeBuilder, includeTieredData); return this; } @@ -69,10 +69,7 @@ public ContinuousAggregatePolicyBuilder WithIncludeTiere /// The builder for method chaining. public ContinuousAggregatePolicyBuilder WithBucketsPerBatch(int bucketsPerBatch) { - if (bucketsPerBatch < 1) - throw new ArgumentException("BucketsPerBatch must be at least 1.", nameof(bucketsPerBatch)); - - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch, bucketsPerBatch); + ContinuousAggregatePolicyBuilderCore.WithBucketsPerBatch(EntityTypeBuilder, bucketsPerBatch); return this; } @@ -83,10 +80,7 @@ public ContinuousAggregatePolicyBuilder WithBucketsPerBa /// The builder for method chaining. public ContinuousAggregatePolicyBuilder WithMaxBatchesPerExecution(int maxBatchesPerExecution) { - if (maxBatchesPerExecution < 0) - throw new ArgumentException("MaxBatchesPerExecution must be 0 (unlimited) or greater.", nameof(maxBatchesPerExecution)); - - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, maxBatchesPerExecution); + ContinuousAggregatePolicyBuilderCore.WithMaxBatchesPerExecution(EntityTypeBuilder, maxBatchesPerExecution); return this; } @@ -97,7 +91,7 @@ public ContinuousAggregatePolicyBuilder WithMaxBatchesPe /// The builder for method chaining. public ContinuousAggregatePolicyBuilder WithRefreshNewestFirst(bool refreshNewestFirst = true) { - EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, refreshNewestFirst); + ContinuousAggregatePolicyBuilderCore.WithRefreshNewestFirst(EntityTypeBuilder, refreshNewestFirst); return this; } } diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilderCore.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilderCore.cs new file mode 100644 index 0000000..599e89a --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilderCore.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy +{ + /// + /// Shared annotation-writing logic for the continuous aggregate refresh policy. + /// + internal static class ContinuousAggregatePolicyBuilderCore + { + /// + /// Writes the base refresh-policy annotations (HasRefreshPolicy flag and optional + /// offset / schedule-interval strings). + /// + public static void WriteRefreshPolicy( + EntityTypeBuilder builder, + string? startOffset, + string? endOffset, + string? scheduleInterval) + { + builder.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + + if (!string.IsNullOrWhiteSpace(startOffset)) + builder.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, startOffset); + + if (!string.IsNullOrWhiteSpace(endOffset)) + builder.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, endOffset); + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + builder.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, scheduleInterval); + } + + /// Writes the initial-start annotation. + public static void WithInitialStart(EntityTypeBuilder builder, DateTime initialStart) + => PolicyJobBuilderCore.WithInitialStart(builder, ContinuousAggregatePolicyAnnotations.InitialStart, initialStart); + + /// Writes the if-not-exists annotation. + public static void WithIfNotExists(EntityTypeBuilder builder, bool ifNotExists) + => PolicyJobBuilderCore.WithIfNotExists(builder, ContinuousAggregatePolicyAnnotations.IfNotExists, ifNotExists); + + /// Writes the include-tiered-data annotation. + public static void WithIncludeTieredData(EntityTypeBuilder builder, bool includeTieredData) + => PolicyJobBuilderCore.WithIncludeTieredData(builder, ContinuousAggregatePolicyAnnotations.IncludeTieredData, includeTieredData); + + /// Validates and writes the buckets-per-batch annotation. + public static void WithBucketsPerBatch(EntityTypeBuilder builder, int bucketsPerBatch) + => PolicyJobBuilderCore.WithBucketsPerBatch(builder, ContinuousAggregatePolicyAnnotations.BucketsPerBatch, bucketsPerBatch); + + /// Validates and writes the max-batches-per-execution annotation. + public static void WithMaxBatchesPerExecution(EntityTypeBuilder builder, int maxBatchesPerExecution) + => PolicyJobBuilderCore.WithMaxBatchesPerExecution(builder, ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, maxBatchesPerExecution); + + /// Writes the refresh-newest-first annotation. + public static void WithRefreshNewestFirst(EntityTypeBuilder builder, bool refreshNewestFirst) + => PolicyJobBuilderCore.WithRefreshNewestFirst(builder, ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, refreshNewestFirst); + } +} diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs new file mode 100644 index 0000000..752f748 --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs @@ -0,0 +1,86 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy +{ + /// + /// Provides a fluent API for configuring a TimescaleDB continuous aggregate refresh policy + /// using string column references. Used by code generated by dotnet ef dbcontext scaffold + /// where generic source-entity type parameters are not available at generation time. + /// + /// The class representing the continuous aggregate view. + public sealed class ContinuousAggregatePolicyStringBuilder where TEntity : class + { + private readonly ContinuousAggregateStringBuilder _builder; + + internal ContinuousAggregatePolicyStringBuilder(ContinuousAggregateStringBuilder builder) + { + _builder = builder; + } + + /// + /// Sets the initial start time for the continuous aggregate refresh policy. + /// + /// The first time the policy job is scheduled to run. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithInitialStart(DateTime initialStart) + { + ContinuousAggregatePolicyBuilderCore.WithInitialStart(_builder.EntityTypeBuilder, initialStart); + return this; + } + + /// + /// Configures whether to issue a notice instead of an error if the policy job already exists. + /// + /// True to issue a notice instead of an error if job exists. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithIfNotExists(bool ifNotExists = true) + { + ContinuousAggregatePolicyBuilderCore.WithIfNotExists(_builder.EntityTypeBuilder, ifNotExists); + return this; + } + + /// + /// Configures whether to override tiered read settings for the continuous aggregate refresh policy. + /// + /// True to include tiered data, false to exclude it. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithIncludeTieredData(bool includeTieredData) + { + ContinuousAggregatePolicyBuilderCore.WithIncludeTieredData(_builder.EntityTypeBuilder, includeTieredData); + return this; + } + + /// + /// Sets the number of buckets processed per batch transaction for the continuous aggregate refresh policy. + /// + /// The number of buckets to process per batch. Defaults to 1. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithBucketsPerBatch(int bucketsPerBatch) + { + ContinuousAggregatePolicyBuilderCore.WithBucketsPerBatch(_builder.EntityTypeBuilder, bucketsPerBatch); + return this; + } + + /// + /// Sets the maximum number of batches per execution for the continuous aggregate refresh policy. + /// + /// Maximum batches per run. 0 means unlimited. Defaults to 0. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithMaxBatchesPerExecution(int maxBatchesPerExecution) + { + ContinuousAggregatePolicyBuilderCore.WithMaxBatchesPerExecution(_builder.EntityTypeBuilder, maxBatchesPerExecution); + return this; + } + + /// + /// Sets the direction of incremental refresh for the continuous aggregate refresh policy. + /// + /// True to refresh newest data first, false to refresh oldest first. Defaults to true. + /// The builder for method chaining. + public ContinuousAggregatePolicyStringBuilder WithRefreshNewestFirst(bool refreshNewestFirst = true) + { + ContinuousAggregatePolicyBuilderCore.WithRefreshNewestFirst(_builder.EntityTypeBuilder, refreshNewestFirst); + return this; + } + } +} diff --git a/src/Eftdb/Configuration/PolicyJobBuilderCore.cs b/src/Eftdb/Configuration/PolicyJobBuilderCore.cs new file mode 100644 index 0000000..d55420f --- /dev/null +++ b/src/Eftdb/Configuration/PolicyJobBuilderCore.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration +{ + /// + /// Holds the annotation key names for a single TimescaleDB policy's job-level parameters. + /// Pass an instance to so the shared writer knows + /// which annotation keys to use for each feature. + /// + internal readonly struct PolicyJobAnnotationKeys + { + /// Annotation key for the schedule interval string. + public string ScheduleInterval { get; init; } + + /// Annotation key for the initial start DateTime. + public string InitialStart { get; init; } + + /// Annotation key for the if-not-exists bool. + public string IfNotExists { get; init; } + + /// Annotation key for the include-tiered-data nullable bool. + public string IncludeTieredData { get; init; } + + /// Annotation key for the buckets-per-batch int. + public string BucketsPerBatch { get; init; } + + /// Annotation key for the max-batches-per-execution int. + public string MaxBatchesPerExecution { get; init; } + + /// Annotation key for the refresh-newest-first bool. + public string RefreshNewestFirst { get; init; } + } + + /// + /// Shared annotation-writing logic for TimescaleDB background-job policies + /// (continuous aggregate refresh, reorder, retention). Operates on the non-generic + /// so every typed and string-context policy builder + /// can delegate here without duplicating validation or annotation calls. + /// + internal static class PolicyJobBuilderCore + { + /// + /// Writes the initial-start annotation. + /// + public static void WithInitialStart(EntityTypeBuilder builder, string annotationKey, DateTime initialStart) + => builder.HasAnnotation(annotationKey, initialStart); + + /// + /// Writes the if-not-exists annotation. + /// + public static void WithIfNotExists(EntityTypeBuilder builder, string annotationKey, bool ifNotExists) + => builder.HasAnnotation(annotationKey, ifNotExists); + + /// + /// Writes the include-tiered-data annotation. + /// + public static void WithIncludeTieredData(EntityTypeBuilder builder, string annotationKey, bool includeTieredData) + => builder.HasAnnotation(annotationKey, includeTieredData); + + /// + /// Validates and writes the buckets-per-batch annotation. + /// + /// Thrown when is less than 1. + public static void WithBucketsPerBatch(EntityTypeBuilder builder, string annotationKey, int bucketsPerBatch) + { + if (bucketsPerBatch < 1) + throw new ArgumentException("BucketsPerBatch must be at least 1.", nameof(bucketsPerBatch)); + + builder.HasAnnotation(annotationKey, bucketsPerBatch); + } + + /// + /// Validates and writes the max-batches-per-execution annotation. + /// + /// Thrown when is negative. + public static void WithMaxBatchesPerExecution(EntityTypeBuilder builder, string annotationKey, int maxBatchesPerExecution) + { + if (maxBatchesPerExecution < 0) + throw new ArgumentException("MaxBatchesPerExecution must be 0 (unlimited) or greater.", nameof(maxBatchesPerExecution)); + + builder.HasAnnotation(annotationKey, maxBatchesPerExecution); + } + + /// + /// Writes the refresh-newest-first annotation. + /// + public static void WithRefreshNewestFirst(EntityTypeBuilder builder, string annotationKey, bool refreshNewestFirst) + => builder.HasAnnotation(annotationKey, refreshNewestFirst); + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs new file mode 100644 index 0000000..eead457 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs @@ -0,0 +1,757 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for ContinuousAggregatePolicyAnnotationRenderer exercised through the public +/// surface. +/// The renderer runs AFTER ContinuousAggregateAnnotationRenderer and uses the presence or +/// absence of MaterializedViewName to decide whether to emit policy code. +/// +public class ContinuousAggregatePolicyAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private const string StandardViewDef = + "SELECT time_bucket('01:00:00'::interval, policy_source.\"time\") AS bucket," + + " avg(policy_source.value) AS avg_value" + + " FROM policy_source" + + " GROUP BY time_bucket('01:00:00'::interval, policy_source.\"time\")"; + + private class PolicySourceEntity + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class PolicyCaEntity + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class PolicyTestContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("policy_ca_view"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + // ── Guard tests ─────────────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_HasRefreshPolicy_Not_Set + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_HasRefreshPolicy_Not_Set() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithRefreshPolicy")); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_MaterializedViewName_Still_Present + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_MaterializedViewName_Still_Present() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregateAnnotations.MaterializedViewName, "policy_ca_view")); // still present + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithRefreshPolicy")); + Assert.True(annotations.ContainsKey(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)); + Assert.True(annotations.ContainsKey(ContinuousAggregatePolicyAnnotations.StartOffset)); + } + + #endregion + + // ── Arg-trimming tests ──────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_WithRefreshPolicy_NoArgs_When_All_Offsets_Absent + + [Fact] + public void GenerateFluentApiCalls_WithRefreshPolicy_NoArgs_When_All_Offsets_Absent() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRefreshPolicy = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshPolicy"); + + Assert.NotNull(withRefreshPolicy); + Assert.Empty(withRefreshPolicy.Arguments); + } + + #endregion + + #region GenerateFluentApiCalls_WithRefreshPolicy_OneArg_When_Only_StartOffset + + [Fact] + public void GenerateFluentApiCalls_WithRefreshPolicy_OneArg_When_Only_StartOffset() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRefreshPolicy = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshPolicy"); + + Assert.NotNull(withRefreshPolicy); + Assert.Equal(["1 month"], withRefreshPolicy.Arguments); + } + + #endregion + + #region GenerateFluentApiCalls_WithRefreshPolicy_TwoArgs_When_Start_And_End + + [Fact] + public void GenerateFluentApiCalls_WithRefreshPolicy_TwoArgs_When_Start_And_End() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRefreshPolicy = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshPolicy"); + + Assert.NotNull(withRefreshPolicy); + Assert.Equal(2, withRefreshPolicy.Arguments.Count); + Assert.Equal("1 month", withRefreshPolicy.Arguments[0]); + Assert.Equal("1 hour", withRefreshPolicy.Arguments[1]); + } + + #endregion + + #region GenerateFluentApiCalls_WithRefreshPolicy_ThreeArgs_When_ScheduleInterval_Present + + [Fact] + public void GenerateFluentApiCalls_WithRefreshPolicy_ThreeArgs_When_ScheduleInterval_Present() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.ScheduleInterval, "24 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRefreshPolicy = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshPolicy"); + + Assert.NotNull(withRefreshPolicy); + Assert.Equal(3, withRefreshPolicy.Arguments.Count); + Assert.Null(withRefreshPolicy.Arguments[0]); + Assert.Null(withRefreshPolicy.Arguments[1]); + Assert.Equal("24 hours", withRefreshPolicy.Arguments[2]); + } + + #endregion + + #region GenerateFluentApiCalls_WithRefreshPolicy_ThreeArgs_When_All_Present + + [Fact] + public void GenerateFluentApiCalls_WithRefreshPolicy_ThreeArgs_When_All_Present() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"), + (ContinuousAggregatePolicyAnnotations.ScheduleInterval, "2 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRefreshPolicy = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshPolicy"); + + Assert.NotNull(withRefreshPolicy); + Assert.Equal(3, withRefreshPolicy.Arguments.Count); + Assert.Equal("1 month", withRefreshPolicy.Arguments[0]); + Assert.Equal("1 hour", withRefreshPolicy.Arguments[1]); + Assert.Equal("2 hours", withRefreshPolicy.Arguments[2]); + } + + #endregion + + // ── Chaining tests ──────────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Chains_InitialStart_When_Present + + [Fact] + public void GenerateFluentApiCalls_Chains_InitialStart_When_Present() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + List chain = [.. result.SelectMany(f => CollectMethodChain(f))]; + + Assert.Contains("WithInitialStart", chain); + MethodCallCodeFragment? initialStartFrag = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithInitialStart"); + + Assert.NotNull(initialStartFrag); + Assert.Equal(initialStart, initialStartFrag.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_InitialStart_When_Absent + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_InitialStart_When_Absent() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_IncludeTieredData + + [Fact] + public void GenerateFluentApiCalls_Chains_IncludeTieredData() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.IncludeTieredData, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithIncludeTieredData"); + + Assert.NotNull(fragment); + Assert.Equal(false, fragment.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_BucketsPerBatch + + [Fact] + public void GenerateFluentApiCalls_Chains_BucketsPerBatch() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 5)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithBucketsPerBatch"); + + Assert.NotNull(fragment); + Assert.Equal(5, fragment.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_MaxBatchesPerExecution + + [Fact] + public void GenerateFluentApiCalls_Chains_MaxBatchesPerExecution() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithMaxBatchesPerExecution"); + + Assert.NotNull(fragment); + Assert.Equal(10, fragment.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_RefreshNewestFirst + + [Fact] + public void GenerateFluentApiCalls_Chains_RefreshNewestFirst() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? fragment = result + .SelectMany(f => { List all = []; for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) all.Add(c); return all; }) + .FirstOrDefault(f => f.Method == "WithRefreshNewestFirst"); + + Assert.NotNull(fragment); + Assert.Equal(false, fragment.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Consumes_All_Policy_Annotations_After_Render + + [Fact] + public void GenerateFluentApiCalls_Consumes_All_Policy_Annotations_After_Render() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"), + (ContinuousAggregatePolicyAnnotations.ScheduleInterval, "2 hours"), + (ContinuousAggregatePolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + (ContinuousAggregatePolicyAnnotations.IfNotExists, true), + (ContinuousAggregatePolicyAnnotations.IncludeTieredData, false), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 3), + (ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10), + (ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false)); + + // Act + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:ContinuousAggregatePolicy:", StringComparison.Ordinal)); + } + + #endregion + + #region GenerateFluentApiCalls_Chain_Order_Is_RefreshPolicy_Then_InitialStart_Then_Others + + [Fact] + public void GenerateFluentApiCalls_Chain_Order_Is_RefreshPolicy_Then_InitialStart_Then_Others() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + (ContinuousAggregatePolicyAnnotations.IncludeTieredData, false), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 3), + (ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10), + (ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => CollectMethodChain(f).Contains("WithRefreshPolicy")); + List chain = CollectMethodChain(root); + + int refreshPolicyIdx = chain.IndexOf("WithRefreshPolicy"); + int initialStartIdx = chain.IndexOf("WithInitialStart"); + int includeTieredIdx = chain.IndexOf("WithIncludeTieredData"); + int bucketsIdx = chain.IndexOf("WithBucketsPerBatch"); + int maxBatchesIdx = chain.IndexOf("WithMaxBatchesPerExecution"); + int newestFirstIdx = chain.IndexOf("WithRefreshNewestFirst"); + + Assert.True(refreshPolicyIdx < initialStartIdx, "WithRefreshPolicy must precede WithInitialStart."); + Assert.True(initialStartIdx < includeTieredIdx, "WithInitialStart must precede WithIncludeTieredData."); + Assert.True(includeTieredIdx < bucketsIdx, "WithIncludeTieredData must precede WithBucketsPerBatch."); + Assert.True(bucketsIdx < maxBatchesIdx, "WithBucketsPerBatch must precede WithMaxBatchesPerExecution."); + Assert.True(maxBatchesIdx < newestFirstIdx, "WithMaxBatchesPerExecution must precede WithRefreshNewestFirst."); + } + + #endregion + + // ── Data-annotation tests ───────────────────────────────────────────────── + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_HasRefreshPolicy_Not_Set + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_HasRefreshPolicy_Not_Set() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ContinuousAggregatePolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_MaterializedViewName_Still_Present + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_MaterializedViewName_Still_Present() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregateAnnotations.MaterializedViewName, "policy_ca_view")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ContinuousAggregatePolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Attribute_With_All_Named_Args + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_ContinuousAggregatePolicyAttribute_With_All_Named_Args() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"), + (ContinuousAggregatePolicyAnnotations.ScheduleInterval, "2 hours"), + (ContinuousAggregatePolicyAnnotations.InitialStart, initialStart), + (ContinuousAggregatePolicyAnnotations.IncludeTieredData, false), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 3), + (ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10), + (ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregatePolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("1 month", attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.StartOffset)]); + Assert.Equal("1 hour", attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.EndOffset)]); + Assert.Equal("2 hours", attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.ScheduleInterval)]); + Assert.True(attr.NamedArguments.ContainsKey(nameof(ContinuousAggregatePolicyAttribute.InitialStart))); + Assert.Equal(false, attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.IncludeTieredData)]); + Assert.Equal(3, attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.BucketsPerBatch)]); + Assert.Equal(10, attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.MaxBatchesPerExecution)]); + Assert.Equal(false, attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.RefreshNewestFirst)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregatePolicyAttribute)); + Assert.NotNull(attr); + Assert.Empty(attr.NamedArguments); + } + + #endregion + + #region GenerateDataAnnotationAttributes_InitialStart_Uses_ISO8601_UTC_String + + [Fact] + public void GenerateDataAnnotationAttributes_InitialStart_Uses_ISO8601_UTC_String() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + + DateTime localTime = new(2025, 6, 1, 12, 0, 0, DateTimeKind.Local); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.InitialStart, localTime)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregatePolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(ContinuousAggregatePolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(localTime.ToUniversalTime(), parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Consumes_All_Policy_Annotations_After_Render + + [Fact] + public void GenerateDataAnnotationAttributes_Consumes_All_Policy_Annotations_After_Render() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"), + (ContinuousAggregatePolicyAnnotations.ScheduleInterval, "2 hours"), + (ContinuousAggregatePolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + (ContinuousAggregatePolicyAnnotations.IfNotExists, true), + (ContinuousAggregatePolicyAnnotations.IncludeTieredData, false), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 3), + (ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10), + (ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false)); + + // Act + CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:ContinuousAggregatePolicy:", StringComparison.Ordinal)); + } + + #endregion + + // ── P2: ConsumeFeatureAnnotations ───────────────────────────────────────── + + #region ConsumeFeatureAnnotations_Removes_All_Policy_Keys_When_ShouldRender_True + + [Fact] + public void ConsumeFeatureAnnotations_Removes_All_Policy_Keys_When_ShouldRender_True() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregatePolicyAnnotations.IfNotExists, false), + (ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 2)); + + // Act + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:ContinuousAggregatePolicy:", StringComparison.Ordinal)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False + + [Fact] + public void ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False() + { + // Arrange + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), + (ContinuousAggregateAnnotations.MaterializedViewName, "policy_ca_view")); + + // Act + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)); + Assert.True(annotations.ContainsKey(ContinuousAggregatePolicyAnnotations.StartOffset)); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_InitialStart_When_Value_Is_Not_DateTime + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_InitialStart_When_Value_Is_Not_DateTime() + { + // Arrange. + using PolicyTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), + (ContinuousAggregatePolicyAnnotations.InitialStart, "2025-06-01T00:00:00Z")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/PolicyJobRendererHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/PolicyJobRendererHelperTests.cs new file mode 100644 index 0000000..56cabbc --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/PolicyJobRendererHelperTests.cs @@ -0,0 +1,269 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using System.Reflection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Unit tests for covering +/// , +/// , and +/// . +/// No DbContext or database is required — all methods operate on annotation +/// dictionaries and instances. +/// +public class PolicyJobRendererHelperTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static readonly MethodInfo WithChunkIntervalMethod = + typeof(ContinuousAggregateStringBuilder) + .GetMethod(nameof(ContinuousAggregateStringBuilder<>.WithChunkInterval), + BindingFlags.Public | BindingFlags.Instance)!; + + private static readonly MethodInfo WithInitialStartMethod = + typeof(ContinuousAggregatePolicyStringBuilder) + .GetMethod(nameof(ContinuousAggregatePolicyStringBuilder.WithInitialStart), + BindingFlags.Public | BindingFlags.Instance)!; + + private static MethodCallCodeFragment RootFragment() => new(WithChunkIntervalMethod, "1 hour"); + + // ── ChainInitialStart ──────────────────────────────────────────────────── + + #region ChainInitialStart_Chains_Method_When_DateTime_Present + + [Fact] + public void ChainInitialStart_Chains_Method_When_DateTime_Present() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations(("test:InitialStart", initialStart)); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainInitialStart( + root, annotations, "test:InitialStart", WithInitialStartMethod); + + // Assert + Assert.NotNull(result.ChainedCall); + Assert.Equal(WithInitialStartMethod.Name, result.ChainedCall.Method); + Assert.Equal(initialStart, result.ChainedCall.Arguments[0]); + } + + #endregion + + #region ChainInitialStart_Returns_Unchanged_When_Absent + + [Fact] + public void ChainInitialStart_Returns_Unchanged_When_Absent() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainInitialStart( + root, annotations, "test:InitialStart", WithInitialStartMethod); + + // Assert + Assert.Null(result.ChainedCall); + Assert.Same(root, result); + } + + #endregion + + #region ChainInitialStart_Returns_Unchanged_When_Value_Not_DateTime + + [Fact] + public void ChainInitialStart_Returns_Unchanged_When_Value_Not_DateTime() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(("test:InitialStart", "2025-06-01T00:00:00Z")); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainInitialStart( + root, annotations, "test:InitialStart", WithInitialStartMethod); + + // Assert + Assert.Null(result.ChainedCall); + Assert.Same(root, result); + } + + #endregion + + // ── ChainScheduleInterval ──────────────────────────────────────────────── + + #region ChainScheduleInterval_Chains_Method_When_String_Present + + [Fact] + public void ChainScheduleInterval_Chains_Method_When_String_Present() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(("test:ScheduleInterval", "24 hours")); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainScheduleInterval( + root, annotations, "test:ScheduleInterval", WithChunkIntervalMethod); + + // Assert + Assert.NotNull(result.ChainedCall); + Assert.Equal(WithChunkIntervalMethod.Name, result.ChainedCall.Method); + Assert.Equal("24 hours", result.ChainedCall.Arguments[0]); + } + + #endregion + + #region ChainScheduleInterval_Returns_Unchanged_When_Absent + + [Fact] + public void ChainScheduleInterval_Returns_Unchanged_When_Absent() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainScheduleInterval( + root, annotations, "test:ScheduleInterval", WithChunkIntervalMethod); + + // Assert + Assert.Null(result.ChainedCall); + Assert.Same(root, result); + } + + #endregion + + #region ChainScheduleInterval_Returns_Unchanged_When_Whitespace + + [Fact] + public void ChainScheduleInterval_Returns_Unchanged_When_Whitespace() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(("test:ScheduleInterval", " ")); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainScheduleInterval( + root, annotations, "test:ScheduleInterval", WithChunkIntervalMethod); + + // Assert + Assert.Null(result.ChainedCall); + Assert.Same(root, result); + } + + #endregion + + #region ChainScheduleInterval_Returns_Unchanged_When_Value_Not_String + + [Fact] + public void ChainScheduleInterval_Returns_Unchanged_When_Value_Not_String() + { + // Arrange + MethodCallCodeFragment root = RootFragment(); + Dictionary annotations = Annotations(("test:ScheduleInterval", 3600)); + + // Act + MethodCallCodeFragment result = PolicyJobRendererHelper.ChainScheduleInterval( + root, annotations, "test:ScheduleInterval", WithChunkIntervalMethod); + + // Assert + Assert.Null(result.ChainedCall); + Assert.Same(root, result); + } + + #endregion + + // ── AddInitialStartNamedArg ─────────────────────────────────────────────── + + #region AddInitialStartNamedArg_Adds_ISO8601_String_For_UTC_DateTime + + [Fact] + public void AddInitialStartNamedArg_Adds_ISO8601_String_For_UTC_DateTime() + { + // Arrange + DateTime utcTime = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations(("test:InitialStart", utcTime)); + Dictionary namedArgs = []; + + // Act + PolicyJobRendererHelper.AddInitialStartNamedArg(annotations, "test:InitialStart", "InitialStart", namedArgs); + + // Assert + Assert.True(namedArgs.ContainsKey("InitialStart")); + string? value = namedArgs["InitialStart"] as string; + Assert.NotNull(value); + Assert.EndsWith("Z", value, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(value, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(utcTime, parsed); + } + + #endregion + + #region AddInitialStartNamedArg_Converts_Local_DateTime_To_UTC + + [Fact] + public void AddInitialStartNamedArg_Converts_Local_DateTime_To_UTC() + { + // Arrange + DateTime localTime = new(2025, 6, 1, 12, 0, 0, DateTimeKind.Local); + Dictionary annotations = Annotations(("test:InitialStart", localTime)); + Dictionary namedArgs = []; + + // Act + PolicyJobRendererHelper.AddInitialStartNamedArg(annotations, "test:InitialStart", "InitialStart", namedArgs); + + // Assert + string? value = namedArgs["InitialStart"] as string; + Assert.NotNull(value); + Assert.EndsWith("Z", value, StringComparison.Ordinal); + + DateTime parsed = DateTime.Parse(value, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(localTime.ToUniversalTime(), parsed); + } + + #endregion + + #region AddInitialStartNamedArg_Does_Nothing_When_Annotation_Absent + + [Fact] + public void AddInitialStartNamedArg_Does_Nothing_When_Annotation_Absent() + { + // Arrange + Dictionary annotations = Annotations(); + Dictionary namedArgs = []; + + // Act + PolicyJobRendererHelper.AddInitialStartNamedArg(annotations, "test:InitialStart", "InitialStart", namedArgs); + + // Assert + Assert.Empty(namedArgs); + } + + #endregion + + #region AddInitialStartNamedArg_Does_Nothing_When_Value_Not_DateTime + + [Fact] + public void AddInitialStartNamedArg_Does_Nothing_When_Value_Not_DateTime() + { + // Arrange + Dictionary annotations = Annotations(("test:InitialStart", "2025-06-01T00:00:00Z")); + Dictionary namedArgs = []; + + // Act + PolicyJobRendererHelper.AddInitialStartNamedArg(annotations, "test:InitialStart", "InitialStart", namedArgs); + + // Assert + Assert.Empty(namedArgs); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs index ba94d5b..0ac396c 100644 --- a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs @@ -1,5 +1,6 @@ #pragma warning disable EF1001 using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Design; using Microsoft.EntityFrameworkCore; @@ -207,5 +208,435 @@ public void Should_Render_Attributes_And_Usings_In_DataAnnotations_Scaffold() } #endregion + + // ── Continuous aggregate refresh policy rendering ───────────────────────── + + private const string PolicyStandardViewDef = + "SELECT time_bucket('01:00:00'::interval, render_policy_source.\"time\") AS bucket," + + " avg(render_policy_source.value) AS avg_value" + + " FROM render_policy_source" + + " GROUP BY time_bucket('01:00:00'::interval, render_policy_source.\"time\")"; + + #region Should_Render_WithRefreshPolicy_Chained_After_IsContinuousAggregate_In_Fluent_Scaffold + + private class PolicySourceA { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewA { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextA : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_a"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_a"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "7 days"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"); + }); + } + } + + [Fact] + public void Should_Render_WithRefreshPolicy_Chained_After_IsContinuousAggregate_In_Fluent_Scaffold() + { + using PolicyCaContextA context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".IsContinuousAggregate(", code); + Assert.Contains(".WithRefreshPolicy(", code); + Assert.True( + code.IndexOf(".IsContinuousAggregate(", StringComparison.Ordinal) < + code.IndexOf(".WithRefreshPolicy(", StringComparison.Ordinal), + ".WithRefreshPolicy( must follow .IsContinuousAggregate( in the rendered output."); + } + + #endregion + + #region Should_Render_WithRefreshPolicy_With_StartOffset_And_EndOffset_Args_In_Fluent_Scaffold + + private class PolicySourceB { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewB { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextB : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_b"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_b"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "7 days"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"); + }); + } + } + + [Fact] + public void Should_Render_WithRefreshPolicy_With_StartOffset_And_EndOffset_Args_In_Fluent_Scaffold() + { + using PolicyCaContextB context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".WithRefreshPolicy(\"7 days\", \"1 hour\")", code); + } + + #endregion + + #region Should_Render_WithRefreshPolicy_With_Three_Args_When_ScheduleInterval_Present + + private class PolicySourceC { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewC { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextC : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_c"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_c"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "24 hours"); + }); + } + } + + [Fact] + public void Should_Render_WithRefreshPolicy_With_Three_Args_When_ScheduleInterval_Present() + { + using PolicyCaContextC context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".WithRefreshPolicy(null, null, \"24 hours\")", code); + } + + #endregion + + #region Should_Render_WithInitialStart_In_Fluent_Scaffold + + private class PolicySourceD { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewD { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextD : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_d"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_d"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_WithInitialStart_In_Fluent_Scaffold() + { + using PolicyCaContextD context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".WithInitialStart(", code); + } + + #endregion + + #region Should_Not_Render_WithRefreshPolicy_When_CA_Renderer_Did_Not_Consume_MaterializedViewName + + private class PolicySourceE { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewE { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextE : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_e"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_e"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Render_WithRefreshPolicy_When_CA_Renderer_Did_Not_Consume_MaterializedViewName() + { + using PolicyCaContextE context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Policy renderer must not emit .WithRefreshPolicy() because the CA renderer failed. + Assert.DoesNotContain(".WithRefreshPolicy(", code); + } + + #endregion + + #region Should_Render_ContinuousAggregatePolicyAttribute_In_DataAnnotations_Scaffold + + private class PolicySourceF { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewF { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextF : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_f"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_f"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "7 days"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"); + }); + } + } + + [Fact] + public void Should_Render_ContinuousAggregatePolicyAttribute_In_DataAnnotations_Scaffold() + { + using PolicyCaContextF context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? caFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(PolicyCaViewF))); + Assert.NotNull(caFile); + Assert.Contains("[ContinuousAggregatePolicy(", caFile.Code); + Assert.DoesNotContain(".WithRefreshPolicy(", result.ContextFile.Code); + } + + #endregion + + #region Should_Render_ContinuousAggregatePolicyAttribute_With_InitialStart_As_ISO8601_String + + private class PolicySourceG { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewG { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextG : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_g"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_g"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_ContinuousAggregatePolicyAttribute_With_InitialStart_As_ISO8601_String() + { + using PolicyCaContextG context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? caFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(PolicyCaViewG))); + Assert.NotNull(caFile); + Assert.Contains("InitialStart = \"2025-06-01T00:00:00", caFile.Code); + } + + #endregion + + #region Should_Render_Full_Policy_Chain_In_Fluent_Scaffold + + private class PolicySourceH { public DateTime Time { get; set; } public double Value { get; set; } } + private class PolicyCaViewH { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class PolicyCaContextH : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("render_policy_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("render_policy_ca_view_h"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "render_policy_ca_view_h"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "render_policy_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, PolicyStandardViewDef); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "7 days"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour"); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData, false); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch, 3); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution, 10); + e.HasAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst, false); + }); + } + } + + [Fact] + public void Should_Render_Full_Policy_Chain_In_Fluent_Scaffold() + { + using PolicyCaContextH context = new(); + + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + Assert.Contains(".WithRefreshPolicy(", code); + Assert.Contains(".WithInitialStart(", code); + Assert.Contains(".WithIncludeTieredData(", code); + Assert.Contains(".WithBucketsPerBatch(", code); + Assert.Contains(".WithMaxBatchesPerExecution(", code); + Assert.Contains(".WithRefreshNewestFirst(", code); + + int withRefreshPolicyIdx = code.IndexOf(".WithRefreshPolicy(", StringComparison.Ordinal); + Assert.True(withRefreshPolicyIdx < code.IndexOf(".WithInitialStart(", StringComparison.Ordinal), + ".WithInitialStart( must follow .WithRefreshPolicy(."); + Assert.True(withRefreshPolicyIdx < code.IndexOf(".WithIncludeTieredData(", StringComparison.Ordinal), + ".WithIncludeTieredData( must follow .WithRefreshPolicy(."); + Assert.True(withRefreshPolicyIdx < code.IndexOf(".WithBucketsPerBatch(", StringComparison.Ordinal), + ".WithBucketsPerBatch( must follow .WithRefreshPolicy(."); + Assert.True(withRefreshPolicyIdx < code.IndexOf(".WithMaxBatchesPerExecution(", StringComparison.Ordinal), + ".WithMaxBatchesPerExecution( must follow .WithRefreshPolicy(."); + Assert.True(withRefreshPolicyIdx < code.IndexOf(".WithRefreshNewestFirst(", StringComparison.Ordinal), + ".WithRefreshNewestFirst( must follow .WithRefreshPolicy(."); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs index 3eb253e..2a2032c 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs @@ -105,7 +105,7 @@ public async Task Should_Extract_Minimal_ContinuousAggregatePolicy() Assert.Equal("7 days", info.StartOffset); Assert.Equal("1 hour", info.EndOffset); - Assert.Equal("01:00:00", info.ScheduleInterval); + Assert.Equal("1 hour", info.ScheduleInterval); Assert.Null(info.InitialStart); Assert.Null(info.IncludeTieredData); Assert.Null(info.BucketsPerBatch); @@ -191,7 +191,7 @@ public async Task Should_Extract_Fully_Configured_ContinuousAggregatePolicy() Assert.Equal("1 month", info.StartOffset); Assert.Equal("1 hour", info.EndOffset); - Assert.Equal("00:30:00", info.ScheduleInterval); + Assert.Equal("30 minutes", info.ScheduleInterval); Assert.NotNull(info.InitialStart); DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); Assert.Equal(expectedDate, info.InitialStart.Value); diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs index 59843bd..5a5c178 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs @@ -222,7 +222,7 @@ public async Task Should_Extract_ScheduleInterval() Assert.Single(result); ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; - Assert.Equal("12:00:00", info.ScheduleInterval); + Assert.Equal("12 hours", info.ScheduleInterval); } #endregion @@ -270,7 +270,7 @@ public async Task Should_Extract_MaxRuntime() Assert.Single(result); ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; - Assert.Equal("01:00:00", info.MaxRuntime); + Assert.Equal("1 hour", info.MaxRuntime); } #endregion @@ -366,7 +366,7 @@ public async Task Should_Extract_RetryPeriod() Assert.Single(result); ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "Metrics")]; - Assert.Equal("00:10:00", info.RetryPeriod); + Assert.Equal("10 minutes", info.RetryPeriod); } #endregion @@ -490,10 +490,10 @@ public async Task Should_Extract_Fully_Configured_ReorderPolicy() Assert.NotNull(info.InitialStart); DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); Assert.Equal(expectedDate, info.InitialStart.Value); - Assert.Equal("06:00:00", info.ScheduleInterval); - Assert.Equal("02:00:00", info.MaxRuntime); + Assert.Equal("6 hours", info.ScheduleInterval); + Assert.Equal("2 hours", info.MaxRuntime); Assert.Equal(3, info.MaxRetries); - Assert.Equal("00:15:00", info.RetryPeriod); + Assert.Equal("15 minutes", info.RetryPeriod); } #endregion diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs index f6e6b29..1e27cf5 100644 --- a/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs @@ -178,9 +178,9 @@ public async Task Should_Extract_RetentionPolicy_With_All_Job_Settings() Assert.Equal("14 days", info.DropAfter); Assert.Null(info.DropCreatedBefore); Assert.Equal("1 day", info.ScheduleInterval); - Assert.Equal("02:00:00", info.MaxRuntime); + Assert.Equal("2 hours", info.MaxRuntime); Assert.Equal(5, info.MaxRetries); - Assert.Equal("00:15:00", info.RetryPeriod); + Assert.Equal("15 minutes", info.RetryPeriod); } #endregion @@ -540,11 +540,10 @@ public async Task Should_RoundTrip_RetentionPolicy_With_All_Job_Settings_And_Ini Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); Assert.Equal("14 days", table[RetentionPolicyAnnotations.DropAfter]); Assert.Null(table[RetentionPolicyAnnotations.DropCreatedBefore]); - // PostgreSQL normalizes sub-day intervals to HH:MM:SS form on the round-trip. - Assert.Equal("12:00:00", table[RetentionPolicyAnnotations.ScheduleInterval]); - Assert.Equal("01:00:00", table[RetentionPolicyAnnotations.MaxRuntime]); + Assert.Equal("12 hours", table[RetentionPolicyAnnotations.ScheduleInterval]); + Assert.Equal("1 hour", table[RetentionPolicyAnnotations.MaxRuntime]); Assert.Equal(3, table[RetentionPolicyAnnotations.MaxRetries]); - Assert.Equal("00:15:00", table[RetentionPolicyAnnotations.RetryPeriod]); + Assert.Equal("15 minutes", table[RetentionPolicyAnnotations.RetryPeriod]); DateTime expectedInitialStart = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); DateTime actualInitialStart = Assert.IsType(table[RetentionPolicyAnnotations.InitialStart]); diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index 6cdb019..90bb81c 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -518,7 +518,7 @@ public async Task Should_Scaffold_Reorder_Policy_With_Custom_Parameters() Assert.NotNull(metricsTable); Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); - Assert.Equal("12:00:00", metricsTable[ReorderPolicyAnnotations.ScheduleInterval]); + Assert.Equal("12 hours", metricsTable[ReorderPolicyAnnotations.ScheduleInterval]); Assert.Equal(5, metricsTable[ReorderPolicyAnnotations.MaxRetries]); } diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs index dee8540..e126af6 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs @@ -716,4 +716,707 @@ public void MethodChaining_Should_Support_All_Policy_Options() } #endregion + + // ── String-context builder (ContinuousAggregateStringBuilder path) ───── + + #region StringBuilder_WithRefreshPolicy_Should_Set_HasRefreshPolicy_Annotation + + private class MetricSource13 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView13 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderRefreshPolicyContext13 : 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("Metrics13"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_13", "MetricSource13", "1 hour", "Timestamp") + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour"); + }); + } + } + + [Fact] + public void StringBuilder_WithRefreshPolicy_Should_Set_HasRefreshPolicy_Annotation() + { + // Arrange + using StringBuilderRefreshPolicyContext13 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView13))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + } + + #endregion + + #region StringBuilder_WithRefreshPolicy_Should_Set_Offset_And_Schedule_Annotations + + private class MetricSource14 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView14 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderOffsetsContext14 : 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("Metrics14"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_14", "MetricSource14", "1 hour", "Timestamp") + .WithRefreshPolicy(startOffset: "7 days", endOffset: "30 minutes", scheduleInterval: "2 hours"); + }); + } + } + + [Fact] + public void StringBuilder_WithRefreshPolicy_Should_Set_Offset_And_Schedule_Annotations() + { + // Arrange + using StringBuilderOffsetsContext14 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView14))!; + + // Assert + Assert.Equal("7 days", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset)?.Value); + Assert.Equal("30 minutes", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset)?.Value); + Assert.Equal("2 hours", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region StringBuilder_WithRefreshPolicy_Should_Not_Set_Null_Or_WhiteSpace_Offsets + + private class MetricSource15 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView15 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderNullOffsetsContext15 : 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("Metrics15"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_15", "MetricSource15", "1 hour", "Timestamp") + .WithRefreshPolicy(startOffset: null, endOffset: " ", scheduleInterval: null); + }); + } + } + + [Fact] + public void StringBuilder_WithRefreshPolicy_Should_Not_Set_Null_Or_WhiteSpace_Offsets() + { + // Arrange + using StringBuilderNullOffsetsContext15 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView15))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region StringBuilder_WithInitialStart_Should_Set_Annotation + + private class MetricSource16 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView16 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderInitialStartContext16 : 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("Metrics16"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_16", "MetricSource16", "1 hour", "Timestamp") + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour") + .WithInitialStart(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void StringBuilder_WithInitialStart_Should_Set_Annotation() + { + // Arrange + using StringBuilderInitialStartContext16 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView16))!; + + // Assert + object? value = entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(value); + Assert.IsType(value); + Assert.Equal(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), (DateTime)value); + } + + #endregion + + #region StringBuilder_WithIfNotExists_Should_Set_Annotation + + private class MetricSource17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView17 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderIfNotExistsContext17 : 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("Metrics17"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_17", "MetricSource17", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithIfNotExists(true); + }); + } + } + + [Fact] + public void StringBuilder_WithIfNotExists_Should_Set_Annotation() + { + // Arrange + using StringBuilderIfNotExistsContext17 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView17))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists)?.Value); + } + + #endregion + + #region StringBuilder_WithIncludeTieredData_Should_Set_Annotation + + private class MetricSource18 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView18 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderIncludeTieredDataContext18 : 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("Metrics18"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_18", "MetricSource18", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithIncludeTieredData(false); + }); + } + } + + [Fact] + public void StringBuilder_WithIncludeTieredData_Should_Set_Annotation() + { + // Arrange + using StringBuilderIncludeTieredDataContext18 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView18))!; + + // Assert + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData)?.Value); + } + + #endregion + + #region StringBuilder_WithRefreshNewestFirst_Should_Set_Annotation + + private class MetricSource19 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView19 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderRefreshNewestFirstContext19 : 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("Metrics19"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_19", "MetricSource19", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithRefreshNewestFirst(false); + }); + } + } + + [Fact] + public void StringBuilder_WithRefreshNewestFirst_Should_Set_Annotation() + { + // Arrange + using StringBuilderRefreshNewestFirstContext19 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView19))!; + + // Assert + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)?.Value); + } + + #endregion + + #region StringBuilder_WithBucketsPerBatch_Should_Set_Annotation_When_Valid + + private class MetricSource20 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView20 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderBucketsPerBatchContext20 : 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("Metrics20"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_20", "MetricSource20", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithBucketsPerBatch(3); + }); + } + } + + [Fact] + public void StringBuilder_WithBucketsPerBatch_Should_Set_Annotation_When_Valid() + { + // Arrange + using StringBuilderBucketsPerBatchContext20 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView20))!; + + // Assert + Assert.Equal(3, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch)?.Value); + } + + #endregion + + #region StringBuilder_WithMaxBatchesPerExecution_Should_Accept_Zero + + private class MetricSource21 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView21 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderMaxBatchesZeroContext21 : 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("Metrics21"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_21", "MetricSource21", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithMaxBatchesPerExecution(0); + }); + } + } + + [Fact] + public void StringBuilder_WithMaxBatchesPerExecution_Should_Accept_Zero() + { + // Arrange + using StringBuilderMaxBatchesZeroContext21 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView21))!; + + // Assert + Assert.Equal(0, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)?.Value); + } + + #endregion + + #region StringBuilder_MethodChaining_Should_Support_All_Policy_Options + + private class MetricSource22 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView22 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderFullChainContext22 : 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("Metrics22"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_22", "MetricSource22", "1 hour", "Timestamp") + .WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "30 minutes") + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .WithIfNotExists(true) + .WithIncludeTieredData(false) + .WithBucketsPerBatch(3) + .WithMaxBatchesPerExecution(10) + .WithRefreshNewestFirst(false); + }); + } + } + + [Fact] + public void StringBuilder_MethodChaining_Should_Support_All_Policy_Options() + { + // Arrange + using StringBuilderFullChainContext22 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateView22))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset)?.Value); + Assert.Equal("30 minutes", entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists)?.Value); + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData)?.Value); + Assert.Equal(3, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch)?.Value); + Assert.Equal(10, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)?.Value); + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)?.Value); + } + + #endregion + + #region StringBuilder_WithBucketsPerBatch_Should_Throw_When_Zero + + private class MetricSource23 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView23 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderBucketsPerBatchInvalidContext23(int bucketsPerBatch) : 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("Metrics23"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_23", "MetricSource23", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithBucketsPerBatch(bucketsPerBatch); + }); + } + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void StringBuilder_WithBucketsPerBatch_Should_Throw_When_Zero(int bucketsPerBatch) + { + // Arrange, Act, Assert + Assert.Throws(() => + { + using StringBuilderBucketsPerBatchInvalidContext23 context = new(bucketsPerBatch); + IModel model = GetModel(context); + }); + } + + #endregion + + #region StringBuilder_WithMaxBatchesPerExecution_Should_Throw_When_Negative + + private class MetricSource24 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateView24 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderMaxBatchesNegativeContext24(int maxBatches) : 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("Metrics24"); + entity.HasNoKey(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate("sb_hourly_metrics_24", "MetricSource24", "1 hour", "Timestamp") + .WithRefreshPolicy() + .WithMaxBatchesPerExecution(maxBatches); + }); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(-100)] + public void StringBuilder_WithMaxBatchesPerExecution_Should_Throw_When_Negative(int maxBatches) + { + // Arrange, Act, Assert + Assert.Throws(() => + { + using StringBuilderMaxBatchesNegativeContext24 context = new(maxBatches); + IModel model = GetModel(context); + }); + } + + #endregion } From f87a8425a769a288066ce7134302935c50c06fcf Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 21 Jul 2026 18:31:53 +0200 Subject: [PATCH 07/12] feat: scaffold retention policies to typed fluent API and data annotations --- .claude/reference/architecture.md | 8 +- .claude/reference/file-organization.md | 4 +- .../RetentionPolicyAnnotationRenderer.cs | 159 +++ .../TimescaleDbAnnotationCodeGenerator.cs | 1 + .../ContinuousAggregatePolicyStringBuilder.cs | 3 + .../RetentionPolicyStringBuilder.cs | 31 + .../RetentionPolicyTypeBuilder.cs | 125 +++ .../RetentionPolicyAnnotationRendererTests.cs | 980 ++++++++++++++++++ .../ScaffoldedModelRenderingTests.cs | 516 +++++++++ .../RetentionPolicyTypeBuilderTests.cs | 654 ++++++++++++ 10 files changed, 2477 insertions(+), 4 deletions(-) create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs create mode 100644 src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index b37b8b1..4dcfafd 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -55,11 +55,12 @@ This document provides detailed architectural information for the CmdScale.Entit - `ReorderPolicyAnnotations.cs` - Annotation constants - `ReorderPolicyTypeBuilder.cs` - Fluent API: `WithReorderPolicy()` -#### RetentionPolicy/ (4 files) +#### RetentionPolicy/ (5 files) - `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy(DropAfter = "30 days")]` - `RetentionPolicyConvention.cs` - IEntityTypeAddedConvention implementation - `RetentionPolicyAnnotations.cs` - Annotation constants -- `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()` +- `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()`; includes a scaffold-targeting overload that takes 6 positional string parameters and returns `RetentionPolicyStringBuilder` +- `RetentionPolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code; exposes `WithInitialStart(DateTime)` as a chained method (DateTime cannot be rendered as a positional literal via `MethodCallCodeFragment`) #### ContinuousAggregate/ (11 files) - `ContinuousAggregateAttribute.cs` - Entity-level attribute defining materialized view @@ -226,6 +227,7 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a | `AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable and dimension annotations to fluent API or data annotation attributes | | `AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations; parses the stored view definition via `ViewDefinitionParser` to reconstruct structured configuration | | `AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | +| `AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; `ShouldRender` guard requires the parent renderer (hypertable or continuous aggregate) to have already consumed its annotation | | `AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared static helpers for rendering optional policy-job fields (InitialStart, ScheduleInterval, MaxRuntime, etc.) shared across all policy renderers | | `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | | `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | @@ -247,7 +249,7 @@ EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to con - When `UseDataAnnotations = false` → `GenerateFluentApiCalls` → fluent API method chains in `OnModelCreating` - When `UseDataAnnotations = true` → `GenerateDataAnnotationAttributes` → `[Attribute]` declarations on entity classes -Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`. +Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`. Registration order matters: child renderers (`ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`) must run after their respective parent renderers so the `ShouldRender` guard can verify the parent annotation was consumed. `TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 7025aa3..a42a77e 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -50,7 +50,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | File | Purpose | |------|---------| -| `Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs` | Fluent API | +| `Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs` | Fluent API (including scaffold-targeting overload) | +| `Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs` | String-based builder used in scaffolded code | | `Configuration/RetentionPolicy/RetentionPolicyAnnotations.cs` | Annotation constants | | `Configuration/RetentionPolicy/RetentionPolicyAttribute.cs` | Data annotation | | `Configuration/RetentionPolicy/RetentionPolicyConvention.cs` | Convention processing | @@ -158,6 +159,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs` | Renders hypertable annotations to fluent API or data annotation C# | | `Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations by parsing the view definition | | `Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | +| `Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; registered after parent renderers (hypertable and continuous aggregate) | | `Generators/AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared helpers for emitting policy-job optional arguments (`InitialStart`, `WithScheduleInterval`, etc.) | | `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | | `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs new file mode 100644 index 0000000..5c9344a --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs @@ -0,0 +1,159 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.PolicyJobRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders TimescaleDB:HasRetentionPolicy and related annotations as a + /// .WithRetentionPolicy(...) Fluent API chain or a [RetentionPolicy(...)] + /// attribute. + /// + internal sealed class RetentionPolicyAnnotationRenderer : IFeatureAnnotationRenderer + { + private static readonly MethodInfo WithRetentionPolicyMethod = + typeof(RetentionPolicyTypeBuilder) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(RetentionPolicyTypeBuilder.WithRetentionPolicy) + && m.GetParameters().Length == 7 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<>) + && m.GetParameters()[3].ParameterType == typeof(string) + && m.ReturnType.IsGenericType + && m.ReturnType.GetGenericTypeDefinition() == typeof(RetentionPolicyStringBuilder<>)); + + private static readonly MethodInfo WithInitialStartMethod = + typeof(RetentionPolicyStringBuilder<>) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == nameof(RetentionPolicyStringBuilder<>.WithInitialStart)); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? dropAfter = GetString(annotations, RetentionPolicyAnnotations.DropAfter); + string? dropCreatedBefore = GetString(annotations, RetentionPolicyAnnotations.DropCreatedBefore); + string? scheduleInterval = GetString(annotations, RetentionPolicyAnnotations.ScheduleInterval); + string? maxRuntime = GetString(annotations, RetentionPolicyAnnotations.MaxRuntime); + int? maxRetries = Find(annotations, RetentionPolicyAnnotations.MaxRetries)?.Value is int r ? r : null; + string? retryPeriod = GetString(annotations, RetentionPolicyAnnotations.RetryPeriod); + + object?[] entryArgs = BuildRetentionPolicyArgs(dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + MethodCallCodeFragment call = new(WithRetentionPolicyMethod, entryArgs); + + call = ChainInitialStart(call, annotations, RetentionPolicyAnnotations.InitialStart, WithInitialStartMethod); + + ConsumeAllRetentionAnnotations(annotations); + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? dropAfter = GetString(annotations, RetentionPolicyAnnotations.DropAfter); + string? dropCreatedBefore = GetString(annotations, RetentionPolicyAnnotations.DropCreatedBefore); + object?[] positionalArgs = dropAfter is not null + ? [dropAfter] + : [null, dropCreatedBefore]; + Dictionary namedArgs = []; + + string? scheduleInterval = GetString(annotations, RetentionPolicyAnnotations.ScheduleInterval); + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + { + namedArgs[nameof(RetentionPolicyAttribute.ScheduleInterval)] = scheduleInterval; + } + + string? maxRuntime = GetString(annotations, RetentionPolicyAnnotations.MaxRuntime); + if (!string.IsNullOrWhiteSpace(maxRuntime)) + { + namedArgs[nameof(RetentionPolicyAttribute.MaxRuntime)] = maxRuntime; + } + + AddInitialStartNamedArg( + annotations, + RetentionPolicyAnnotations.InitialStart, + nameof(RetentionPolicyAttribute.InitialStart), + namedArgs); + + if (Find(annotations, RetentionPolicyAnnotations.MaxRetries)?.Value is int maxRetries) + { + namedArgs[nameof(RetentionPolicyAttribute.MaxRetries)] = maxRetries; + } + + string? retryPeriod = GetString(annotations, RetentionPolicyAnnotations.RetryPeriod); + if (!string.IsNullOrWhiteSpace(retryPeriod)) + { + namedArgs[nameof(RetentionPolicyAttribute.RetryPeriod)] = retryPeriod; + } + + ConsumeAllRetentionAnnotations(annotations); + return [new AttributeCodeFragment(typeof(RetentionPolicyAttribute), positionalArgs, namedArgs)]; + } + + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return; + } + + ConsumeAllRetentionAnnotations(annotations); + } + + /// + /// Guards all three rendering paths: a retention policy annotation is only emitted when a + /// parent feature renderer already succeeded. The hypertable renderer consumes + /// IsHypertable on success; the continuous aggregate renderer consumes + /// MaterializedViewName on success. If either key annotation is still present in the + /// dictionary the corresponding parent renderer failed or was not applicable, and the + /// retention policy annotations must be left for the .HasAnnotation fallback. + /// + private static bool ShouldRender(IDictionary annotations) + => Find(annotations, RetentionPolicyAnnotations.HasRetentionPolicy)?.Value is true + && Find(annotations, HypertableAnnotations.IsHypertable) is null + && Find(annotations, ContinuousAggregateAnnotations.MaterializedViewName) is null; + + /// + /// Builds positional arguments for the scaffold-targeting WithRetentionPolicy overload. + /// All six arguments are always emitted: trimmed forms resolve against the user-facing overload + /// whose third parameter is initialStart (DateTime?), which either fails to compile + /// or silently maps later arguments onto the wrong parameters. + /// + private static object?[] BuildRetentionPolicyArgs( + string? dropAfter, + string? dropCreatedBefore, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) + => [dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod]; + + private static void ConsumeAllRetentionAnnotations(IDictionary annotations) + { + Consume(annotations, + RetentionPolicyAnnotations.HasRetentionPolicy, + RetentionPolicyAnnotations.DropAfter, + RetentionPolicyAnnotations.DropCreatedBefore, + RetentionPolicyAnnotations.InitialStart, + RetentionPolicyAnnotations.ScheduleInterval, + RetentionPolicyAnnotations.MaxRuntime, + RetentionPolicyAnnotations.MaxRetries, + RetentionPolicyAnnotations.RetryPeriod); + } + } +} diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index 0576879..7910b8b 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -33,6 +33,7 @@ public sealed class TimescaleDbAnnotationCodeGenerator( new HypertableAnnotationRenderer(), new ContinuousAggregateAnnotationRenderer(reporter), new ContinuousAggregatePolicyAnnotationRenderer(), + new RetentionPolicyAnnotationRenderer(), ]; private readonly Dictionary> _entityAttributeCache = []; diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs index 752f748..51b3abd 100644 --- a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyStringBuilder.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy { @@ -12,6 +13,8 @@ public sealed class ContinuousAggregatePolicyStringBuilder where TEntit { private readonly ContinuousAggregateStringBuilder _builder; + internal EntityTypeBuilder EntityTypeBuilder => _builder.EntityTypeBuilder; + internal ContinuousAggregatePolicyStringBuilder(ContinuousAggregateStringBuilder builder) { _builder = builder; diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs new file mode 100644 index 0000000..855ec11 --- /dev/null +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy +{ + /// + /// Provides a fluent API for configuring optional TimescaleDB retention policy parameters + /// whose types cannot be rendered by the scaffold code generator (e.g. ). + /// Returned by the scaffold-targeting overload of WithRetentionPolicy. + /// + /// The entity type the retention policy is applied to. + public sealed class RetentionPolicyStringBuilder where TEntity : class + { + private readonly EntityTypeBuilder _builder; + + internal RetentionPolicyStringBuilder(EntityTypeBuilder builder) + { + _builder = builder; + } + + /// + /// Sets the initial start time for the retention policy job. + /// + /// The first time the policy job is scheduled to run. + /// The builder for method chaining. + public RetentionPolicyStringBuilder WithInitialStart(DateTime initialStart) + { + _builder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, initialStart); + return this; + } + } +} diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs index 6a5b38b..7c54c55 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs @@ -1,3 +1,5 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy @@ -85,5 +87,128 @@ public static EntityTypeBuilder WithRetentionPolicy( return entityTypeBuilder; } + + /// + /// Configures a TimescaleDB retention policy for the entity using the scaffold-targeting overload. + /// Exactly one of or must be specified. + /// + /// The type of the entity being configured. + /// The builder for the entity type being configured. + /// The interval after which chunks are dropped. Mutually exclusive with . + /// The interval before which chunks created are dropped. Mutually exclusive with . + /// The interval at which the retention policy job runs. + /// The maximum amount of time the job is allowed to run. + /// The number of times the job is retried if it fails. + /// The amount of time the scheduler waits between retries. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static RetentionPolicyStringBuilder WithRetentionPolicy( + this EntityTypeBuilder entityTypeBuilder, + string? dropAfter, + string? dropCreatedBefore, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) where TEntity : class + { + WriteRetentionPolicy(entityTypeBuilder, dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + return new RetentionPolicyStringBuilder(entityTypeBuilder); + } + + /// + /// Configures a TimescaleDB retention policy for a continuous aggregate, chained after the + /// continuous aggregate configuration. Used by code generated by dotnet ef dbcontext scaffold. + /// Exactly one of or must be specified. + /// + /// The class representing the continuous aggregate view. + /// The continuous aggregate builder being configured. + /// The interval after which chunks are dropped. Mutually exclusive with . + /// The interval before which chunks created are dropped. Mutually exclusive with . + /// The interval at which the retention policy job runs. + /// The maximum amount of time the job is allowed to run. + /// The number of times the job is retried if it fails. + /// The amount of time the scheduler waits between retries. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static RetentionPolicyStringBuilder WithRetentionPolicy( + this ContinuousAggregateStringBuilder builder, + string? dropAfter, + string? dropCreatedBefore, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) where TEntity : class + { + WriteRetentionPolicy(builder.EntityTypeBuilder, dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + return new RetentionPolicyStringBuilder(builder.EntityTypeBuilder); + } + + /// + /// Configures a TimescaleDB retention policy for a continuous aggregate, chained after the + /// refresh policy configuration. Used by code generated by dotnet ef dbcontext scaffold. + /// Exactly one of or must be specified. + /// + /// The class representing the continuous aggregate view. + /// The refresh policy builder being configured. + /// The interval after which chunks are dropped. Mutually exclusive with . + /// The interval before which chunks created are dropped. Mutually exclusive with . + /// The interval at which the retention policy job runs. + /// The maximum amount of time the job is allowed to run. + /// The number of times the job is retried if it fails. + /// The amount of time the scheduler waits between retries. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static RetentionPolicyStringBuilder WithRetentionPolicy( + this ContinuousAggregatePolicyStringBuilder builder, + string? dropAfter, + string? dropCreatedBefore, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) where TEntity : class + { + WriteRetentionPolicy(builder.EntityTypeBuilder, dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + return new RetentionPolicyStringBuilder(builder.EntityTypeBuilder); + } + + private static void WriteRetentionPolicy( + EntityTypeBuilder entityTypeBuilder, + string? dropAfter, + string? dropCreatedBefore, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) + { + bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); + bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); + + if (hasDropAfter && hasDropCreatedBefore) + { + throw new InvalidOperationException("WithRetentionPolicy: 'dropAfter' and 'dropCreatedBefore' are mutually exclusive. Specify exactly one."); + } + + if (!hasDropAfter && !hasDropCreatedBefore) + { + throw new InvalidOperationException("WithRetentionPolicy: Exactly one of 'dropAfter' or 'dropCreatedBefore' must be specified."); + } + + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + + if (hasDropAfter) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropAfter, dropAfter!); + + if (hasDropCreatedBefore) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, dropCreatedBefore!); + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, scheduleInterval); + + if (!string.IsNullOrWhiteSpace(maxRuntime)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRuntime, maxRuntime); + + if (maxRetries.HasValue) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRetries, maxRetries.Value); + + if (!string.IsNullOrWhiteSpace(retryPeriod)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.RetryPeriod, retryPeriod); + } } } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs new file mode 100644 index 0000000..d473c4d --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs @@ -0,0 +1,980 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for RetentionPolicyAnnotationRenderer exercised through the public +/// surface. +/// The renderer runs AFTER HypertableAnnotationRenderer and +/// ContinuousAggregateAnnotationRenderer and uses the ABSENCE of IsHypertable and +/// MaterializedViewName to decide whether to emit retention policy code. +/// +public class RetentionPolicyAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator + { + get + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; + } + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private static List FlattenChain(MethodCallCodeFragment? fragment) + { + List all = []; + for (MethodCallCodeFragment? c = fragment; c != null; c = c.ChainedCall) all.Add(c); + return all; + } + + private class RetentionRendererEntity + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class RetentionRendererContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("retention_renderer_entity"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + } + + // ── Guard tests ─────────────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_HasRetentionPolicy_Absent + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_HasRetentionPolicy_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy), + "HasRetentionPolicy annotation must remain unconsumed when ShouldRender is false."); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter), + "DropAfter annotation must remain unconsumed when ShouldRender is false."); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_MaterializedViewName_Still_Present + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_MaterializedViewName_Still_Present() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (ContinuousAggregateAnnotations.MaterializedViewName, "some_view")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy), + "HasRetentionPolicy annotation must remain unconsumed when ShouldRender is false."); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter), + "DropAfter annotation must remain unconsumed when ShouldRender is false."); + } + + #endregion + + #region GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed_And_MaterializedViewName_Absent + + [Fact] + public void GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed_And_MaterializedViewName_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + } + + #endregion + + #region GenerateFluentApiCalls_Renders_When_MaterializedViewName_Consumed_And_IsHypertable_Absent + + [Fact] + public void GenerateFluentApiCalls_Renders_When_MaterializedViewName_Consumed_And_IsHypertable_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_HasRetentionPolicy_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_HasRetentionPolicy_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(RetentionPolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(RetentionPolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_MaterializedViewName_Still_Present + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_MaterializedViewName_Still_Present() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (ContinuousAggregateAnnotations.MaterializedViewName, "some_view")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(RetentionPolicyAttribute)); + } + + #endregion + + // ── Fluent positional argument tests (always all six args) ──────────────── + + #region GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_Only_DropAfter + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_Only_DropAfter() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Equal("7 days", withRetentionPolicy.Arguments[0]); + Assert.All(withRetentionPolicy.Arguments.Skip(1), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_Only_DropCreatedBefore + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_Only_DropCreatedBefore() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropCreatedBefore, "30 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Null(withRetentionPolicy.Arguments[0]); + Assert.Equal("30 days", withRetentionPolicy.Arguments[1]); + Assert.All(withRetentionPolicy.Arguments.Skip(2), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_ScheduleInterval + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_ScheduleInterval() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Equal("7 days", withRetentionPolicy.Arguments[0]); + Assert.Null(withRetentionPolicy.Arguments[1]); + Assert.Equal("1 day", withRetentionPolicy.Arguments[2]); + Assert.All(withRetentionPolicy.Arguments.Skip(3), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_MaxRuntime + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_MaxRuntime() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.MaxRuntime, "01:00:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Equal("7 days", withRetentionPolicy.Arguments[0]); + Assert.Null(withRetentionPolicy.Arguments[1]); + Assert.Null(withRetentionPolicy.Arguments[2]); + Assert.Equal("01:00:00", withRetentionPolicy.Arguments[3]); + Assert.All(withRetentionPolicy.Arguments.Skip(4), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_MaxRetries + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_Emits_All_Args_When_DropAfter_And_MaxRetries() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.MaxRetries, 5)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Equal("7 days", withRetentionPolicy.Arguments[0]); + Assert.Null(withRetentionPolicy.Arguments[1]); + Assert.Null(withRetentionPolicy.Arguments[2]); + Assert.Null(withRetentionPolicy.Arguments[3]); + Assert.Equal(5, withRetentionPolicy.Arguments[4]); + Assert.Null(withRetentionPolicy.Arguments[5]); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_SixArgs_When_All_Six_Set + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_SixArgs_When_All_Six_Set() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day"), + (RetentionPolicyAnnotations.MaxRuntime, "01:00:00"), + (RetentionPolicyAnnotations.MaxRetries, 3), + (RetentionPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Equal("7 days", withRetentionPolicy.Arguments[0]); + Assert.Null(withRetentionPolicy.Arguments[1]); + Assert.Equal("1 day", withRetentionPolicy.Arguments[2]); + Assert.Equal("01:00:00", withRetentionPolicy.Arguments[3]); + Assert.Equal(3, withRetentionPolicy.Arguments[4]); + Assert.Equal("00:10:00", withRetentionPolicy.Arguments[5]); + } + + #endregion + + #region GenerateFluentApiCalls_WithRetentionPolicy_SixArgs_When_DropCreatedBefore_And_RetryPeriod + + [Fact] + public void GenerateFluentApiCalls_WithRetentionPolicy_SixArgs_When_DropCreatedBefore_And_RetryPeriod() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropCreatedBefore, "30 days"), + (RetentionPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withRetentionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithRetentionPolicy"); + + Assert.NotNull(withRetentionPolicy); + Assert.Equal(6, withRetentionPolicy.Arguments.Count); + Assert.Null(withRetentionPolicy.Arguments[0]); + Assert.Equal("30 days", withRetentionPolicy.Arguments[1]); + Assert.Null(withRetentionPolicy.Arguments[2]); + Assert.Null(withRetentionPolicy.Arguments[3]); + Assert.Null(withRetentionPolicy.Arguments[4]); + Assert.Equal("00:10:00", withRetentionPolicy.Arguments[5]); + } + + #endregion + + // ── InitialStart chaining tests ─────────────────────────────────────────── + + #region GenerateFluentApiCalls_Chains_WithInitialStart_When_Present + + [Fact] + public void GenerateFluentApiCalls_Chains_WithInitialStart_When_Present() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? initialStartFrag = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithInitialStart"); + + Assert.NotNull(initialStartFrag); + Assert.Equal(initialStart, initialStartFrag.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.InitialStart, "2025-06-01T00:00:00Z")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Chain_Order_Is_WithRetentionPolicy_Then_WithInitialStart + + [Fact] + public void GenerateFluentApiCalls_Chain_Order_Is_WithRetentionPolicy_Then_WithInitialStart() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => CollectMethodChain(f).Contains("WithRetentionPolicy")); + List chain = CollectMethodChain(root); + + int retentionIdx = chain.IndexOf("WithRetentionPolicy"); + int initialStartIdx = chain.IndexOf("WithInitialStart"); + + Assert.True(retentionIdx < initialStartIdx, + "WithRetentionPolicy must precede WithInitialStart in the chain."); + } + + #endregion + + // ── Annotation consumption tests ────────────────────────────────────────── + + #region GenerateFluentApiCalls_Consumes_All_Retention_Annotations_After_Render + + [Fact] + public void GenerateFluentApiCalls_Consumes_All_Retention_Annotations_After_Render() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day"), + (RetentionPolicyAnnotations.MaxRuntime, "01:00:00"), + (RetentionPolicyAnnotations.MaxRetries, 3), + (RetentionPolicyAnnotations.RetryPeriod, "00:10:00"), + (RetentionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.MaxRuntime)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.MaxRetries)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.RetryPeriod)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True + + [Fact] + public void ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day")); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False + + [Fact] + public void ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (ContinuousAggregateAnnotations.MaterializedViewName, "some_view")); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.True(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter)); + } + + #endregion + + // ── Data-annotation attribute generation tests ──────────────────────────── + + #region GenerateDataAnnotationAttributes_DropAfter_As_Positional_Arg_0 + + [Fact] + public void GenerateDataAnnotationAttributes_DropAfter_As_Positional_Arg_0() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + object? positionalArg = Assert.Single(attr.Arguments); + Assert.Equal("7 days", positionalArg); + } + + #endregion + + #region GenerateDataAnnotationAttributes_DropCreatedBefore_Only_Has_Two_Positional_Args + + [Fact] + public void GenerateDataAnnotationAttributes_DropCreatedBefore_Only_Has_Two_Positional_Args() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropCreatedBefore, "30 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal(2, attr.Arguments.Count); + Assert.Null(attr.Arguments[0]); + Assert.Equal("30 days", attr.Arguments[1]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("1 day", attr.NamedArguments[nameof(RetentionPolicyAttribute.ScheduleInterval)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_MaxRuntime_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_MaxRuntime_As_Named_Arg() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.MaxRuntime, "01:00:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("01:00:00", attr.NamedArguments[nameof(RetentionPolicyAttribute.MaxRuntime)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_MaxRetries_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_MaxRetries_As_Named_Arg() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.MaxRetries, 5)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal(5, attr.NamedArguments[nameof(RetentionPolicyAttribute.MaxRetries)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_RetryPeriod_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_RetryPeriod_As_Named_Arg() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("00:10:00", attr.NamedArguments[nameof(RetentionPolicyAttribute.RetryPeriod)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(RetentionPolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(initialStart, parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Local_DateTime_Converted_To_UTC_For_InitialStart + + [Fact] + public void GenerateDataAnnotationAttributes_Local_DateTime_Converted_To_UTC_For_InitialStart() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime localTime = new(2025, 6, 1, 12, 0, 0, DateTimeKind.Local); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.InitialStart, localTime)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(RetentionPolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(localTime.ToUniversalTime(), parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Empty(attr.NamedArguments); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Consumes_All_Retention_Annotations_After_Render + + [Fact] + public void GenerateDataAnnotationAttributes_Consumes_All_Retention_Annotations_After_Render() + { + // Arrange + using RetentionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (RetentionPolicyAnnotations.HasRetentionPolicy, true), + (RetentionPolicyAnnotations.DropAfter, "7 days"), + (RetentionPolicyAnnotations.ScheduleInterval, "1 day"), + (RetentionPolicyAnnotations.MaxRuntime, "01:00:00"), + (RetentionPolicyAnnotations.MaxRetries, 3), + (RetentionPolicyAnnotations.RetryPeriod, "00:10:00"), + (RetentionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + CreateAnnotationCodeGenerator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.DropAfter)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.MaxRuntime)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.MaxRetries)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.RetryPeriod)); + Assert.False(annotations.ContainsKey(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs index 0ac396c..1ac9d91 100644 --- a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.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.RetentionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Design; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -638,5 +639,520 @@ public void Should_Render_Full_Policy_Chain_In_Fluent_Scaffold() } #endregion + + // ── Retention policy rendering ──────────────────────────────────────────── + + #region Should_Render_WithRetentionPolicy_Chained_In_Fluent_Scaffold_For_Hypertable + + private class RpHtSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpHtContextA : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_ht_source_a"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_Chained_In_Fluent_Scaffold_For_Hypertable() + { + // Arrange & Act + using RpHtContextA context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".IsHypertable(", code); + Assert.Contains(".WithRetentionPolicy(", code); + } + + #endregion + + #region Should_Render_WithRetentionPolicy_With_DropAfter_Only_In_Fluent_Scaffold + + private class RpSingleArgSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpSingleArgContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_single_arg_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_With_DropAfter_Only_In_Fluent_Scaffold() + { + // Arrange & Act + using RpSingleArgContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(\"7 days\", null, null, null, null, null)", code); + } + + #endregion + + #region Should_Render_WithRetentionPolicy_With_ScheduleInterval_In_Fluent_Scaffold + + private class RpThreeArgsSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpThreeArgsContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_three_args_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + e.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, "1 day"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_With_ScheduleInterval_In_Fluent_Scaffold() + { + // Arrange & Act + using RpThreeArgsContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(\"7 days\", null, \"1 day\", null, null, null)", code); + } + + #endregion + + #region Should_Render_WithRetentionPolicy_Six_Args_In_Fluent_Scaffold + + private class RpSixArgsSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpSixArgsContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_six_args_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + e.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, "1 day"); + e.HasAnnotation(RetentionPolicyAnnotations.MaxRuntime, "01:00:00"); + e.HasAnnotation(RetentionPolicyAnnotations.MaxRetries, 3); + e.HasAnnotation(RetentionPolicyAnnotations.RetryPeriod, "00:10:00"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_Six_Args_In_Fluent_Scaffold() + { + // Arrange & Act + using RpSixArgsContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(\"7 days\", null, \"1 day\", \"01:00:00\", 3, \"00:10:00\")", code); + } + + #endregion + + #region Should_Render_WithRetentionPolicy_With_DropCreatedBefore_In_Fluent_Scaffold + + private class RpDropCreatedBeforeSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpDropCreatedBeforeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_drop_created_before_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, "30 days"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_With_DropCreatedBefore_In_Fluent_Scaffold() + { + // Arrange & Act + using RpDropCreatedBeforeContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(null, \"30 days\", null, null, null, null)", code); + } + + #endregion + + #region Should_Render_WithInitialStart_Chained_After_WithRetentionPolicy_In_Fluent_Scaffold + + private class RpInitialStartSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpInitialStartContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_initial_start_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + e.HasAnnotation(RetentionPolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_WithInitialStart_Chained_After_WithRetentionPolicy_In_Fluent_Scaffold() + { + // Arrange & Act + using RpInitialStartContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(", code); + Assert.Contains(".WithInitialStart(", code); + Assert.True( + code.IndexOf(".WithRetentionPolicy(", StringComparison.Ordinal) < + code.IndexOf(".WithInitialStart(", StringComparison.Ordinal), + ".WithInitialStart( must follow .WithRetentionPolicy(."); + } + + #endregion + + #region Should_Render_WithRetentionPolicy_For_ContinuousAggregate_In_Fluent_Scaffold + + private class RpCaSourceEntity { public DateTime Time { get; set; } public double Value { get; set; } } + private class RpCaViewEntity { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class RpCaContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_ca_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("rp_ca_view"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "rp_ca_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "rp_ca_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, rp_ca_source.\"time\") AS bucket," + + " avg(rp_ca_source.value) AS avg_value" + + " FROM rp_ca_source" + + " GROUP BY time_bucket('01:00:00'::interval, rp_ca_source.\"time\")"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "30 days"); + }); + } + } + + [Fact] + public void Should_Render_WithRetentionPolicy_For_ContinuousAggregate_In_Fluent_Scaffold() + { + // Arrange & Act + using RpCaContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".IsContinuousAggregate(", code); + Assert.Contains(".WithRetentionPolicy(", code); + } + + #endregion + + #region Should_Not_Render_WithRetentionPolicy_When_IsHypertable_Still_Present_In_Fluent_Scaffold + + private class RpGuardHtSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpGuardHtContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_guard_ht_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Render_WithRetentionPolicy_When_IsHypertable_Still_Present_In_Fluent_Scaffold() + { + // Arrange & Act + using RpGuardHtContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.DoesNotContain(".WithRetentionPolicy(", code); + } + + #endregion + + #region Should_Not_Render_WithRetentionPolicy_When_MaterializedViewName_Still_Present_In_Fluent_Scaffold + + private class RpGuardCaSource { public DateTime Time { get; set; } public double Value { get; set; } } + private class RpGuardCaView { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class RpGuardCaContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Views => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_guard_ca_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("rp_guard_ca_view"); + e.Property(x => x.Bucket).HasColumnName("bucket"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "rp_guard_ca_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "rp_guard_ca_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, "UNPARSEABLE SQL"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Render_WithRetentionPolicy_When_MaterializedViewName_Still_Present_In_Fluent_Scaffold() + { + // Arrange & Act + using RpGuardCaContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.DoesNotContain(".WithRetentionPolicy(", code); + } + + #endregion + + #region Should_Render_RetentionPolicyAttribute_In_DataAnnotations_Scaffold + + private class RpDaSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpDaContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_da_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Render_RetentionPolicyAttribute_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RpDaContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RpDaSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.Contains("[RetentionPolicy(", entityFile.Code); + Assert.DoesNotContain(".WithRetentionPolicy(", result.ContextFile.Code); + } + + #endregion + + #region Should_Render_RetentionPolicyAttribute_With_InitialStart_As_ISO8601_In_DataAnnotations_Scaffold + + private class RpDaInitialStartSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpDaInitialStartContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_da_initial_start_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + e.HasAnnotation(RetentionPolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_RetentionPolicyAttribute_With_InitialStart_As_ISO8601_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RpDaInitialStartContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RpDaInitialStartSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.Contains("InitialStart = \"2025-06-01T00:00:00", entityFile.Code); + } + + #endregion + + #region Should_Not_Render_RetentionPolicyAttribute_When_IsHypertable_Not_Consumed_In_DataAnnotations_Scaffold + + private class RpDaGuardSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RpDaGuardContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rp_da_guard_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Render_RetentionPolicyAttribute_When_IsHypertable_Not_Consumed_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RpDaGuardContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RpDaGuardSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.DoesNotContain("[RetentionPolicy(", entityFile.Code); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs index 4218220..bfa0611 100644 --- a/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs @@ -734,4 +734,658 @@ public void WithRetentionPolicy_Should_Return_EntityTypeBuilder_For_Chaining() } #endregion + + #region ScaffoldOverload_Should_Set_HasRetentionPolicy_And_DropAfter + + private class ScaffoldDropAfterEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldDropAfterContext : DbContext + { + public DbSet Items => 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("scaffold_drop_after"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Set_HasRetentionPolicy_And_DropAfter() + { + // Arrange & Act + using ScaffoldDropAfterContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldDropAfterEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)); + } + + #endregion + + #region ScaffoldOverload_Should_Set_HasRetentionPolicy_And_DropCreatedBefore + + private class ScaffoldDropCreatedBeforeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldDropCreatedBeforeContext : DbContext + { + public DbSet Items => 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("scaffold_drop_created_before"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: null, + dropCreatedBefore: "30 days", + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Set_HasRetentionPolicy_And_DropCreatedBefore() + { + // Arrange & Act + using ScaffoldDropCreatedBeforeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldDropCreatedBeforeEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)); + } + + #endregion + + #region ScaffoldOverload_Should_Set_Optional_Annotations_When_Provided + + private class ScaffoldAllParamsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldAllParamsContext : DbContext + { + public DbSet Items => 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("scaffold_all_params"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: "1 day", + maxRuntime: "01:00:00", + maxRetries: 3, + retryPeriod: "00:10:00"); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Set_Optional_Annotations_When_Provided() + { + // Arrange & Act + using ScaffoldAllParamsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldAllParamsEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("01:00:00", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:10:00", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region ScaffoldOverload_Should_Not_Set_Optional_Annotations_When_All_Null + + private class ScaffoldMinimalParamsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldMinimalParamsContext : DbContext + { + public DbSet Items => 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("scaffold_minimal_params"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Not_Set_Optional_Annotations_When_All_Null() + { + // Arrange & Act + using ScaffoldMinimalParamsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldMinimalParamsEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion + + #region ScaffoldOverload_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified + + private class ScaffoldBothEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldBothContext : DbContext + { + public DbSet Items => 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("scaffold_both"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: "30 days", + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified() + { + // Arrange + using ScaffoldBothContext context = new(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("mutually exclusive", exception.Message); + } + + #endregion + + #region ScaffoldOverload_Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified + + private class ScaffoldNeitherEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldNeitherContext : DbContext + { + public DbSet Items => 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("scaffold_neither"); + entity.WithRetentionPolicy( + dropAfter: null, + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified() + { + // Arrange + using ScaffoldNeitherContext context = new(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("Exactly one", exception.Message); + } + + #endregion + + #region ScaffoldOverload_Should_Return_RetentionPolicyStringBuilder_For_Chaining + + private class ScaffoldStringBuilderEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldStringBuilderContext : DbContext + { + public DbSet Items => 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("scaffold_string_builder"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null) + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Return_RetentionPolicyStringBuilder_For_Chaining() + { + // Arrange & Act + using ScaffoldStringBuilderContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldStringBuilderEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.NotNull(entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion + + #region ScaffoldOverload_Should_Not_Write_InitialStart_Without_StringBuilder_Chain + + private class ScaffoldNoInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldNoInitialStartContext : DbContext + { + public DbSet Items => 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("scaffold_no_initial_start"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_Should_Not_Write_InitialStart_Without_StringBuilder_Chain() + { + // Arrange & Act + using ScaffoldNoInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldNoInitialStartEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion + + // ── RetentionPolicyStringBuilder tests ─────────────────────────────────── + + #region StringBuilder_WithInitialStart_Sets_Annotation + + private class StringBuilderInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderInitialStartContext : DbContext + { + public DbSet Items => 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("sb_initial_start"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null) + .WithInitialStart(new DateTime(2026, 3, 15, 8, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void StringBuilder_WithInitialStart_Sets_Annotation() + { + // Arrange & Act + using StringBuilderInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilderInitialStartEntity))!; + + // Assert + object? value = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(value); + Assert.IsType(value); + Assert.Equal(new DateTime(2026, 3, 15, 8, 0, 0, DateTimeKind.Utc), (DateTime)value); + } + + #endregion + + #region StringBuilder_WithInitialStart_Returns_Builder_For_Chaining + + private class StringBuilderChainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderChainContext : DbContext + { + public DbSet Items => 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("sb_chain"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + RetentionPolicyStringBuilder builder = + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + + RetentionPolicyStringBuilder chainResult = + builder.WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Same(builder, chainResult); + }); + } + } + + [Fact] + public void StringBuilder_WithInitialStart_Returns_Builder_For_Chaining() + { + // Arrange & Act + using StringBuilderChainContext context = new(); + GetModel(context); + } + + #endregion + + #region StringBuilder_Repeated_WithInitialStart_Latest_Value_Wins + + private class StringBuilderRepeatedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderRepeatedContext : DbContext + { + public DbSet Items => 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("sb_repeated"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null) + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .WithInitialStart(new DateTime(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void StringBuilder_Repeated_WithInitialStart_Latest_Value_Wins() + { + // Arrange & Act + using StringBuilderRepeatedContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilderRepeatedEntity))!; + + // Assert + object? value = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(value); + Assert.Equal(new DateTime(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc), (DateTime)value); + } + + #endregion + + // ── Parity test: scaffold overload vs. named-param overload ────────────── + + #region ScaffoldOverload_Produces_Identical_Annotations_To_NamedParam_Overload + + private class ParityNamedParamEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ParityScaffoldEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ParityNamedParamContext : DbContext + { + public DbSet Items => 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("parity_named"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "1 day", + maxRuntime: "01:00:00", + maxRetries: 3, + retryPeriod: "00:10:00"); + }); + } + } + + private class ParityScaffoldContext : DbContext + { + public DbSet Items => 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("parity_scaffold"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: "1 day", + maxRuntime: "01:00:00", + maxRetries: 3, + retryPeriod: "00:10:00"); + }); + } + } + + [Fact] + public void ScaffoldOverload_Produces_Identical_Annotations_To_NamedParam_Overload() + { + // Arrange & Act + using ParityNamedParamContext namedCtx = new(); + using ParityScaffoldContext scaffoldCtx = new(); + + IModel namedModel = GetModel(namedCtx); + IModel scaffoldModel = GetModel(scaffoldCtx); + + IEntityType namedEntity = namedModel.FindEntityType(typeof(ParityNamedParamEntity))!; + IEntityType scaffoldEntity = scaffoldModel.FindEntityType(typeof(ParityScaffoldEntity))!; + + // Assert + string[] sharedAnnotationKeys = + [ + RetentionPolicyAnnotations.HasRetentionPolicy, + RetentionPolicyAnnotations.DropAfter, + RetentionPolicyAnnotations.ScheduleInterval, + RetentionPolicyAnnotations.MaxRuntime, + RetentionPolicyAnnotations.MaxRetries, + RetentionPolicyAnnotations.RetryPeriod, + ]; + + foreach (string key in sharedAnnotationKeys) + { + object? namedValue = namedEntity.FindAnnotation(key)?.Value; + object? scaffoldValue = scaffoldEntity.FindAnnotation(key)?.Value; + Assert.Equal(namedValue, scaffoldValue); + } + } + + #endregion } From 81c2bb8d0f2d83d2baf07495204830d05fbe63e1 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 21 Jul 2026 22:43:01 +0200 Subject: [PATCH 08/12] feat: scaffold reorder policies to typed fluent API and data annotations --- .../ReorderPolicyAnnotationRenderer.cs | 163 +++ .../TimescaleCSharpModelGenerator.cs | 4 + .../TimescaleDbAnnotationCodeGenerator.cs | 1 + .../HypertableScaffoldingExtractor.cs | 16 +- .../TimescaleDatabaseModelFactory.cs | 28 + .../ReorderPolicyStringBuilder.cs | 31 + .../ReorderPolicy/ReorderPolicyTypeBuilder.cs | 74 +- .../RetentionPolicyStringBuilder.cs | 2 + .../Features/Hypertables/HypertableDiffer.cs | 50 +- .../ReorderPolicyAnnotationRendererTests.cs | 934 ++++++++++++++++++ .../ScaffoldedModelRenderingTests.cs | 419 ++++++++ .../ReorderPolicyTypeBuilderTests.cs | 663 +++++++++++++ 12 files changed, 2382 insertions(+), 3 deletions(-) create mode 100644 src/Eftdb.Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs create mode 100644 src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs create mode 100644 tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs new file mode 100644 index 0000000..a5a5405 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs @@ -0,0 +1,163 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.PolicyJobRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders TimescaleDB:HasReorderPolicy and related annotations as a + /// .WithReorderPolicy(...) Fluent API chain or a [ReorderPolicy(...)] + /// attribute. + /// + internal sealed class ReorderPolicyAnnotationRenderer : IFeatureAnnotationRenderer + { + /// + /// Reflects the scaffold-targeting WithReorderPolicy overload whose receiver is + /// EntityTypeBuilder<>. + /// + private static readonly MethodInfo WithReorderPolicyMethod = + typeof(ReorderPolicyTypeBuilder) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(ReorderPolicyTypeBuilder.WithReorderPolicy) + && m.GetParameters().Length == 6 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<>) + && m.ReturnType.IsGenericType + && m.ReturnType.GetGenericTypeDefinition() == typeof(ReorderPolicyStringBuilder<>)); + + private static readonly MethodInfo WithInitialStartMethod = + typeof(ReorderPolicyStringBuilder<>) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == nameof(ReorderPolicyStringBuilder<>.WithInitialStart)); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? indexName = GetString(annotations, ReorderPolicyAnnotations.IndexName); + if (string.IsNullOrWhiteSpace(indexName)) + { + return []; + } + + string? scheduleInterval = GetString(annotations, ReorderPolicyAnnotations.ScheduleInterval); + string? maxRuntime = GetString(annotations, ReorderPolicyAnnotations.MaxRuntime); + int? maxRetries = Find(annotations, ReorderPolicyAnnotations.MaxRetries)?.Value is int r ? r : null; + string? retryPeriod = GetString(annotations, ReorderPolicyAnnotations.RetryPeriod); + + object?[] entryArgs = BuildReorderPolicyArgs(indexName, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + MethodCallCodeFragment call = new(WithReorderPolicyMethod, entryArgs); + + call = ChainInitialStart(call, annotations, ReorderPolicyAnnotations.InitialStart, WithInitialStartMethod); + + ConsumeAllReorderAnnotations(annotations); + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? indexName = GetString(annotations, ReorderPolicyAnnotations.IndexName); + if (string.IsNullOrWhiteSpace(indexName)) + { + ConsumeAllReorderAnnotations(annotations); + return []; + } + + object?[] positionalArgs = [indexName]; + Dictionary namedArgs = []; + + string? scheduleInterval = GetString(annotations, ReorderPolicyAnnotations.ScheduleInterval); + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + { + namedArgs[nameof(ReorderPolicyAttribute.ScheduleInterval)] = scheduleInterval; + } + + string? maxRuntime = GetString(annotations, ReorderPolicyAnnotations.MaxRuntime); + if (!string.IsNullOrWhiteSpace(maxRuntime)) + { + namedArgs[nameof(ReorderPolicyAttribute.MaxRuntime)] = maxRuntime; + } + + AddInitialStartNamedArg( + annotations, + ReorderPolicyAnnotations.InitialStart, + nameof(ReorderPolicyAttribute.InitialStart), + namedArgs); + + if (Find(annotations, ReorderPolicyAnnotations.MaxRetries)?.Value is int maxRetries) + { + namedArgs[nameof(ReorderPolicyAttribute.MaxRetries)] = maxRetries; + } + + string? retryPeriod = GetString(annotations, ReorderPolicyAnnotations.RetryPeriod); + if (!string.IsNullOrWhiteSpace(retryPeriod)) + { + namedArgs[nameof(ReorderPolicyAttribute.RetryPeriod)] = retryPeriod; + } + + ConsumeAllReorderAnnotations(annotations); + return [new AttributeCodeFragment(typeof(ReorderPolicyAttribute), positionalArgs, namedArgs)]; + } + + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return; + } + + ConsumeAllReorderAnnotations(annotations); + } + + /// + /// Guards all three rendering paths: a reorder policy annotation is only emitted when the + /// hypertable renderer already succeeded. The hypertable renderer consumes IsHypertable + /// on success. If IsHypertable is still present the hypertable renderer failed and the + /// reorder policy annotations must be left for the .HasAnnotation fallback. + /// + private static bool ShouldRender(IDictionary annotations) + => Find(annotations, ReorderPolicyAnnotations.HasReorderPolicy)?.Value is true + && Find(annotations, HypertableAnnotations.IsHypertable) is null; + + /// + /// Builds positional arguments for the scaffold-targeting WithReorderPolicy overload. + /// All five arguments are always emitted: trimmed forms could resolve against the user-facing + /// overload whose second parameter is initialStart (DateTime?), silently binding + /// later arguments to the wrong parameters. + /// + private static object?[] BuildReorderPolicyArgs( + string indexName, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) + => [indexName, scheduleInterval, maxRuntime, maxRetries, retryPeriod]; + + private static void ConsumeAllReorderAnnotations(IDictionary annotations) + { + Consume(annotations, + ReorderPolicyAnnotations.HasReorderPolicy, + ReorderPolicyAnnotations.IndexName, + ReorderPolicyAnnotations.InitialStart, + ReorderPolicyAnnotations.ScheduleInterval, + ReorderPolicyAnnotations.MaxRuntime, + ReorderPolicyAnnotations.MaxRetries, + ReorderPolicyAnnotations.RetryPeriod); + } + } +} diff --git a/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs index 9f4f1e0..22a29dc 100644 --- a/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleCSharpModelGenerator.cs @@ -49,6 +49,10 @@ public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationO additionalFile.Code = RemoveDesignUsings(additionalFile.Code); } + scaffoldedModel.ContextFile.Code = AddMissingUsings( + scaffoldedModel.ContextFile.Code, + [typeof(TimescaleDbContextOptionsBuilderExtensions).Namespace!]); + if (!options.UseDataAnnotations) { return scaffoldedModel; diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index 7910b8b..d14237f 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -34,6 +34,7 @@ public sealed class TimescaleDbAnnotationCodeGenerator( new ContinuousAggregateAnnotationRenderer(reporter), new ContinuousAggregatePolicyAnnotationRenderer(), new RetentionPolicyAnnotationRenderer(), + new ReorderPolicyAnnotationRenderer(), ]; private readonly Dictionary> _entityAttributeCache = []; diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index 63f1ed9..adf0ea1 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -55,7 +55,15 @@ List AdditionalDimensions { Dictionary<(string, string), bool> compressionSettings = []; using DbCommand command = connection.CreateCommand(); - command.CommandText = "SELECT hypertable_schema, hypertable_name, compression_enabled FROM timescaledb_information.hypertables;"; + command.CommandText = @" + SELECT hypertable_schema, hypertable_name, compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_schema NOT IN ( + '_timescaledb_internal', + '_timescaledb_catalog', + '_timescaledb_config', + '_timescaledb_cache' + );"; using DbDataReader reader = command.ExecuteReader(); while (reader.Read()) { @@ -80,6 +88,12 @@ private static void GetHypertableSettings( time_interval::text AS time_interval_text, integer_interval FROM timescaledb_information.dimensions + WHERE hypertable_schema NOT IN ( + '_timescaledb_internal', + '_timescaledb_catalog', + '_timescaledb_config', + '_timescaledb_cache' + ) ORDER BY hypertable_schema, hypertable_name, dimension_number;"; using DbDataReader reader = command.ExecuteReader(); diff --git a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs index 42a2da0..f82ea0c 100644 --- a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs +++ b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs @@ -16,6 +16,14 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design public class TimescaleDatabaseModelFactory(IDiagnosticsLogger logger) : NpgsqlDatabaseModelFactory(logger) { + private static readonly HashSet TimescaleInternalSchemas = + [ + "_timescaledb_internal", + "_timescaledb_catalog", + "_timescaledb_config", + "_timescaledb_cache" + ]; + private readonly List<(ITimescaleFeatureExtractor Extractor, IAnnotationApplier Applier)> _features = [ (new HypertableScaffoldingExtractor(), new HypertableAnnotationApplier()), @@ -29,6 +37,26 @@ public override DatabaseModel Create(DbConnection connection, DatabaseModelFacto { DatabaseModel databaseModel = base.Create(connection, options); + bool hasExplicitSchemaFilter = options.Schemas.Any(); + if (!hasExplicitSchemaFilter) + { + List internalTables = [.. databaseModel.Tables + .Where(t => t.Schema != null && TimescaleInternalSchemas.Contains(t.Schema))]; + + foreach (DatabaseTable table in internalTables) + { + databaseModel.Tables.Remove(table); + } + + List internalSequences = [.. databaseModel.Sequences + .Where(s => s.Schema != null && TimescaleInternalSchemas.Contains(s.Schema))]; + + foreach (DatabaseSequence sequence in internalSequences) + { + databaseModel.Sequences.Remove(sequence); + } + } + // Extract all TimescaleDB features from the database List> allFeatureData = [.. _features.Select(feature => feature.Extractor.Extract(connection))]; diff --git a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs new file mode 100644 index 0000000..97bcfe1 --- /dev/null +++ b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy +{ + /// + /// Provides a fluent API for configuring optional TimescaleDB reorder policy parameters + /// whose types cannot be rendered by the scaffold code generator (e.g. ). + /// Returned by the scaffold-targeting overloads of WithReorderPolicy. + /// + /// The entity type the reorder policy is applied to. + public sealed class ReorderPolicyStringBuilder where TEntity : class + { + private readonly EntityTypeBuilder _builder; + + internal ReorderPolicyStringBuilder(EntityTypeBuilder builder) + { + _builder = builder; + } + + /// + /// Sets the initial start time for the reorder policy job. + /// + /// The first time the policy job is scheduled to run. + /// The builder for method chaining. + public ReorderPolicyStringBuilder WithInitialStart(DateTime initialStart) + { + _builder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart); + return this; + } + } +} diff --git a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs index 9d37e83..feb32d3 100644 --- a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Metadata.Builders; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy { @@ -62,5 +63,76 @@ public static EntityTypeBuilder WithReorderPolicy( return entityTypeBuilder; } + + /// + /// Configures a TimescaleDB reorder policy for the entity. + /// + /// The type of the entity being configured. + /// The builder for the entity type being configured. + /// The name of the existing index that the reorder policy will use to sort the data. + /// The interval at which the reorder policy job runs. + /// The maximum amount of time the job is allowed to run. + /// The number of times the job is retried if it fails. + /// The amount of time the scheduler waits between retries. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static ReorderPolicyStringBuilder WithReorderPolicy( + this EntityTypeBuilder entityTypeBuilder, + string indexName, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) where TEntity : class + { + WriteReorderPolicy(entityTypeBuilder, indexName, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + return new ReorderPolicyStringBuilder(entityTypeBuilder); + } + + /// + /// Configures a TimescaleDB reorder policy for a hypertable that already has a retention policy + /// configured, chained after the retention policy configuration. + /// + /// The type of the entity being configured. + /// The retention policy builder being configured. + /// The name of the existing index that the reorder policy will use to sort the data. + /// The interval at which the reorder policy job runs. + /// The maximum amount of time the job is allowed to run. + /// The number of times the job is retried if it fails. + /// The amount of time the scheduler waits between retries. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static ReorderPolicyStringBuilder WithReorderPolicy( + this RetentionPolicyStringBuilder builder, + string indexName, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) where TEntity : class + { + WriteReorderPolicy(builder.EntityTypeBuilder, indexName, scheduleInterval, maxRuntime, maxRetries, retryPeriod); + return new ReorderPolicyStringBuilder(builder.EntityTypeBuilder); + } + + private static void WriteReorderPolicy( + EntityTypeBuilder entityTypeBuilder, + string indexName, + string? scheduleInterval, + string? maxRuntime, + int? maxRetries, + string? retryPeriod) + { + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.IndexName, indexName); + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, scheduleInterval); + + if (!string.IsNullOrWhiteSpace(maxRuntime)) + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.MaxRuntime, maxRuntime); + + if (maxRetries.HasValue) + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.MaxRetries, maxRetries.Value); + + if (!string.IsNullOrWhiteSpace(retryPeriod)) + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.RetryPeriod, retryPeriod); + } } } diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs index 855ec11..65c66be 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs @@ -12,6 +12,8 @@ public sealed class RetentionPolicyStringBuilder where TEntity : class { private readonly EntityTypeBuilder _builder; + internal EntityTypeBuilder EntityTypeBuilder => _builder; + internal RetentionPolicyStringBuilder(EntityTypeBuilder builder) { _builder = builder; diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs index 9840273..b51490c 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs @@ -34,7 +34,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) || !AreDimensionsEqual(x.Target.AdditionalDimensions, x.Source.AdditionalDimensions) || !AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || - !AreStringListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) + !AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) ); foreach (var hypertable in updatedHypertables) @@ -123,6 +123,54 @@ private static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyL return (list1 ?? []).SequenceEqual(list2 ?? []); } + /// + /// Compares two compression ORDER BY lists treating an implicit direction as ASC. + /// + private static bool AreOrderByListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) + { + IReadOnlyList l1 = list1 ?? []; + IReadOnlyList l2 = list2 ?? []; + + if (l1.Count != l2.Count) + { + return false; + } + + return l1.Zip(l2).All(pair => NormalizeOrderByEntry(pair.First) == NormalizeOrderByEntry(pair.Second)); + } + + /// + /// Normalizes a compression ORDER BY entry to its canonical form with an explicit direction + /// keyword immediately after the column name. + /// Examples: + /// "Value" -> "Value ASC" + /// "Value ASC" -> "Value ASC" + /// "Value DESC" -> "Value DESC" + /// "Value NULLS FIRST" -> "Value ASC NULLS FIRST" + /// "Value ASC NULLS FIRST" -> "Value ASC NULLS FIRST" + /// + private static string NormalizeOrderByEntry(string entry) + { + string trimmed = entry.Trim(); + int spaceIndex = trimmed.IndexOf(' '); + + if (spaceIndex < 0) + { + return trimmed + " ASC"; + } + + string columnPart = trimmed[..spaceIndex]; + string suffix = trimmed[(spaceIndex + 1)..].TrimStart(); + + if (suffix.StartsWith("DESC", StringComparison.OrdinalIgnoreCase) + || suffix.StartsWith("ASC", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + return $"{columnPart} ASC {suffix}"; + } + private static bool AreChunkSkipColumnsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { if (list1 == null && list2 == null) return true; diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs new file mode 100644 index 0000000..857daa6 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs @@ -0,0 +1,934 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for ReorderPolicyAnnotationRenderer exercised through the public +/// surface. +/// The renderer runs AFTER the hypertable renderer and uses the ABSENCE of IsHypertable +/// (i.e. it was consumed by the hypertable renderer) to decide whether to emit reorder policy code. +/// +public class ReorderPolicyAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator + { + get + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; + } + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private static List FlattenChain(MethodCallCodeFragment? fragment) + { + List all = []; + for (MethodCallCodeFragment? c = fragment; c != null; c = c.ChainedCall) all.Add(c); + return all; + } + + private class ReorderRendererEntity + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ReorderRendererContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("reorder_renderer_entity"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + } + + // ── Guard tests (fluent) ────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_HasReorderPolicy_Absent + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_HasReorderPolicy_Absent() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.IndexName, "ix_reorder_guard_fluent")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithReorderPolicy")); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_guard_ht"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithReorderPolicy")); + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy), + "HasReorderPolicy annotation must remain unconsumed when ShouldRender is false."); + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName), + "IndexName annotation must remain unconsumed when ShouldRender is false."); + } + + #endregion + + #region GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed + + [Fact] + public void GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_renders_when_consumed")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithReorderPolicy")); + } + + #endregion + + // ── Guard tests (attribute) ─────────────────────────────────────────────── + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_HasReorderPolicy_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_HasReorderPolicy_Absent() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.IndexName, "ix_reorder_da_guard_no_flag")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ReorderPolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_da_guard_ht"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ReorderPolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_And_Consumes_When_IndexName_Missing + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_And_Consumes_When_IndexName_Missing() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy), + "HasReorderPolicy must be consumed even when IndexName is missing (blank-IndexName branch calls ConsumeAllReorderAnnotations)."); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Renders_When_IsHypertable_Consumed + + [Fact] + public void GenerateDataAnnotationAttributes_Renders_When_IsHypertable_Consumed() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_da_renders")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Contains(result, a => a.Type == typeof(ReorderPolicyAttribute)); + } + + #endregion + + // ── Fluent positional argument tests (always all 5 args) ───────────────── + + #region GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_IndexName_Only + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_IndexName_Only() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_t")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_reorder_t", withReorderPolicy.Arguments[0]); + Assert.All(withReorderPolicy.Arguments.Skip(1), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_ScheduleInterval_Provided + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_ScheduleInterval_Provided() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_sched"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_reorder_sched", withReorderPolicy.Arguments[0]); + Assert.Equal("2 days", withReorderPolicy.Arguments[1]); + Assert.All(withReorderPolicy.Arguments.Skip(2), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_MaxRuntime_Provided + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_MaxRuntime_Provided() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_maxrt"), + (ReorderPolicyAnnotations.MaxRuntime, "01:00:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_reorder_maxrt", withReorderPolicy.Arguments[0]); + Assert.Null(withReorderPolicy.Arguments[1]); + Assert.Equal("01:00:00", withReorderPolicy.Arguments[2]); + Assert.All(withReorderPolicy.Arguments.Skip(3), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_MaxRetries_Provided + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_MaxRetries_Provided() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_maxret"), + (ReorderPolicyAnnotations.MaxRetries, 5)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_reorder_maxret", withReorderPolicy.Arguments[0]); + Assert.Null(withReorderPolicy.Arguments[1]); + Assert.Null(withReorderPolicy.Arguments[2]); + Assert.Equal(5, withReorderPolicy.Arguments[3]); + Assert.Null(withReorderPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_RetryPeriod_Provided + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_Emits_All_Five_Args_When_RetryPeriod_Provided() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_retry"), + (ReorderPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_reorder_retry", withReorderPolicy.Arguments[0]); + Assert.Null(withReorderPolicy.Arguments[1]); + Assert.Null(withReorderPolicy.Arguments[2]); + Assert.Null(withReorderPolicy.Arguments[3]); + Assert.Equal("00:10:00", withReorderPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithReorderPolicy_FiveArgs_When_All_Five_Set + + [Fact] + public void GenerateFluentApiCalls_WithReorderPolicy_FiveArgs_When_All_Five_Set() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_t"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days"), + (ReorderPolicyAnnotations.MaxRuntime, "01:00:00"), + (ReorderPolicyAnnotations.MaxRetries, 3), + (ReorderPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withReorderPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithReorderPolicy"); + + Assert.NotNull(withReorderPolicy); + Assert.Equal(5, withReorderPolicy.Arguments.Count); + Assert.Equal("ix_t", withReorderPolicy.Arguments[0]); + Assert.Equal("2 days", withReorderPolicy.Arguments[1]); + Assert.Equal("01:00:00", withReorderPolicy.Arguments[2]); + Assert.Equal(3, withReorderPolicy.Arguments[3]); + Assert.Equal("00:10:00", withReorderPolicy.Arguments[4]); + } + + #endregion + + // ── InitialStart chaining tests ─────────────────────────────────────────── + + #region GenerateFluentApiCalls_Chains_WithInitialStart_When_Present + + [Fact] + public void GenerateFluentApiCalls_Chains_WithInitialStart_When_Present() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_chain_start"), + (ReorderPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? initialStartFrag = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithInitialStart"); + + Assert.NotNull(initialStartFrag); + Assert.Equal(initialStart, initialStartFrag.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_no_start")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_wrong_type"), + (ReorderPolicyAnnotations.InitialStart, "2025-06-01T00:00:00Z")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Chain_Order_Is_WithReorderPolicy_Then_WithInitialStart + + [Fact] + public void GenerateFluentApiCalls_Chain_Order_Is_WithReorderPolicy_Then_WithInitialStart() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_order"), + (ReorderPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => CollectMethodChain(f).Contains("WithReorderPolicy")); + List chain = CollectMethodChain(root); + + int reorderIdx = chain.IndexOf("WithReorderPolicy"); + int initialStartIdx = chain.IndexOf("WithInitialStart"); + + Assert.True(reorderIdx < initialStartIdx, + "WithReorderPolicy must precede WithInitialStart in the chain."); + } + + #endregion + + // ── Annotation consumption tests ────────────────────────────────────────── + + #region GenerateFluentApiCalls_Consumes_All_Reorder_Annotations_After_Render + + [Fact] + public void GenerateFluentApiCalls_Consumes_All_Reorder_Annotations_After_Render() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_consume"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days"), + (ReorderPolicyAnnotations.MaxRuntime, "01:00:00"), + (ReorderPolicyAnnotations.MaxRetries, 3), + (ReorderPolicyAnnotations.RetryPeriod, "00:10:00"), + (ReorderPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.InitialStart)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.MaxRuntime)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.MaxRetries)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.RetryPeriod)); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_no_consume"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True + + [Fact] + public void ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + generator.ScaffoldMode = true; + generator.ScaffoldDataAnnotationsMode = true; + + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_consume_da_keys"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days")); + + // Act + generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Removes_All_Keys_Via_DataAnnotations_Mode + + [Fact] + public void ConsumeFeatureAnnotations_Removes_All_Keys_Via_DataAnnotations_Mode() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + generator.ScaffoldMode = true; + generator.ScaffoldDataAnnotationsMode = true; + + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_da_mode_consume"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days"), + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "time")); + + // Act + generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + // ── Data-annotation attribute generation tests ──────────────────────────── + + #region GenerateDataAnnotationAttributes_IndexName_As_Positional_Arg_0 + + [Fact] + public void GenerateDataAnnotationAttributes_IndexName_As_Positional_Arg_0() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_positional")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + object? positionalArg = Assert.Single(attr.Arguments); + Assert.Equal("ix_reorder_positional", positionalArg); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_no_named")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + Assert.Empty(attr.NamedArguments); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_sched_named"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("2 days", attr.NamedArguments[nameof(ReorderPolicyAttribute.ScheduleInterval)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_MaxRuntime_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_MaxRuntime_As_Named_Arg() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_maxrt_named"), + (ReorderPolicyAnnotations.MaxRuntime, "01:00:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("01:00:00", attr.NamedArguments[nameof(ReorderPolicyAttribute.MaxRuntime)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_MaxRetries_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_MaxRetries_As_Named_Arg() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_maxret_named"), + (ReorderPolicyAnnotations.MaxRetries, 5)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal(5, attr.NamedArguments[nameof(ReorderPolicyAttribute.MaxRetries)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_RetryPeriod_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_RetryPeriod_As_Named_Arg() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_retry_named"), + (ReorderPolicyAnnotations.RetryPeriod, "00:10:00")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("00:10:00", attr.NamedArguments[nameof(ReorderPolicyAttribute.RetryPeriod)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_iso_start"), + (ReorderPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(ReorderPolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(initialStart, parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Local_DateTime_Converted_To_UTC_For_InitialStart + + [Fact] + public void GenerateDataAnnotationAttributes_Local_DateTime_Converted_To_UTC_For_InitialStart() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime localTime = new(2025, 6, 1, 12, 0, 0, DateTimeKind.Local); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_local_start"), + (ReorderPolicyAnnotations.InitialStart, localTime)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(ReorderPolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(ReorderPolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(localTime.ToUniversalTime(), parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Consumes_All_Reorder_Annotations_After_Render + + [Fact] + public void GenerateDataAnnotationAttributes_Consumes_All_Reorder_Annotations_After_Render() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_consume_all"), + (ReorderPolicyAnnotations.ScheduleInterval, "2 days"), + (ReorderPolicyAnnotations.MaxRuntime, "01:00:00"), + (ReorderPolicyAnnotations.MaxRetries, 3), + (ReorderPolicyAnnotations.RetryPeriod, "00:10:00"), + (ReorderPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + CreateAnnotationCodeGenerator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.InitialStart)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.MaxRuntime)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.MaxRetries)); + Assert.False(annotations.ContainsKey(ReorderPolicyAnnotations.RetryPeriod)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Does_Not_Consume_When_ShouldRender_False + + [Fact] + public void GenerateDataAnnotationAttributes_Does_Not_Consume_When_ShouldRender_False() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true), + (ReorderPolicyAnnotations.IndexName, "ix_reorder_da_no_consume"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.HasReorderPolicy)); + Assert.True(annotations.ContainsKey(ReorderPolicyAnnotations.IndexName)); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs index 1ac9d91..bf0da97 100644 --- a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.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.Design; using Microsoft.EntityFrameworkCore; @@ -1154,5 +1155,423 @@ public void Should_Not_Render_RetentionPolicyAttribute_When_IsHypertable_Not_Con } #endregion + + // ── Reorder policy rendering ────────────────────────────────────────────── + + #region Should_Render_WithReorderPolicy_Chained_In_Fluent_Scaffold_For_Hypertable + + private class RrpHtSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpHtContextA : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_ht_source_a"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_ht_source_a"); + }); + } + } + + [Fact] + public void Should_Render_WithReorderPolicy_Chained_In_Fluent_Scaffold_For_Hypertable() + { + // Arrange & Act + using RrpHtContextA context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithReorderPolicy(", code); + } + + #endregion + + #region Should_Render_WithReorderPolicy_With_IndexName_Only_Five_Nulls_In_Fluent_Scaffold + + private class RrpFiveNullsSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpFiveNullsContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_five_nulls_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_t"); + }); + } + } + + [Fact] + public void Should_Render_WithReorderPolicy_With_IndexName_Only_Five_Nulls_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpFiveNullsContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithReorderPolicy(\"ix_t\", null, null, null, null)", code); + } + + #endregion + + #region Should_Render_WithReorderPolicy_With_ScheduleInterval_In_Fluent_Scaffold + + private class RrpScheduleIntervalSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpScheduleIntervalContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_schedule_interval_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_t"); + e.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, "2 days"); + }); + } + } + + [Fact] + public void Should_Render_WithReorderPolicy_With_ScheduleInterval_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpScheduleIntervalContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithReorderPolicy(\"ix_t\", \"2 days\", null, null, null)", code); + } + + #endregion + + #region Should_Render_WithReorderPolicy_Five_Args_In_Fluent_Scaffold + + private class RrpFiveArgsSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpFiveArgsContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_five_args_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_t"); + e.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, "2 days"); + e.HasAnnotation(ReorderPolicyAnnotations.MaxRuntime, "01:00:00"); + e.HasAnnotation(ReorderPolicyAnnotations.MaxRetries, 3); + e.HasAnnotation(ReorderPolicyAnnotations.RetryPeriod, "00:10:00"); + }); + } + } + + [Fact] + public void Should_Render_WithReorderPolicy_Five_Args_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpFiveArgsContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithReorderPolicy(\"ix_t\", \"2 days\", \"01:00:00\", 3, \"00:10:00\")", code); + } + + #endregion + + #region Should_Render_WithInitialStart_Chained_After_WithReorderPolicy_In_Fluent_Scaffold + + private class RrpInitialStartSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpInitialStartContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_initial_start_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_initial_start"); + e.HasAnnotation(ReorderPolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_WithInitialStart_Chained_After_WithReorderPolicy_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpInitialStartContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithReorderPolicy(", code); + Assert.Contains(".WithInitialStart(", code); + Assert.True( + code.IndexOf(".WithReorderPolicy(", StringComparison.Ordinal) < + code.IndexOf(".WithInitialStart(", StringComparison.Ordinal), + ".WithInitialStart( must follow .WithReorderPolicy(."); + } + + #endregion + + #region Should_Render_WithReorderPolicy_After_WithRetentionPolicy_In_Fluent_Scaffold + + private class RrpChainOrderSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpChainOrderContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_chain_order_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + e.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_chain_order"); + }); + } + } + + [Fact] + public void Should_Render_WithReorderPolicy_After_WithRetentionPolicy_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpChainOrderContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.Contains(".WithRetentionPolicy(", code); + Assert.Contains(".WithReorderPolicy(", code); + Assert.True( + code.IndexOf(".WithRetentionPolicy(", StringComparison.Ordinal) < + code.IndexOf(".WithReorderPolicy(", StringComparison.Ordinal), + ".WithRetentionPolicy( must precede .WithReorderPolicy( in the chain."); + } + + #endregion + + #region Should_Not_Render_WithReorderPolicy_When_IsHypertable_Still_Present_In_Fluent_Scaffold + + private class RrpGuardHtSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpGuardHtContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_guard_ht_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_guard_ht"); + }); + } + } + + [Fact] + public void Should_Not_Render_WithReorderPolicy_When_IsHypertable_Still_Present_In_Fluent_Scaffold() + { + // Arrange & Act + using RrpGuardHtContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: false); + string code = result.ContextFile.Code; + + // Assert + Assert.DoesNotContain(".WithReorderPolicy(", code); + } + + #endregion + + #region Should_Render_ReorderPolicyAttribute_In_DataAnnotations_Scaffold + + private class RrpDaSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpDaContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_da_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_da"); + }); + } + } + + [Fact] + public void Should_Render_ReorderPolicyAttribute_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RrpDaContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RrpDaSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.Contains("[ReorderPolicy(", entityFile.Code); + Assert.DoesNotContain(".WithReorderPolicy(", result.ContextFile.Code); + } + + #endregion + + #region Should_Render_ReorderPolicyAttribute_With_InitialStart_As_ISO8601_In_DataAnnotations_Scaffold + + private class RrpDaInitialStartSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpDaInitialStartContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_da_initial_start_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time"); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_da_initial_start"); + e.HasAnnotation(ReorderPolicyAnnotations.InitialStart, + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Render_ReorderPolicyAttribute_With_InitialStart_As_ISO8601_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RrpDaInitialStartContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RrpDaInitialStartSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.Contains("InitialStart = \"2025-06-01T00:00:00", entityFile.Code); + } + + #endregion + + #region Should_Not_Render_ReorderPolicyAttribute_When_IsHypertable_Not_Consumed_In_DataAnnotations_Scaffold + + private class RrpDaGuardSource { public DateTime Time { get; set; } public double Value { get; set; } } + + private class RrpDaGuardContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("rrp_da_guard_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.HasAnnotation(HypertableAnnotations.IsHypertable, true); + e.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + e.HasAnnotation(ReorderPolicyAnnotations.IndexName, "ix_rrp_da_guard"); + }); + } + } + + [Fact] + public void Should_Not_Render_ReorderPolicyAttribute_When_IsHypertable_Not_Consumed_In_DataAnnotations_Scaffold() + { + // Arrange & Act + using RrpDaGuardContext context = new(); + ScaffoldedModel result = Generate(context, useDataAnnotations: true); + + ScaffoldedFile? entityFile = result.AdditionalFiles.FirstOrDefault(f => f.Path.Contains(nameof(RrpDaGuardSource))); + Assert.NotNull(entityFile); + + // Assert + Assert.DoesNotContain("[ReorderPolicy(", entityFile.Code); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs index 47f768c..3b41354 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ReorderPolicyTypeBuilderTests.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -616,4 +617,666 @@ public void WithReorderPolicy_Should_Return_EntityTypeBuilder_For_Chaining() } #endregion + + // ── EntityTypeBuilder-receiver scaffold overload ─────────────────────────── + + #region ScaffoldOverload_EntityTypeBuilder_Sets_HasReorderPolicy_And_IndexName + + private class ScaffoldEtbBaseEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbBaseContext : DbContext + { + public DbSet Items => 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("scaffold_etb_base"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_base", (string?)null, (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Sets_HasReorderPolicy_And_IndexName() + { + // Arrange + using ScaffoldEtbBaseContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldEtbBaseEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("ix_scaffold_etb_base", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Sets_All_Optional_Annotations_When_Provided + + private class ScaffoldEtbFullEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbFullContext : DbContext + { + public DbSet Items => 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("scaffold_etb_full"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_full", "2 days", "01:00:00", 3, "00:10:00"); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Sets_All_Optional_Annotations_When_Provided() + { + // Arrange + using ScaffoldEtbFullContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldEtbFullEntity))!; + + // Assert + Assert.Equal("2 days", entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("01:00:00", entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:10:00", entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Does_Not_Set_Optionals_When_All_Null + + private class ScaffoldEtbNullsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbNullsContext : DbContext + { + public DbSet Items => 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("scaffold_etb_nulls"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_nulls", (string?)null, (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Does_Not_Set_Optionals_When_All_Null() + { + // Arrange + using ScaffoldEtbNullsContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldEtbNullsEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Returns_ReorderPolicyStringBuilder_Enabling_Chaining + + private class ScaffoldEtbChainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbChainContext : DbContext + { + public DbSet Items => 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("scaffold_etb_chain"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_chain", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Returns_ReorderPolicyStringBuilder_Enabling_Chaining() + { + // Arrange + using ScaffoldEtbChainContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldEtbChainEntity))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)!.Value); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Does_Not_Write_InitialStart_Itself + + private class ScaffoldEtbNoStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbNoStartContext : DbContext + { + public DbSet Items => 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("scaffold_etb_no_start"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_no_start", "1 day", (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Does_Not_Write_InitialStart_Itself() + { + // Arrange + using ScaffoldEtbNoStartContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldEtbNoStartEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Parity_With_UserFacing_Overload + + private class ScaffoldEtbParityUserEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbParityScaffoldEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldEtbParityUserContext : DbContext + { + public DbSet Items => 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("scaffold_etb_parity_user"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + "ix_scaffold_etb_parity", + scheduleInterval: "2 days", + maxRuntime: "01:00:00", + maxRetries: 3, + retryPeriod: "00:10:00"); + }); + } + } + + private class ScaffoldEtbParityScaffoldContext : DbContext + { + public DbSet Items => 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("scaffold_etb_parity_scaffold"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_scaffold_etb_parity", "2 days", "01:00:00", 3, "00:10:00"); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Parity_With_UserFacing_Overload() + { + // Arrange + using ScaffoldEtbParityUserContext userContext = new(); + using ScaffoldEtbParityScaffoldContext scaffoldContext = new(); + + // Act + IEntityType userEntity = GetModel(userContext).FindEntityType(typeof(ScaffoldEtbParityUserEntity))!; + IEntityType scaffoldEntity = GetModel(scaffoldContext).FindEntityType(typeof(ScaffoldEtbParityScaffoldEntity))!; + + // Assert + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal( + userEntity.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value, + scaffoldEntity.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + // ── RetentionPolicyStringBuilder-receiver scaffold overload ─────────────── + + #region ScaffoldOverload_RetentionBuilder_Sets_Both_HasRetentionPolicy_And_HasReorderPolicy + + private class ScaffoldRpRpEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldRpRpContext : DbContext + { + public DbSet Items => 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("scaffold_rp_rp"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("7 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithReorderPolicy("ix_scaffold_rp_rp", (string?)null, (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionBuilder_Sets_Both_HasRetentionPolicy_And_HasReorderPolicy() + { + // Arrange + using ScaffoldRpRpContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldRpRpEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal(true, entityType.FindAnnotation(ReorderPolicyAnnotations.HasReorderPolicy)?.Value); + Assert.Equal("ix_scaffold_rp_rp", entityType.FindAnnotation(ReorderPolicyAnnotations.IndexName)?.Value); + } + + #endregion + + #region ScaffoldOverload_RetentionBuilder_Preserves_DropAfter + + private class ScaffoldRpDropAfterEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldRpDropAfterContext : DbContext + { + public DbSet Items => 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("scaffold_rp_drop_after"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("30 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithReorderPolicy("ix_scaffold_rp_drop_after", (string?)null, (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionBuilder_Preserves_DropAfter() + { + // Arrange + using ScaffoldRpDropAfterContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldRpDropAfterEntity))!; + + // Assert + Assert.Equal("30 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + } + + #endregion + + #region ScaffoldOverload_RetentionBuilder_Returns_ReorderPolicyStringBuilder_Enabling_WithInitialStart + + private class ScaffoldRpStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldRpStartContext : DbContext + { + public DbSet Items => 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("scaffold_rp_start"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("7 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithReorderPolicy("ix_scaffold_rp_start", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionBuilder_Returns_ReorderPolicyStringBuilder_Enabling_WithInitialStart() + { + // Arrange + using ScaffoldRpStartContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldRpStartEntity))!; + + // Assert + Assert.Equal( + new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc), + entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value); + } + + #endregion + + #region ScaffoldOverload_RetentionBuilder_No_Optional_Reorder_Annotations_When_Null + + private class ScaffoldRpNullsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldRpNullsContext : DbContext + { + public DbSet Items => 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("scaffold_rp_nulls"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("7 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithReorderPolicy("ix_scaffold_rp_nulls", (string?)null, (string?)null, (int?)null, (string?)null); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionBuilder_No_Optional_Reorder_Annotations_When_Null() + { + // Arrange + using ScaffoldRpNullsContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScaffoldRpNullsEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.ScheduleInterval)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRuntime)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.MaxRetries)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.RetryPeriod)); + Assert.Null(entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)); + } + + #endregion + + // ── ReorderPolicyStringBuilder ──────────────────────────────────────────── + + #region ReorderPolicyStringBuilder_WithInitialStart_Sets_InitialStart_Annotation + + private class StringBuilderStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderStartContext : DbContext + { + public DbSet Items => 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("sb_start"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_sb_start", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 5, 15, 12, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ReorderPolicyStringBuilder_WithInitialStart_Sets_InitialStart_Annotation() + { + // Arrange + using StringBuilderStartContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilderStartEntity))!; + + // Assert + Assert.Equal( + new DateTime(2025, 5, 15, 12, 0, 0, DateTimeKind.Utc), + entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value); + } + + #endregion + + #region ReorderPolicyStringBuilder_WithInitialStart_Returns_Same_Instance + + private class StringBuilderSameInstanceEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private (ReorderPolicyStringBuilder? Original, + ReorderPolicyStringBuilder? Returned) _sbSameInstanceCapture; + + private class StringBuilderSameInstanceContext(ReorderPolicyTypeBuilderTests outer) : DbContext + { + public DbSet Items => 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("sb_same_instance"); + entity.IsHypertable(x => x.Timestamp); + ReorderPolicyStringBuilder original = + entity.WithReorderPolicy("ix_sb_same", (string?)null, (string?)null, (int?)null, (string?)null); + ReorderPolicyStringBuilder returned = + original.WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + outer._sbSameInstanceCapture = (original, returned); + }); + } + } + + [Fact] + public void ReorderPolicyStringBuilder_WithInitialStart_Returns_Same_Instance() + { + // Arrange & Act + using StringBuilderSameInstanceContext context = new(this); + _ = GetModel(context); + + // Assert + Assert.NotNull(_sbSameInstanceCapture.Original); + Assert.Same(_sbSameInstanceCapture.Original, _sbSameInstanceCapture.Returned); + } + + #endregion + + #region ReorderPolicyStringBuilder_WithInitialStart_Repeated_Call_Uses_Latest_Value + + private class StringBuilderRepeatEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderRepeatContext : DbContext + { + public DbSet Items => 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("sb_repeat"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("ix_sb_repeat", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .WithInitialStart(new DateTime(2026, 7, 4, 8, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ReorderPolicyStringBuilder_WithInitialStart_Repeated_Call_Uses_Latest_Value() + { + // Arrange + using StringBuilderRepeatContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(StringBuilderRepeatEntity))!; + + // Assert + Assert.Equal( + new DateTime(2026, 7, 4, 8, 0, 0, DateTimeKind.Utc), + entityType.FindAnnotation(ReorderPolicyAnnotations.InitialStart)?.Value); + } + + #endregion } From 938dc07575928532d3dc3b893336f98824cefeb7 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 21 Jul 2026 22:43:40 +0200 Subject: [PATCH 09/12] test: implement full scaffolding roundtrip tests --- tests/Eftdb.Tests/Eftdb.Tests.csproj | 1 + .../Integration/ScaffoldRoundTripTests.cs | 650 ++++++++++++++++++ 2 files changed, 651 insertions(+) create mode 100644 tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs diff --git a/tests/Eftdb.Tests/Eftdb.Tests.csproj b/tests/Eftdb.Tests/Eftdb.Tests.csproj index f5354b4..8037cd7 100644 --- a/tests/Eftdb.Tests/Eftdb.Tests.csproj +++ b/tests/Eftdb.Tests/Eftdb.Tests.csproj @@ -17,6 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs new file mode 100644 index 0000000..7cdbd46 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs @@ -0,0 +1,650 @@ +#pragma warning disable EF1001 // IScaffoldingModelFactory is an EF Core internal API, used here to drive the full scaffold-to-IModel pipeline. + +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.Design; +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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using System.Reflection; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Validates that the TimescaleDB scaffolding roundtrip is lossless for all renderable features. +/// A code-first reference database is created via migrations, scaffolded back to C# with the +/// design-time pipeline, and the generated code is compiled in-memory with Roslyn to catch +/// renderer bugs that produce un-compilable output. +/// +/// Two complementary roundtrip assertions are performed: +/// +/// Annotation-level (hypertable, retention, reorder): the code-first reference model and the +/// scaffolded model are compared via feature differs. +/// +/// Database-level (continuous aggregate): the scaffolded model is applied to a second database +/// via the migration pipeline and the two databases are then queried directly. +/// +/// Scope: only TimescaleDB feature differs are tested. Base EF Core relational differences +/// (column nullability, CLR type mapping, etc.) are intentionally out of scope. +/// +public sealed class ScaffoldRoundTripTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } + + #region Reference-model entity types + + private class CompressedMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + public int Region { get; set; } + } + + private class PolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaSourceMetric + { + public DateTime Timestamp { get; set; } + public string Ticker { get; set; } = string.Empty; + public double Price { get; set; } + public int Volume { get; set; } + } + + private class CaHourlyView + { + public DateTime Bucket { get; set; } + public string Ticker { get; set; } = string.Empty; + public double MaxPrice { get; set; } + public double AvgPrice { get; set; } + public long CountRows { get; set; } + } + + #endregion + + private sealed class RoundTripReferenceContext(string connectionString) : DbContext + { + public DbSet CompressedMetrics => Set(); + public DbSet PolicyMetrics => Set(); + public DbSet DropCreatedBeforeMetrics => Set(); + public DbSet CaSourceMetrics => Set(); + public DbSet CaHourlyViews => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_compressed_metric"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("30 days") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value) + ]) + .HasDimension(Dimension.CreateHash(nameof(CompressedMetric.Region), 4)); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_policy_metric"); + entity.HasIndex(x => x.Timestamp, "rrt_policy_metric_time_idx"); + + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "30 days", + scheduleInterval: "2 days", + maxRuntime: "1 hour", + maxRetries: 3, + retryPeriod: "5 minutes", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .WithReorderPolicy( + indexName: "rrt_policy_metric_time_idx", + scheduleInterval: "1 day", + maxRuntime: "30 minutes", + maxRetries: 2, + retryPeriod: "10 minutes", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_dcb_metric"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropCreatedBefore: "60 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_ca_source"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("rrt_ca_hourly"); + + entity.IsContinuousAggregate( + "rrt_ca_hourly", + "1 hour", + x => x.Timestamp, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.MaxPrice, x => x.Price, EAggregateFunction.Max) + .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.CountRows, x => x.Ticker, EAggregateFunction.Count) + .AddGroupByColumn(x => x.Ticker) + .MaterializedOnly(true) + .WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "4 hours"); + + entity.WithRetentionPolicy(dropAfter: "90 days"); + }); + } + } + + // ── Design-time helpers ─────────────────────────────────────────────────── + + private static async Task CreateIsolatedDatabaseAsync(string adminConnectionString) + { + string dbName = $"rrt_{Guid.NewGuid():N}"; + + await using NpgsqlConnection admin = new(adminConnectionString); + await admin.OpenAsync(); + await using (NpgsqlCommand cmd = new($"CREATE DATABASE {dbName}", admin)) + { + await cmd.ExecuteNonQueryAsync(); + } + + string isolated = adminConnectionString.Replace("test_db", dbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection conn = new(isolated); + await conn.OpenAsync(); + await using (NpgsqlCommand ext = new("CREATE EXTENSION IF NOT EXISTS timescaledb", conn)) + { + await ext.ExecuteNonQueryAsync(); + } + + return isolated; + } + + private static ServiceProvider CreateDesignServices() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider(); + } + + private static ModelCodeGenerationOptions MakeCodeGenOptions( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) => new() + { + Language = "C#", + UseDataAnnotations = useDataAnnotations, + ProjectDir = ".", + ModelNamespace = "RoundTripScaffold", + ContextName = "RoundTripDbContext", + ContextNamespace = "RoundTripScaffold", + ConnectionString = connectionString, + SuppressOnConfiguring = suppressOnConfiguring + }; + + /// + /// Scaffolds the database into an (via ) + /// and also renders the model to a for Roslyn compilation. + /// + private static (IModel Model, ScaffoldedModel ScaffoldedCode) ScaffoldDatabase( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) + { + using ServiceProvider sp = CreateDesignServices(); + + IDatabaseModelFactory dbFactory = sp.GetRequiredService(); + using NpgsqlConnection conn = new(connectionString); + DatabaseModel dbModel = dbFactory.Create(conn, new DatabaseModelFactoryOptions()); + + IScaffoldingModelFactory scaffoldingModelFactory = sp.GetRequiredService(); + IModel model = scaffoldingModelFactory.Create(dbModel, new ModelReverseEngineerOptions()); + + ModelCodeGenerationOptions codeGenOptions = MakeCodeGenOptions(connectionString, useDataAnnotations, suppressOnConfiguring); + using ServiceProvider sp2 = CreateDesignServices(); + IModelCodeGenerator codeGenerator = sp2.GetRequiredService().Select(codeGenOptions); + ScaffoldedModel scaffoldedCode = codeGenerator.GenerateModel(model, codeGenOptions); + + return (model, scaffoldedCode); + } + + /// + /// Loads the compiled assembly from Roslyn PE bytes, instantiates + /// RoundTripScaffold.RoundTripDbContext with options pointing at + /// , and applies the migration pipeline to create the schema. + /// + private static async Task ApplyCompiledContextToDatabase(byte[] peBytes, string connectionString) + { + Assembly compiledAssembly = Assembly.Load(peBytes); + Type contextType = compiledAssembly.GetType("RoundTripScaffold.RoundTripDbContext") + ?? throw new InvalidOperationException("RoundTripScaffold.RoundTripDbContext not found in compiled assembly."); + + Type builderType = typeof(DbContextOptionsBuilder<>).MakeGenericType(contextType); + DbContextOptionsBuilder builder = (DbContextOptionsBuilder)Activator.CreateInstance(builderType)!; + builder.UseNpgsql(connectionString).UseTimescaleDb(); + + DbContext compiledCtx = (DbContext)Activator.CreateInstance(contextType, builder.Options)!; + await using (compiledCtx) + { + await CreateDatabaseViaMigrationAsync(compiledCtx); + } + } + + /// + /// Queries a single row for from + /// timescaledb_information.continuous_aggregates joined to the materialization + /// hypertable dimensions, and returns the relevant fields. + /// + private static async Task<(string ViewDefinition, bool MaterializedOnly, string? ChunkInterval)> + QueryContinuousAggregateInfoAsync(string connectionString, string viewName) + { + const string sql = @" + SELECT + ca.view_definition, + ca.materialized_only, + dim.time_interval::text AS chunk_interval + FROM timescaledb_information.continuous_aggregates ca + LEFT JOIN _timescaledb_catalog.continuous_agg cagg + ON ca.view_schema = cagg.user_view_schema + AND ca.view_name = cagg.user_view_name + LEFT JOIN _timescaledb_catalog.hypertable mat_ht + ON cagg.mat_hypertable_id = mat_ht.id + LEFT JOIN timescaledb_information.dimensions dim + ON dim.hypertable_schema = mat_ht.schema_name + AND dim.hypertable_name = mat_ht.table_name + AND dim.dimension_number = 1 + WHERE ca.view_name = @viewName;"; + + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = new(sql, conn); + cmd.Parameters.AddWithValue("viewName", viewName); + await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(); + + Assert.True(await reader.ReadAsync(), $"No row found in continuous_aggregates for view '{viewName}'."); + + string viewDefinition = reader.GetString(0); + bool materializedOnly = reader.GetBoolean(1); + string? chunkInterval = reader.IsDBNull(2) ? null : reader.GetString(2); + + return (viewDefinition, materializedOnly, chunkInterval); + } + + /// + /// Queries the refresh-policy job parameters for the continuous aggregate identified by + /// from timescaledb_information.jobs. + /// + private static async Task<(string? StartOffset, string? EndOffset, string ScheduleInterval)> + QueryCaRefreshPolicyAsync(string connectionString, string viewName) + { + const string sql = @" + SELECT + j.config->>'start_offset' AS start_offset, + j.config->>'end_offset' AS end_offset, + j.schedule_interval::text AS schedule_interval + FROM timescaledb_information.jobs j + INNER JOIN _timescaledb_catalog.continuous_agg ca + ON (j.config->>'mat_hypertable_id')::integer = ca.mat_hypertable_id + WHERE j.proc_name = 'policy_refresh_continuous_aggregate' + AND ca.user_view_name = @viewName;"; + + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = new(sql, conn); + cmd.Parameters.AddWithValue("viewName", viewName); + await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(); + + Assert.True(await reader.ReadAsync(), $"No refresh policy job found for CA view '{viewName}'."); + + string? startOffset = reader.IsDBNull(0) ? null : reader.GetString(0); + string? endOffset = reader.IsDBNull(1) ? null : reader.GetString(1); + string scheduleInterval = reader.GetString(2); + + return (startOffset, endOffset, scheduleInterval); + } + + /// + /// Queries the drop-after retention policy on the continuous aggregate view identified by + /// . Retention on a CA targets its materialized hypertable. + /// + private static async Task QueryCaRetentionDropAfterAsync( + string connectionString, string viewName) + { + const string sql = @" + SELECT j.config->>'drop_after' AS drop_after + FROM timescaledb_information.jobs j + INNER JOIN _timescaledb_catalog.continuous_agg ca + ON (j.config->>'hypertable_id')::integer = ca.mat_hypertable_id + WHERE j.proc_name = 'policy_retention' + AND ca.user_view_name = @viewName;"; + + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = new(sql, conn); + cmd.Parameters.AddWithValue("viewName", viewName); + await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(); + + if (!await reader.ReadAsync()) + { + return null; + } + + return reader.IsDBNull(0) ? null : reader.GetString(0); + } + + /// + /// Compiles all files from a with Roslyn and asserts zero errors. + /// + private static byte[] CompileAndAssertNoErrors(ScaffoldedModel scaffolded, string label) + { + IEnumerable trustedPlatformAssemblies = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + + IEnumerable loadedAssemblyPaths = + AppDomain.CurrentDomain + .GetAssemblies() + .Select(a => a.Location) + .Where(loc => !string.IsNullOrWhiteSpace(loc)); + + IEnumerable references = + trustedPlatformAssemblies + .Concat(loadedAssemblyPaths) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(File.Exists) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)); + + IEnumerable sources = + [scaffolded.ContextFile.Code, .. scaffolded.AdditionalFiles.Select(f => f.Code)]; + + CSharpParseOptions parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + + IEnumerable trees = sources.Select(src => CSharpSyntaxTree.ParseText(src, parseOptions)); + + CSharpCompilation compilation = CSharpCompilation.Create( + $"RoundTripScaffold_{label}", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + using MemoryStream pe = new(); + Microsoft.CodeAnalysis.Emit.EmitResult emitResult = compilation.Emit(pe); + + IReadOnlyList errors = + [.. emitResult.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)]; + + Assert.True( + errors.Count == 0, + $"[{label}] Scaffolded code has {errors.Count} compilation error(s):\n" + + string.Join("\n", errors.Select(e => e.ToString()))); + + return pe.ToArray(); + } + + // ── Differ assertion helpers ────────────────────────────────────────────── + + /// + /// Runs all five feature differs between two relational models and asserts zero operations + /// in both directions. A non-zero result is reported with operation types and table names + /// so a failing test is self-diagnosing. + /// + private static void AssertZeroDiff( + IRelationalModel source, + IRelationalModel target, + string direction) + { + IFeatureDiffer[] differs = + [ + new HypertableDiffer(), + new ContinuousAggregateDiffer(), + new ContinuousAggregatePolicyDiffer(), + new RetentionPolicyDiffer(), + new ReorderPolicyDiffer(), + ]; + + List failures = []; + foreach (IFeatureDiffer differ in differs) + { + IReadOnlyList ops = differ.GetDifferences(source, target); + if (ops.Count > 0) + { + string opList = string.Join(", ", ops.Select(op => + { + PropertyInfo? nameProp = + op.GetType().GetProperty("TableName") + ?? op.GetType().GetProperty("MaterializedViewName"); + string name = nameProp?.GetValue(op)?.ToString() ?? "?"; + return $"{op.GetType().Name}({name})"; + })); + failures.Add($" {differ.GetType().Name}: {ops.Count} op(s) — {opList}"); + } + } + + Assert.True( + failures.Count == 0, + $"Feature differs produced non-zero operations [{direction}]:\n" + + string.Join("\n", failures)); + } + + /// + /// Runs only the annotation-stable feature differs (hypertable, retention, reorder) between + /// the code-first reference model and the scaffolded model. The CA differ is excluded because + /// the code-first model uses structured annotations (TimeBucketWidth, AggregateFunctions, etc.) + /// while the scaffolded model uses raw ViewDefinition — a known design trade-off described in + /// the class XML doc. + /// + private static void AssertZeroDiffForNonCaFeatures( + IRelationalModel source, + IRelationalModel target, + string direction) + { + IFeatureDiffer[] differs = + [ + new HypertableDiffer(), + new RetentionPolicyDiffer(), + new ReorderPolicyDiffer(), + ]; + + List failures = []; + foreach (IFeatureDiffer differ in differs) + { + IReadOnlyList ops = differ.GetDifferences(source, target); + if (ops.Count > 0) + { + string opList = string.Join(", ", ops.Select(op => + { + PropertyInfo? nameProp = + op.GetType().GetProperty("TableName") + ?? op.GetType().GetProperty("MaterializedViewName"); + string name = nameProp?.GetValue(op)?.ToString() ?? "?"; + return $"{op.GetType().Name}({name})"; + })); + failures.Add($" {differ.GetType().Name}: {ops.Count} op(s) — {opList}"); + } + } + + Assert.True( + failures.Count == 0, + $"Non-CA feature differs produced non-zero operations [{direction}]:\n" + + string.Join("\n", failures)); + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #region Should_RoundTrip_With_Zero_Feature_Diff_Operations_In_Fluent_Mode + + [Fact] + public async Task Should_RoundTrip_With_Zero_Feature_Diff_Operations_In_Fluent_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using RoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: false); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: false); + + // Assert + CompileAndAssertNoErrors(codeA, "fluent"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + // Assert + AssertZeroDiff(relA, relB, "fluent: pass-1 → pass-2"); + AssertZeroDiff(relB, relA, "fluent: pass-2 → pass-1"); + + // Assert + IRelationalModel refRelModel = refCtx.GetService().Model.GetRelationalModel(); + AssertZeroDiffForNonCaFeatures(refRelModel, relA, "fluent: reference → scaffolded"); + AssertZeroDiffForNonCaFeatures(relA, refRelModel, "fluent: scaffolded → reference"); + } + + #endregion + + #region Should_RoundTrip_With_Zero_Feature_Diff_Operations_In_DataAnnotations_Mode + + [Fact] + public async Task Should_RoundTrip_With_Zero_Feature_Diff_Operations_In_DataAnnotations_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using RoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: true); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: true); + + // Assert + CompileAndAssertNoErrors(codeA, "data-annotations"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + // Assert + AssertZeroDiff(relA, relB, "da: pass-1 → pass-2"); + AssertZeroDiff(relB, relA, "da: pass-2 → pass-1"); + + // Assert + IRelationalModel refRelModel = refCtx.GetService().Model.GetRelationalModel(); + AssertZeroDiffForNonCaFeatures(refRelModel, relA, "da: reference → scaffolded"); + AssertZeroDiffForNonCaFeatures(relA, refRelModel, "da: scaffolded → reference"); + } + + #endregion + + #region Should_Produce_Identical_Continuous_Aggregate_When_Migrating_From_Scaffolded_Model + + [Fact] + public async Task Should_Produce_Identical_Continuous_Aggregate_When_Migrating_From_Scaffolded_Model() + { + // Arrange + string db1 = await CreateIsolatedDatabaseAsync(_connectionString!); + await using RoundTripReferenceContext refCtx = new(db1); + await CreateDatabaseViaMigrationAsync(refCtx); + + string db2 = await CreateIsolatedDatabaseAsync(_connectionString!); + + // Act + (IModel modelDb1, ScaffoldedModel codeDb1) = ScaffoldDatabase(db1, useDataAnnotations: false, suppressOnConfiguring: true); + byte[] peBytes = CompileAndAssertNoErrors(codeDb1, "ca-db-level"); + + await ApplyCompiledContextToDatabase(peBytes, db2); + + // Assert + (string viewDefDb1, bool matOnlyDb1, string? chunkDb1) = await QueryContinuousAggregateInfoAsync(db1, "rrt_ca_hourly"); + (string viewDefDb2, bool matOnlyDb2, string? chunkDb2) = await QueryContinuousAggregateInfoAsync(db2, "rrt_ca_hourly"); + Assert.Equal(viewDefDb1, viewDefDb2); + Assert.Equal(matOnlyDb1, matOnlyDb2); + Assert.Equal(chunkDb1, chunkDb2); + + (string? startDb1, string? endDb1, string schedDb1) = await QueryCaRefreshPolicyAsync(db1, "rrt_ca_hourly"); + (string? startDb2, string? endDb2, string schedDb2) = await QueryCaRefreshPolicyAsync(db2, "rrt_ca_hourly"); + Assert.Equal(startDb1, startDb2); + Assert.Equal(endDb1, endDb2); + Assert.Equal(schedDb1, schedDb2); + + string? retDb1 = await QueryCaRetentionDropAfterAsync(db1, "rrt_ca_hourly"); + string? retDb2 = await QueryCaRetentionDropAfterAsync(db2, "rrt_ca_hourly"); + Assert.Equal(retDb1, retDb2); + + (IModel modelDb2, ScaffoldedModel _) = ScaffoldDatabase(db2, useDataAnnotations: false); + + IRelationalModel relDb1 = modelDb1.GetRelationalModel(); + IRelationalModel relDb2 = modelDb2.GetRelationalModel(); + + AssertZeroDiff(relDb1, relDb2, "ca-db-level: scaffold(DB1) → scaffold(DB2)"); + AssertZeroDiff(relDb2, relDb1, "ca-db-level: scaffold(DB2) → scaffold(DB1)"); + } + + #endregion +} + +#pragma warning restore EF1001 From cb2a2d75e3df286578f71b3f89eb63fe53051154 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 21 Jul 2026 23:00:47 +0200 Subject: [PATCH 10/12] docs: add relevant scaffolding docs --- .claude/reference/architecture.md | 8 ++-- .claude/reference/file-organization.md | 4 +- docs/01-dotnet-tools.md | 4 +- docs/03-scaffolding.md | 45 ++++++++++++++++++ samples/Eftdb.Samples.DatabaseFirst/README.md | 47 +++++-------------- 5 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 docs/03-scaffolding.md diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 4dcfafd..002e509 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -49,11 +49,12 @@ This document provides detailed architectural information for the CmdScale.Entit - `HypertableAnnotations.cs` - Annotation constants - `HypertableTypeBuilder.cs` - Fluent API: `IsHypertable()`, `WithChunkTimeInterval()`, `HasRangeDimension()`, `HasHashDimension()`, etc. -#### ReorderPolicy/ (3 files) +#### ReorderPolicy/ (5 files) - `ReorderPolicyAttribute.cs` - Data annotation: `[ReorderPolicy("index_name")]` - `ReorderPolicyConvention.cs` - IEntityTypeAddedConvention implementation - `ReorderPolicyAnnotations.cs` - Annotation constants -- `ReorderPolicyTypeBuilder.cs` - Fluent API: `WithReorderPolicy()` +- `ReorderPolicyTypeBuilder.cs` - Fluent API: `WithReorderPolicy()`; includes a scaffold-targeting overload that takes 5 positional string/int parameters and returns `ReorderPolicyStringBuilder`; also provides a chained overload on `RetentionPolicyStringBuilder` for co-located policy configuration +- `ReorderPolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code; exposes `WithInitialStart(DateTime)` as a chained method (DateTime cannot be rendered as a positional literal via `MethodCallCodeFragment`) #### RetentionPolicy/ (5 files) - `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy(DropAfter = "30 days")]` @@ -228,6 +229,7 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a | `AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations; parses the stored view definition via `ViewDefinitionParser` to reconstruct structured configuration | | `AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | | `AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; `ShouldRender` guard requires the parent renderer (hypertable or continuous aggregate) to have already consumed its annotation | +| `AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs` | Renders reorder policy annotations to `WithReorderPolicy(...)` fluent API or `[ReorderPolicy]` attribute; `ShouldRender` guard requires the hypertable renderer to have already consumed its annotation | | `AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared static helpers for rendering optional policy-job fields (InitialStart, ScheduleInterval, MaxRuntime, etc.) shared across all policy renderers | | `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | | `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | @@ -249,7 +251,7 @@ EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to con - When `UseDataAnnotations = false` → `GenerateFluentApiCalls` → fluent API method chains in `OnModelCreating` - When `UseDataAnnotations = true` → `GenerateDataAnnotationAttributes` → `[Attribute]` declarations on entity classes -Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`. Registration order matters: child renderers (`ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`) must run after their respective parent renderers so the `ShouldRender` guard can verify the parent annotation was consumed. +Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`. Registration order matters: child renderers (`ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`) must run after their respective parent renderers so the `ShouldRender` guard can verify the parent annotation was consumed. `TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index a42a77e..5a28479 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -34,7 +34,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | File | Purpose | |------|---------| -| `Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs` | Fluent API | +| `Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs` | Fluent API (including scaffold-targeting overload) | +| `Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs` | String-based builder used in scaffolded code | | `Configuration/ReorderPolicy/ReorderPolicyAnnotations.cs` | Annotation constants | | `Configuration/ReorderPolicy/ReorderPolicyAttribute.cs` | Data annotation | | `Configuration/ReorderPolicy/ReorderPolicyConvention.cs` | Convention processing | @@ -160,6 +161,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs` | Renders continuous aggregate annotations by parsing the view definition | | `Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | | `Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; registered after parent renderers (hypertable and continuous aggregate) | +| `Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs` | Renders reorder policy annotations to `WithReorderPolicy(...)` fluent API or `[ReorderPolicy]` attribute; registered after the hypertable renderer | | `Generators/AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared helpers for emitting policy-job optional arguments (`InitialStart`, `WithScheduleInterval`, etc.) | | `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | | `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | diff --git a/docs/01-dotnet-tools.md b/docs/01-dotnet-tools.md index d830a59..13e9588 100644 --- a/docs/01-dotnet-tools.md +++ b/docs/01-dotnet-tools.md @@ -141,4 +141,6 @@ Some configuration cannot be recovered from the database catalog, and some value - **WHERE clauses come back PostgreSQL-normalized**, including casts and parentheses (e.g. `"ticker" = 'MCRS'` scaffolds as `(ticker = 'MCRS'::text)`). - **TimescaleDB's auto-created indexes are suppressed**: the descending time index (`
__idx`) and the per-dimension composites (`
___idx`) are recreated automatically by TimescaleDB and do not scaffold as explicit indexes. A user-defined, non-unique index that exactly matches this name and column pattern is indistinguishable from the auto-created one and is suppressed as well. - **Raw GROUP BY expressions have no data-annotations representation**: in `--data-annotations` mode, a GROUP BY entry that is not a plain column (e.g. `EXTRACT(HOUR FROM time)`) is reported as a warning and must be configured via the Fluent API's `AddGroupByColumn(...)`. -- **Unparseable view definitions degrade gracefully**: when a continuous aggregate's view SQL cannot be parsed into the structured configuration, a warning is reported and the configuration is preserved as `.HasAnnotation(...)` calls; migrations recreate the view from the raw SQL definition. \ No newline at end of file +- **Unparseable view definitions degrade gracefully**: when a continuous aggregate's view SQL cannot be parsed into the structured configuration, a warning is reported and the configuration is preserved as `.HasAnnotation(...)` calls; migrations recreate the view from the raw SQL definition. + +For the full normalization contract and all known limitations, see [Scaffolding Behavior and Limitations](./03-scaffolding.md). \ No newline at end of file diff --git a/docs/03-scaffolding.md b/docs/03-scaffolding.md new file mode 100644 index 0000000..843425c --- /dev/null +++ b/docs/03-scaffolding.md @@ -0,0 +1,45 @@ +# Scaffolding Behavior and Limitations + +Scaffolding a TimescaleDB database with the `CmdScale.EntityFrameworkCore.TimescaleDB.Design` package emits typed Fluent API calls or data annotations for all TimescaleDB features. The scaffolded model is functionally equivalent to the database — migrating from it produces an identical schema, a contract enforced by roundtrip integration tests. See [dotnet ef tools](./01-dotnet-tools.md) for setup and command usage. + +## Positional-null policy forms + +Scaffolded retention and reorder policy calls use all positional arguments, filling omitted parameters with `null`: + +```csharp +.WithRetentionPolicy("90 days", null, null, null, 3, "15 minutes") +.WithReorderPolicy("trade_time_idx", null, null, 2, "10 minutes") +``` + +This is intentional: the user-facing overloads take `initialStart` (`DateTime?`) as an early positional parameter, and a trimmed call would bind later arguments to the wrong parameters. The parameter order is: + +```csharp +.WithRetentionPolicy(dropAfter, dropCreatedBefore, scheduleInterval, maxRuntime, maxRetries, retryPeriod) +.WithReorderPolicy(indexName, scheduleInterval, maxRuntime, maxRetries, retryPeriod) +``` + +`InitialStart` is always chained as a separate `.WithInitialStart(DateTime)` call. Rewriting a scaffolded call to named-argument style by hand is safe — the result is annotation-equivalent. + +The continuous aggregate refresh policy is not affected and scaffolds with trailing nulls trimmed (`.WithRefreshPolicy("7 days", "1 hour")`). + +## Normalization contract + +Scaffolding reproduces what the database stores, which is not always the literal text the original code wrote: + +- **Intervals are humanized when an exact single-unit reduction exists**: `01:00:00` → `1 hour`, `86400000000` (bigint µs) → `1 day`, `2 day` → `2 days`. Values without a single-unit reduction (`01:30:00`) are kept as-is. Calendar units collapse to the fixed duration TimescaleDB stores: `1 month` → `30 days`. +- **WHERE clauses come back PostgreSQL-normalized**: `"\"ticker\" = 'MCRS'"` scaffolds as `"(ticker = 'MCRS'::text)"`. +- **GROUP BY is canonicalized by PostgreSQL** before scaffolding sees it: positional notation (`.AddGroupByColumn("1, 2")`) is resolved to the referenced expressions. Simple columns become typed group-by entries, the `time_bucket(...)` expression is represented by the time-bucket configuration, and non-column expressions are preserved verbatim as raw strings. Grouping semantics always survive; the positional notation does not. +- **Values equal to TimescaleDB defaults are omitted** and filled back in by the runtime. +- **Implicitly enabled compression is reported explicitly**: chunk skipping requires compression, so a hypertable configured only with `.WithChunkSkipping(...)` scaffolds with `EnableCompression` set. +- **TimescaleDB's auto-created indexes are suppressed** (`
__idx` and per-dimension composites) — they are recreated automatically on hypertable creation. A user-defined, non-unique index exactly matching this name and column pattern is indistinguishable and is suppressed as well. +- **TimescaleDB internal schemas** (`_timescaledb_internal`, `_timescaledb_catalog`, `_timescaledb_config`, `_timescaledb_cache`) are excluded automatically unless explicitly requested via `--schema`. + +--- + +## Known limitations + +- **`WithNoData` and `CreateGroupIndexes`** are creation-time-only options of a continuous aggregate and are not queryable from the catalog. Scaffolded aggregates use the defaults (populated on creation, group indexes created). The schema is identical; only the initial-population timing differs. +- **CLR type fidelity is not preserved**: custom mapped types (e.g. NodaTime `LocalDateTime`) scaffold as BCL types with an explicit store type (`[Column(TypeName = "timestamp without time zone")] DateTime`). The column definition is identical; re-apply the custom type mapping manually if needed. +- **`InitialStart` sub-microsecond precision is lost**: PostgreSQL stores microseconds, so .NET's seventh fractional digit (100 ns ticks) does not survive — `…19.3905112Z` scaffolds as `…19.3905110Z`. +- **Non-column GROUP BY expressions have no data-annotations representation**: in `--data-annotations` mode they are reported as a warning and must be configured via the Fluent API's `AddGroupByColumn(string)`. +- **Unparseable view definitions degrade gracefully**: when a continuous aggregate's view SQL cannot be parsed into typed configuration, a warning is reported and the configuration is preserved as `.HasAnnotation(...)` calls; migrations still recreate the view correctly from the raw SQL definition. diff --git a/samples/Eftdb.Samples.DatabaseFirst/README.md b/samples/Eftdb.Samples.DatabaseFirst/README.md index 820fd13..f703b2b 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/README.md +++ b/samples/Eftdb.Samples.DatabaseFirst/README.md @@ -2,19 +2,13 @@ This project demonstrates how to use the **Database-First** approach with [TimescaleDB](https://www.timescale.com/) using the `CmdScale.EntityFrameworkCore.TimescaleDB` package. ---- - -## Required NuGet Packages - -Ensure the following package is installed in your project: - -- `CmdScale.EntityFrameworkCore.TimescaleDB.Design` +For command usage see [dotnet ef tools](../../docs/01-dotnet-tools.md); for the normalization contract and known limitations see [Scaffolding Behavior and Limitations](../../docs/03-scaffolding.md). --- -## Scaffold DbContext and Models +## Quick Start -Use the following command to scaffold the `DbContext` and entity classes from an existing TimescaleDB database: +Install the design-time package, then run: ```bash dotnet ef dbcontext scaffold \ @@ -27,29 +21,9 @@ dotnet ef dbcontext scaffold \ --project samples/Eftdb.Samples.DatabaseFirst ``` -This command will: - -- Generate entity models in the `Models/` directory -- Place the `MyTimescaleDbContext` in the current directory -- Use the specified connection string to connect to the TimescaleDB instance +Add `--data-annotations` to generate attributes instead of Fluent API calls. -> **Note**: When scaffolding your `DbContext`, use the `--schema` flag to target only your data schema (usually `public`). This prevents the tool from unintentionally including TimescaleDB's internal management tables from schemas like `_timescaledb_internal`. - -### Data Annotations Variant - -Add `--data-annotations` to express the TimescaleDB configuration as attributes on the entity classes (`[Hypertable]`, `[ContinuousAggregate]`, ...) instead of Fluent API calls in `OnModelCreating`: - -```bash -dotnet ef dbcontext scaffold \ - "Host=localhost;Database=cmdscale-ef-timescaledb;Username=timescale_admin;Password=R#!kro#GP43ra8Ae" \ - CmdScale.EntityFrameworkCore.TimescaleDB.Design \ - --data-annotations \ - --output-dir Models \ - --schema public \ - --context-dir . \ - --context MyTimescaleDbContext \ - --project samples/Eftdb.Samples.DatabaseFirst -``` +> **Note**: Use `--schema public` to prevent the tool from including TimescaleDB's internal management schemas (`_timescaledb_internal`, etc.). --- @@ -66,17 +40,18 @@ samples/Eftdb.Samples.DatabaseFirst/ ## Docker -- A `docker-compose.yml` file is available at the repository root to spin up a TimescaleDB container for local development: +A `docker-compose.yml` file is available at the repository root to spin up a TimescaleDB container for local development: - ```bash - docker-compose up -d - ``` +```bash +docker-compose up -d +``` -- Connection string settings should match the configuration in your `docker-compose.yml`. +Connection string settings should match the configuration in your `docker-compose.yml`. --- ## Resources +- [Scaffolding Behavior and Limitations](../../docs/03-scaffolding.md) - [Entity Framework Core Documentation](https://learn.microsoft.com/en-us/ef/core/) - [TimescaleDB Documentation](https://docs.timescale.com/) From a7798bd837ee8c8476deae957e214ccf1cabb349 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Wed, 22 Jul 2026 15:59:14 +0200 Subject: [PATCH 11/12] test: expand branch coverage across renderers, differs, generators and helpers --- ...escaleCSharpMigrationOperationGenerator.cs | 45 +- .../RetentionPolicyAttributeTests.cs | 22 + .../ContinuousAggregateConventionTests.cs | 152 ++ ...ontinuousAggregatePolicyConventionTests.cs | 292 +++ .../Conventions/HypertableConventionTests.cs | 38 + .../AnnotationRendererHelperTests.cs | 81 + ...tinuousAggregateAnnotationRendererTests.cs | 1293 +++++++++++++ .../HypertableAnnotationRendererTests.cs | 463 ++++- .../ReorderPolicyAnnotationRendererTests.cs | 22 + ...ContinuousAggregateCSharpGeneratorTests.cs | 60 +- .../TimescaleCSharpModelGeneratorTests.cs | 418 +++- ...TimescaleDbAnnotationCodeGeneratorTests.cs | 1696 +++++++++++++++++ .../Generators/ViewDefinitionParserTests.cs | 582 +++++- .../Differs/ContinuousAggregateDifferTests.cs | 1059 +++++++++- .../Differs/HypertableDifferTests.cs | 350 +++- .../Differs/RetentionPolicyDifferTests.cs | 60 + ...tinuousAggregateOperationGeneratorTests.cs | 166 +- .../ReorderPolicyOperationGeneratorTests.cs | 130 +- .../RetentionPolicyOperationGeneratorTests.cs | 160 +- .../Generators/SqlBuilderHelperTests.cs | 38 +- ...eCSharpMigrationOperationGeneratorTests.cs | 305 ++- ...nuousAggregateScaffoldingExtractorTests.cs | 76 + .../ReorderPolicyScaffoldingExtractorTests.cs | 56 + .../Internals/OperationOrderingTests.cs | 146 +- .../ParentEntityTypeResolverTests.cs | 192 ++ ...inuousAggregateMigrationExtensionsTests.cs | 40 +- ...RetentionPolicyMigrationExtensionsTests.cs | 19 + .../Scaffolding/IntervalParsingHelperTests.cs | 495 ++++- .../ContinuousAggregateBuilderTests.cs | 151 ++ 29 files changed, 8448 insertions(+), 159 deletions(-) create mode 100644 tests/Eftdb.Tests/Design/Generators/TimescaleDbAnnotationCodeGeneratorTests.cs create mode 100644 tests/Eftdb.Tests/Internals/ParentEntityTypeResolverTests.cs diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index 1801030..1be20d8 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -13,70 +13,51 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui ArgumentNullException.ThrowIfNull(operation); ArgumentNullException.ThrowIfNull(builder); - HypertableCSharpGenerator? hypertableCSharpGenerator = null; - ReorderPolicyCSharpGenerator? reorderPolicyCSharpGenerator = null; - RetentionPolicyCSharpGenerator? retentionPolicyCSharpGenerator = null; - ContinuousAggregateCSharpGenerator? continuousAggregateCSharpGenerator = null; - ContinuousAggregatePolicyCSharpGenerator? continuousAggregatePolicyCSharpGenerator = null; - switch (operation) { case CreateHypertableOperation create: - hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper); - hypertableCSharpGenerator.Generate(create, builder); + new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(create, builder); return; case AlterHypertableOperation alter: - hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper); - hypertableCSharpGenerator.Generate(alter, builder); + new HypertableCSharpGenerator(Dependencies.CSharpHelper).Generate(alter, builder); return; case AddReorderPolicyOperation addReorder: - reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - reorderPolicyCSharpGenerator.Generate(addReorder, builder); + new ReorderPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addReorder, builder); return; case AlterReorderPolicyOperation alterReorder: - reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - reorderPolicyCSharpGenerator.Generate(alterReorder, builder); + new ReorderPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(alterReorder, builder); return; case DropReorderPolicyOperation dropReorder: - reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - reorderPolicyCSharpGenerator.Generate(dropReorder, builder); + new ReorderPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(dropReorder, builder); return; case AddRetentionPolicyOperation addRetention: - retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - retentionPolicyCSharpGenerator.Generate(addRetention, builder); + new RetentionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addRetention, builder); return; case AlterRetentionPolicyOperation alterRetention: - retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - retentionPolicyCSharpGenerator.Generate(alterRetention, builder); + new RetentionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(alterRetention, builder); return; case DropRetentionPolicyOperation dropRetention: - retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - retentionPolicyCSharpGenerator.Generate(dropRetention, builder); + new RetentionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(dropRetention, builder); return; case CreateContinuousAggregateOperation createContinuousAggregate: - continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); - continuousAggregateCSharpGenerator.Generate(createContinuousAggregate, builder); + new ContinuousAggregateCSharpGenerator(Dependencies.CSharpHelper).Generate(createContinuousAggregate, builder); return; case AlterContinuousAggregateOperation alterContinuousAggregate: - continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); - continuousAggregateCSharpGenerator.Generate(alterContinuousAggregate, builder); + new ContinuousAggregateCSharpGenerator(Dependencies.CSharpHelper).Generate(alterContinuousAggregate, builder); return; case DropContinuousAggregateOperation dropContinuousAggregate: - continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper); - continuousAggregateCSharpGenerator.Generate(dropContinuousAggregate, builder); + new ContinuousAggregateCSharpGenerator(Dependencies.CSharpHelper).Generate(dropContinuousAggregate, builder); return; case AddContinuousAggregatePolicyOperation addContinuousAggregatePolicy: - continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - continuousAggregatePolicyCSharpGenerator.Generate(addContinuousAggregatePolicy, builder); + new ContinuousAggregatePolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addContinuousAggregatePolicy, builder); return; case RemoveContinuousAggregatePolicyOperation removeContinuousAggregatePolicy: - continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper); - continuousAggregatePolicyCSharpGenerator.Generate(removeContinuousAggregatePolicy, builder); + new ContinuousAggregatePolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(removeContinuousAggregatePolicy, builder); return; default: diff --git a/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs index cd1f666..8af88b0 100644 --- a/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs +++ b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs @@ -230,6 +230,28 @@ public void AllProperties_CanBeSetTogether() #endregion + #region Should_Throw_When_DropAfter_WhitespaceOnly + + [Fact] + public void Constructor2_With_WhitespaceOnly_DropAfter_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: " ", dropCreatedBefore: null)); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor2_With_WhitespaceOnly_DropCreatedBefore_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: null, dropCreatedBefore: "\t ")); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + #region Constructor2 Empty and Whitespace String Tests [Fact] diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs index ec0eab9..d230c1e 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs @@ -960,4 +960,156 @@ public void Should_Not_Set_GroupByColumns_Annotation_Without_Attributes() } #endregion + + #region Should_Not_Set_TimeBucket_Annotations_When_TimeBucket_Attribute_Absent + + [Hypertable("Timestamp")] + private class NoTimeBucketSourceEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "no_timebucket_view", ParentName = "no_timebucket_source")] + private class NoTimeBucketCaEntity + { + [Aggregate(EAggregateFunction.Avg, "Value")] + public double AvgValue { get; set; } + } + + private class NoTimeBucketContext : DbContext + { + public DbSet Sources => 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.HasNoKey(); + entity.ToTable("no_timebucket_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_TimeBucket_Annotations_When_TimeBucket_Attribute_Absent() + { + using NoTimeBucketContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoTimeBucketCaEntity))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)); + } + + #endregion + + #region Should_Use_Property_Name_As_Source_Column_When_AggregateAttribute_SourceColumn_Not_Provided + + [Hypertable("Timestamp")] + private class DefaultSourceColSourceEntity + { + public DateTime Timestamp { get; set; } + public double Revenue { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "default_src_col_view", ParentName = "default_src_col_source")] + [TimeBucket("1 day", "Timestamp")] + private class DefaultSourceColCaEntity + { + [Aggregate(EAggregateFunction.Count)] + public long EventCount { get; set; } + } + + private class DefaultSourceColContext : DbContext + { + public DbSet Sources => 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.HasNoKey(); + entity.ToTable("default_src_col_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Use_Property_Name_As_Source_Column_When_AggregateAttribute_SourceColumn_Not_Provided() + { + // Arrange + using DefaultSourceColContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DefaultSourceColCaEntity))!; + List? aggregateFunctions = entityType + .FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + + // Assert + Assert.NotNull(aggregateFunctions); + Assert.Contains(aggregateFunctions, e => e.StartsWith("EventCount:Count:*", StringComparison.Ordinal)); + } + + #endregion + + #region Should_Skip_CA_Annotations_For_Property_Bag_Entity + + private class PropertyBagCaContext : DbContext + { + 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("PropertyBagCaTestEntity", b => + { + b.HasNoKey(); + b.ToTable("prop_bag_ca_test"); + b.Property("Id"); + }); + } + } + + [Fact] + public void Should_Skip_CA_Annotations_For_Property_Bag_Entity() + { + // Arrange + using PropertyBagCaContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType("PropertyBagCaTestEntity")!; + + // Act + Assert + Assert.NotNull(entityType); + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs index a6232fb..b612723 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs @@ -849,6 +849,298 @@ public void Should_Require_ContinuousAggregate_Attribute() #endregion + #region Should_Skip_Entity_Without_ContinuousAggregatePolicy_Attribute + + private class MetricEntitySkip + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics_skip", ParentName = "MetricsSkip")] + private class AggregateEntitySkip + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class SkipNoPolicyContext : 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("MetricsSkip"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Skip_Entity_Without_ContinuousAggregatePolicy_Attribute() + { + // Arrange + using SkipNoPolicyContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntitySkip))!; + + // Assert + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)); + } + + #endregion + + #region Should_Not_Set_StartOffset_When_Null + + private class MetricEntityNoStart + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics_no_start", ParentName = "MetricsNoStart")] + [ContinuousAggregatePolicy(EndOffset = "1 hour", ScheduleInterval = "1 hour")] + private class AggregateEntityNoStart + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoStartOffsetContext : 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("MetricsNoStart"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_StartOffset_When_Null() + { + // Arrange + using NoStartOffsetContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntityNoStart))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset)); + } + + #endregion + + #region Should_Not_Set_EndOffset_When_Null + + private class MetricEntityNoEnd + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics_no_end", ParentName = "MetricsNoEnd")] + [ContinuousAggregatePolicy(StartOffset = "1 month", ScheduleInterval = "1 hour")] + private class AggregateEntityNoEnd + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoEndOffsetContext : 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("MetricsNoEnd"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_EndOffset_When_Null() + { + // Arrange + using NoEndOffsetContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntityNoEnd))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset)); + } + + #endregion + + #region Should_Not_Set_ScheduleInterval_When_Null + + private class MetricEntityNoSchedule + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics_no_schedule", ParentName = "MetricsNoSchedule")] + [ContinuousAggregatePolicy(StartOffset = "1 month", EndOffset = "1 hour")] + private class AggregateEntityNoSchedule + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoScheduleIntervalContext : 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("MetricsNoSchedule"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Not_Set_ScheduleInterval_When_Null() + { + // Arrange + using NoScheduleIntervalContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntityNoSchedule))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region Should_Not_Set_IncludeTieredData_When_Not_Specified + + private class MetricEntityNoTiered + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AggregateEntityNoTiered + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoIncludeTieredDataContext : 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("MetricsNoTiered"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics_no_tiered", + "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_Not_Set_IncludeTieredData_When_Not_Specified() + { + // Arrange + using NoIncludeTieredDataContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntityNoTiered))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData)); + } + + #endregion + #region Should_Not_Set_BucketsPerBatch_Annotation_When_Equal_To_Default_One private class MetricEntity16 diff --git a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs index 75b664b..0987911 100644 --- a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs @@ -1075,4 +1075,42 @@ public void MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI() } #endregion + + #region Should_Skip_Hypertable_Annotations_For_Property_Bag_Entity + + private class PropertyBagEntityContext : DbContext + { + 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("PropertyBagHypertableTestEntity", b => + { + b.HasNoKey(); + b.ToTable("prop_bag_hypertable_test"); + b.Property("Id"); + }); + } + } + + [Fact] + public void Should_Skip_Hypertable_Annotations_For_Property_Bag_Entity() + { + // Arrange + using PropertyBagEntityContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType entityType = model.FindEntityType("PropertyBagHypertableTestEntity")!; + + // Assert + Assert.NotNull(entityType); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.IsHypertable)); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs index 2613bc6..1e42009 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs @@ -371,6 +371,48 @@ public void TryResolvePropertyName_Fallback_FindProperty_When_No_StoreObject() #endregion + #region TryResolvePropertyName_NoStore_And_PropertyMissing_Returns_Raw + + private class NoStoreMissingPropertyEntity + { + public int Counter { get; set; } + } + + private class NoStoreMissingPropertyContext : DbContext + { + public DbSet Items => 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.ToSqlQuery("SELECT 0 AS \"Counter\""); + }); + } + } + + [Fact] + public void TryResolvePropertyName_NoStore_And_PropertyMissing_Returns_Raw() + { + // Arrange + using NoStoreMissingPropertyContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "nonexistent_column", out string propertyName); + + // Assert + Assert.False(found); + Assert.Equal("nonexistent_column", propertyName); + } + + #endregion + #region TryResolvePropertyName_View_Mapped_Entity_Returns_True_On_Match private class ViewMappedEntity @@ -411,4 +453,43 @@ public void TryResolvePropertyName_View_Mapped_Entity_Returns_True_On_Match() } #endregion + + #region TryResolvePropertyName_FindProperty_Fallback_When_Column_Name_Differs_From_Property_Name + + private class SnakeCaseEntity + { + public int Id { get; set; } + public double SomeValue { get; set; } + } + + private class SnakeCaseContext : DbContext + { + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb() + .UseSnakeCaseNamingConvention(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(e => e.ToTable("snake_case_entity")); + } + + [Fact] + public void TryResolvePropertyName_FindProperty_Fallback_When_Column_Name_Differs_From_Property_Name() + { + // Arrange + using SnakeCaseContext context = new(); + IEntityType entityType = GetEntityType(context); + + // Act + bool found = AnnotationRendererHelper.TryResolvePropertyName(entityType, "SomeValue", out string propertyName); + + // Assert + Assert.True(found); + Assert.Equal("SomeValue", propertyName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs index 299dcdc..f2f8807 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs @@ -1507,4 +1507,1297 @@ public void GenerateDataAnnotationAttributes_Property_CacheHit_Returns_Identical } #endregion + + // ── ViewDefinition parsing edge cases ───────────────────────────────────── + + #region Should_Return_Empty_When_ViewDefinition_Has_No_TimeBucket + + private class NoTimeBucketCaEntity + { + public double Value { get; set; } + } + + private class NoTimeBucketContext : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("no_time_bucket_stats"); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_ViewDefinition_Has_No_TimeBucket() + { + // Arrange + const string viewDefNoTimeBucket = + "SELECT value FROM raw_data GROUP BY value"; + + using NoTimeBucketContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary fluentAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "no_time_bucket_stats"), + (ContinuousAggregateAnnotations.ParentName, "raw_data"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefNoTimeBucket)); + + Dictionary daAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "no_time_bucket_stats"), + (ContinuousAggregateAnnotations.ParentName, "raw_data"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefNoTimeBucket)); + + IAnnotationCodeGenerator generator = CreateAnnotationCodeGenerator(); + + // Act + IReadOnlyList fluentResult = generator.GenerateFluentApiCalls(entityType, fluentAnnotations); + IReadOnlyList daResult = generator.GenerateDataAnnotationAttributes(entityType, daAnnotations); + + // Assert + Assert.DoesNotContain(fluentResult, f => CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + Assert.DoesNotContain(daResult, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion + + #region Should_Return_Empty_When_TimeBucketSourceColumn_Is_Null + + private class NoSourceColumnCaEntity + { + public double Value { get; set; } + } + + private class NoSourceColumnContext : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("no_source_col_stats"); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_TimeBucketSourceColumn_Is_Null() + { + // Arrange + const string viewDefNoSourceCol = + "SELECT time_bucket('1 hour') AS bucket, avg(value) AS avg_value FROM raw_data GROUP BY 1"; + + using NoSourceColumnContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary fluentAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "no_source_col_stats"), + (ContinuousAggregateAnnotations.ParentName, "raw_data"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefNoSourceCol)); + + Dictionary daAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "no_source_col_stats"), + (ContinuousAggregateAnnotations.ParentName, "raw_data"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDefNoSourceCol)); + + IAnnotationCodeGenerator generator = CreateAnnotationCodeGenerator(); + + // Act + IReadOnlyList fluentResult = generator.GenerateFluentApiCalls(entityType, fluentAnnotations); + IReadOnlyList daResult = generator.GenerateDataAnnotationAttributes(entityType, daAnnotations); + + // Assert + Assert.DoesNotContain(fluentResult, f => CollectMethodChain(f).Contains(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate))); + Assert.DoesNotContain(daResult, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion + + // ── ResolveParentColumnArg — unresolvable parent ─────────────────────────── + + #region Should_Use_Raw_ColumnName_When_Parent_EntityType_Not_Resolved + + private class UnresolvableParentCaEntity + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class UnresolvableParentContext : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("unresolvable_parent_stats"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Use_Raw_ColumnName_When_Parent_EntityType_Not_Resolved() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, src.\"time\") AS bucket," + + " avg(src.value) AS avg_value" + + " FROM unknown_source src" + + " GROUP BY time_bucket('01:00:00'::interval, src.\"time\")"; + + using UnresolvableParentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "unresolvable_parent_stats"), + (ContinuousAggregateAnnotations.ParentName, "unknown_source"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + + Assert.IsType(root.Arguments[1]); + Assert.IsType(root.Arguments[3]); + MethodCallCodeFragment? addAgg = null; + for (MethodCallCodeFragment? current = root; current != null; current = current.ChainedCall) + { + if (current.Method == "AddAggregateFunction") { addAgg = current; break; } + } + Assert.NotNull(addAgg); + Assert.IsType(addAgg.Arguments[1]); + } + + #endregion + + // ── COUNT(*) wildcard in fluent API ─────────────────────────────────────── + + #region Should_Use_Wildcard_String_When_AggregateSourceColumn_Is_Star + + private class CountStarSourceEntity + { + public DateTime Time { get; set; } + public double Price { get; set; } + } + + private class CountStarCaEntity + { + public DateTime Bucket { get; set; } + public long TradeCount { get; set; } + } + + private class CountStarFluentContext : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("count_star_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Price).HasColumnName("price"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("count_star_ca_stats"); + e.Property(x => x.TradeCount).HasColumnName("trade_count"); + }); + } + } + + [Fact] + public void Should_Use_Wildcard_String_When_AggregateSourceColumn_Is_Star() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " count(*) AS trade_count" + + " FROM count_star_source s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using CountStarFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "count_star_ca_stats"), + (ContinuousAggregateAnnotations.ParentName, "count_star_source"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + + MethodCallCodeFragment? addAgg = null; + for (MethodCallCodeFragment? current = root; current != null; current = current.ChainedCall) + { + if (current.Method == "AddAggregateFunction") { addAgg = current; break; } + } + Assert.NotNull(addAgg); + Assert.Equal("*", addAgg.Arguments[1]); + Assert.IsType(addAgg.Arguments[1]); + Assert.Equal(EAggregateFunction.Count, addAgg.Arguments[2]); + } + + #endregion + + // ── MaterializedOnly, WhereClause, ChunkInterval, GroupByColumn, raw alias ── + + #region Should_Chain_MaterializedOnly_When_MaterializedOnly_Is_True + + private class MaterializedOnlyParentEntity4 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnlyCaEntity4 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyContext4 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("mat_only_src4"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("mat_only_stats4"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Chain_MaterializedOnly_When_MaterializedOnly_Is_True() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM mat_only_src4 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using MaterializedOnlyContext4 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary fluentAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "mat_only_stats4"), + (ContinuousAggregateAnnotations.ParentName, "mat_only_src4"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (ContinuousAggregateAnnotations.MaterializedOnly, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, fluentAnnotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Contains("MaterializedOnly", chain); + } + + #endregion + + #region Should_Chain_WhereClause_When_WhereClause_Present_In_FluentApi + + private class WhereClauseParentEntity5 + { + public DateTime Time { get; set; } + public double Value { get; set; } + public string? Region { get; set; } + } + + private class WhereClauseCaEntity5 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class WhereClauseContext5 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("where_clause_src5"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("where_clause_stats5"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Chain_WhereClause_When_WhereClause_Present_In_FluentApi() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM where_clause_src5 s" + + " WHERE s.value > 0" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using WhereClauseContext5 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "where_clause_stats5"), + (ContinuousAggregateAnnotations.ParentName, "where_clause_src5"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Contains("Where", chain); + } + + #endregion + + #region Should_Chain_ChunkInterval_When_Not_Derived_Default + + private class ChunkIntervalParentEntity6 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ChunkIntervalCaEntity6 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ChunkIntervalContext6 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("chunk_interval_src6"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("chunk_interval_stats6"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Chain_ChunkInterval_When_Not_Derived_Default() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM chunk_interval_src6 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using ChunkIntervalContext6 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "chunk_interval_stats6"), + (ContinuousAggregateAnnotations.ParentName, "chunk_interval_src6"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (ContinuousAggregateAnnotations.ChunkInterval, "1 month")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Contains("WithChunkInterval", chain); + } + + #endregion + + #region Should_Not_Chain_ChunkInterval_When_IsDerivedDefault + + private class DerivedDefaultChunkIntervalParentEntity7 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DerivedDefaultChunkIntervalCaEntity7 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class DerivedDefaultChunkIntervalContext7 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("derived_default_src7"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("derived_default_stats7"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Not_Chain_ChunkInterval_When_IsDerivedDefault() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM derived_default_src7 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using DerivedDefaultChunkIntervalContext7 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "derived_default_stats7"), + (ContinuousAggregateAnnotations.ParentName, "derived_default_src7"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (ContinuousAggregateAnnotations.ChunkInterval, "70 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.DoesNotContain("WithChunkInterval", chain); + } + + #endregion + + #region Should_Chain_AddGroupByColumn_For_GroupBy_Columns + + private class GroupByColParentEntity8 + { + public DateTime Time { get; set; } + public double Value { get; set; } + public string Region { get; set; } = ""; + } + + private class GroupByColCaEntity8 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = ""; + } + + private class GroupByColContext8 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("group_by_col_src8"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + e.Property(x => x.Region).HasColumnName("region"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("group_by_col_stats8"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + e.Property(x => x.Region).HasColumnName("region"); + }); + } + } + + [Fact] + public void Should_Chain_AddGroupByColumn_For_GroupBy_Columns() + { + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value," + + " s.region AS region" + + " FROM group_by_col_src8 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\"), s.region"; + + using GroupByColContext8 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "group_by_col_stats8"), + (ContinuousAggregateAnnotations.ParentName, "group_by_col_src8"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Contains("AddGroupByColumn", chain); + } + + #endregion + + #region Should_Use_Raw_Alias_When_AggregateAlias_Does_Not_Match_Any_Property + + private class RawAliasParentEntity9 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class RawAliasCaEntity9 + { + public DateTime Bucket { get; set; } + } + + private class RawAliasContext9 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("raw_alias_src9"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("raw_alias_stats9"); + }); + } + } + + [Fact] + public void Should_Use_Raw_Alias_When_AggregateAlias_Does_Not_Match_Any_Property() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS unmapped_alias" + + " FROM raw_alias_src9 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using RawAliasContext9 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "raw_alias_stats9"), + (ContinuousAggregateAnnotations.ParentName, "raw_alias_src9"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + MethodCallCodeFragment? addAgg = null; + for (MethodCallCodeFragment? cur = root; cur != null; cur = cur.ChainedCall) + { + if (cur.Method == "AddAggregateFunction") { addAgg = cur; break; } + } + Assert.NotNull(addAgg); + Assert.IsType(addAgg.Arguments[0]); + Assert.Equal("unmapped_alias", addAgg.Arguments[0]); + } + + #endregion + + #region Should_Set_MaterializedOnly_Named_Arg_In_DataAnnotations + + private class MaterializedOnlyDaParentEntity10 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class MaterializedOnlyDaCaEntity10 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MaterializedOnlyDaContext10 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("mat_only_da_src10"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("mat_only_da_stats10"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Set_MaterializedOnly_Named_Arg_In_DataAnnotations() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM mat_only_da_src10 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + + using MaterializedOnlyDaContext10 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "mat_only_da_stats10"), + (ContinuousAggregateAnnotations.ParentName, "mat_only_da_src10"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (ContinuousAggregateAnnotations.MaterializedOnly, true)); + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? caAttr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(caAttr); + Assert.True(caAttr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.MaterializedOnly))); + } + + #endregion + + #region Should_Set_Where_Named_Arg_In_DataAnnotations + + private class WhereDaParentEntity11 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class WhereDaCaEntity11 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class WhereDaContext11 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("where_da_src11"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("where_da_stats11"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Set_Where_Named_Arg_In_DataAnnotations() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM where_da_src11 s" + + " WHERE s.value > 0" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + + using WhereDaContext11 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "where_da_stats11"), + (ContinuousAggregateAnnotations.ParentName, "where_da_src11"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? caAttr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(caAttr); + Assert.True(caAttr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.Where))); + } + + #endregion + + #region Should_Set_ChunkInterval_Named_Arg_In_DataAnnotations_When_Not_Derived_Default + + private class ChunkIntervalDaParentEntity12 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class ChunkIntervalDaCaEntity12 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ChunkIntervalDaContext12 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("chunk_da_src12"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("chunk_da_stats12"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void Should_Set_ChunkInterval_Named_Arg_In_DataAnnotations_When_Not_Derived_Default() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM chunk_da_src12 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + + using ChunkIntervalDaContext12 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "chunk_da_stats12"), + (ContinuousAggregateAnnotations.ParentName, "chunk_da_src12"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (ContinuousAggregateAnnotations.ChunkInterval, "1 month")); + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? caAttr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(caAttr); + Assert.True(caAttr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.ChunkInterval))); + } + + #endregion + + // ── Default fallbacks: createGroupIndexes and parentName ───────────────── + + #region CreateGroupIndexes_DefaultsToTrue_When_Annotation_Is_Null + + private class NullGiParentEntity13 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class NullGiCaEntity13 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullGiContext13 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("null_gi_src13"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("null_gi_stats13"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void CreateGroupIndexes_DefaultsToTrue_When_Annotation_Is_Null() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value" + + " FROM null_gi_src13 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using NullGiContext13 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "null_gi_stats13"), + (ContinuousAggregateAnnotations.ParentName, "null_gi_src13"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.DoesNotContain("CreateGroupIndexes", chain); + } + + #endregion + + #region GenerateFluentApiCalls_ParentName_Null_FallsBackTo_MaterializedViewName + + private class NullParentNameCaEntity14 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullParentNameContext14 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("null_parent_name_stats14"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_ParentName_Null_FallsBackTo_MaterializedViewName() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.avg_value) AS avg_value" + + " FROM unknown_parent_src s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using NullParentNameContext14 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "null_parent_name_stats14"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + string parentArg = Assert.IsType(root.Arguments[1]); + Assert.Equal(string.Empty, parentArg); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ParentName_Null_FallsBackTo_EmptyString + + private class NullParentNameDaCaEntity15 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullParentNameDaContext15 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("null_parent_name_da_stats15"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_ParentName_Null_FallsBackTo_EmptyString() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.avg_value) AS avg_value" + + " FROM unknown_parent_da_src s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using NullParentNameDaContext15 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "null_parent_name_da_stats15"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? caAttr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(caAttr); + Assert.True(caAttr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.ParentName))); + string parentNameArg = Assert.IsType(caAttr.NamedArguments[nameof(ContinuousAggregateAttribute.ParentName)]); + Assert.Equal(string.Empty, parentNameArg); + } + + #endregion + + #region ConsumeFeatureAnnotations_ReportsWarning_WhenViewDefinitionUnparseable + + [Fact] + public void ConsumeFeatureAnnotations_ReportsWarning_WhenViewDefinitionUnparseable() + { + // Arrange + using CaTestContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hourly_stats"), + (ContinuousAggregateAnnotations.ViewDefinition, "NOT VALID SQL AT ALL")); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + string warning = Assert.Single(reporter.Warnings); + Assert.Contains("hourly_stats", warning); + Assert.Contains(ContinuousAggregateAnnotations.MaterializedViewName, annotations.Keys); + } + + #endregion + + #region GenerateDataAnnotationAttributes_NoGroupByAttribute_WhenGroupByEntryUnresolvable + + private class UnresolvableGroupByParentEntity16 + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class UnresolvableGroupByCaEntity16 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class UnresolvableGroupByContext16 : DbContext + { + public DbSet Sources => Set(); + public DbSet Stats => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("unresolvable_gb_src16"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("unresolvable_gb_stats16"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_NoGroupByAttribute_WhenGroupByEntryUnresolvable() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket," + + " avg(s.value) AS avg_value," + + " s.region AS region" + + " FROM unresolvable_gb_src16 s" + + " GROUP BY time_bucket('01:00:00'::interval, s.\"time\"), s.region"; + + using UnresolvableGroupByContext16 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "unresolvable_gb_stats16"), + (ContinuousAggregateAnnotations.ParentName, "unresolvable_gb_src16"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + (TimescaleDbAnnotationCodeGenerator generator, RecordingReporter reporter) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + IReadOnlyList result = generator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Single(reporter.Warnings); + Assert.Contains(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion + + // ── Fluent API: parentEntityType null AND parentName null → uses materializedViewName ── + + #region GenerateFluentApiCalls_Uses_MaterializedViewName_When_ParentName_And_EntityType_Both_Null + + private class NullParentCaEntity17 + { + public DateTime Bucket { get; set; } + public double AvgVal { get; set; } + } + + private class NullParentContext17 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("null_parent_ca17"); + e.Property(x => x.AvgVal).HasColumnName("avg_val"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_Uses_MaterializedViewName_When_ParentName_And_EntityType_Both_Null() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.time) AS bucket, avg(s.avg_val) AS avg_val" + + " FROM null_parent_src17 s GROUP BY 1"; + + using NullParentContext17 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "null_parent_ca17"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + (TimescaleDbAnnotationCodeGenerator generator, _) = CreateGeneratorWithReporter(); + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + IEnumerable methods = result.SelectMany(f => CollectMethodChain(f)); + Assert.Contains("IsContinuousAggregate", methods); + } + + #endregion + + // ── DA mode: parentEntityType null AND parentName null → uses materializedViewName ── + + #region GenerateDataAnnotationAttributes_Uses_MaterializedViewName_When_ParentName_And_EntityType_Both_Null + + private class NullParentDaCaEntity18 + { + public DateTime Bucket { get; set; } + public double AvgVal { get; set; } + } + + private class NullParentDaContext18 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("null_parent_da_ca18"); + e.Property(x => x.AvgVal).HasColumnName("avg_val"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_Uses_MaterializedViewName_When_ParentName_And_EntityType_Both_Null() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.time) AS bucket, avg(s.avg_val) AS avg_val" + + " FROM null_parent_da_src18 s GROUP BY 1"; + + using NullParentDaContext18 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "null_parent_da_ca18"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + (TimescaleDbAnnotationCodeGenerator generator, _) = CreateGeneratorWithReporter(); + generator.ScaffoldDataAnnotationsMode = true; + + // Act + IReadOnlyList result = generator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Contains(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs index 48d173d..633455d 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs @@ -95,7 +95,6 @@ public void GenerateFluentApiCalls_Returns_Empty_When_TimeColumn_Is_Missing() { using NoTimeColumnContext context = new(); IEntityType entityType = GetEntityType(context); - // IsHypertable is true but no HypertableTimeColumn annotation Dictionary annotations = Annotations( (HypertableAnnotations.IsHypertable, true)); @@ -233,7 +232,6 @@ public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_Ascending() List chain = CollectMethodChain(hypertableCall); Assert.Contains(nameof(HypertableTypeBuilder.WithCompressionOrderBy), chain); - // Find the WithCompressionOrderBy fragment and verify the closure uses ByAscending MethodCallCodeFragment? current = hypertableCall; MethodCallCodeFragment? orderByCall = null; while (current != null) @@ -371,7 +369,6 @@ public void GenerateFluentApiCalls_Chains_WithCompressionOrderBy_NullsFirst() Assert.NotNull(orderByCall); NestedClosureCodeFragment closure = Assert.IsType(orderByCall.Arguments[0]); Assert.Equal(nameof(OrderBySelector<>.ByAscending), closure.MethodCalls[0].Method); - // Second argument to ByAscending should be true (nullsFirst) Assert.Equal(2, closure.MethodCalls[0].Arguments.Count); Assert.Equal(true, closure.MethodCalls[0].Arguments[1]); } @@ -406,7 +403,6 @@ public void GenerateFluentApiCalls_SegmentBy_Suppresses_Standalone_EnableCompres MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); List chain = CollectMethodChain(hypertableCall); - // SegmentBy implicitly enables compression, so standalone EnableCompression should not appear Assert.DoesNotContain(nameof(HypertableTypeBuilder.EnableCompression), chain); Assert.Contains(nameof(HypertableTypeBuilder.WithCompressionSegmentBy), chain); } @@ -829,7 +825,6 @@ public void GenerateDataAnnotationAttributes_EmitsDimension_Hash_Attribute() Assert.Contains(result, a => a.Type == typeof(DimensionAttribute)); AttributeCodeFragment dimAttr = result.First(a => a.Type == typeof(DimensionAttribute)); - // Second positional arg should be EDimensionType.Hash Assert.Equal(EDimensionType.Hash, dimAttr.Arguments[1]); } @@ -1094,4 +1089,462 @@ public void ConsumeFeatureAnnotations_Consumes_All_Hypertable_Annotations_In_DaM } #endregion + + #region GenerateDataAnnotationAttributes_CompressionOrderBy_Unmapped_Column_With_Suffix_Returns_RawString + + private class UnmappedOrderByEntity { public DateTime Ts { get; set; } } + + private class UnmappedOrderByContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_unmapped_orderby"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_CompressionOrderBy_Unmapped_Column_With_Suffix_Returns_RawString() + { + // Arrange + using UnmappedOrderByContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "unmapped_col DESC")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.CompressionOrderBy))); + string[] orderByArray = Assert.IsType(hypertableAttr.NamedArguments[nameof(HypertableAttribute.CompressionOrderBy)]); + Assert.Single(orderByArray); + Assert.Equal("unmapped_col DESC", orderByArray[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CompressionOrderBy_Mapped_Column_With_Suffix_Returns_NameOfCodeFragment + + private class MappedOrderByEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class MappedOrderByContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_mapped_orderby"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_CompressionOrderBy_Mapped_Column_With_Suffix_Returns_NameOfCodeFragment() + { + // Arrange + using MappedOrderByContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "DeviceId DESC")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.CompressionOrderBy))); + object[] orderByArray = Assert.IsType(hypertableAttr.NamedArguments[nameof(HypertableAttribute.CompressionOrderBy)]); + Assert.Single(orderByArray); + Assert.IsType(orderByArray[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_TimeColumn_IsWhitespace + + private class DaWhitespaceTimeEntity { public DateTime Ts { get; set; } } + + private class DaWhitespaceTimeContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_ws_time"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_TimeColumn_IsWhitespace() + { + // Arrange + using DaWhitespaceTimeContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, " ")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(HypertableAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_InvalidJsonDimensions_ReturnsEmptyDimensions + + private class DaInvalidDimensionsEntity { public DateTime Ts { get; set; } } + + private class DaInvalidDimensionsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_invalid_dims"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_InvalidJsonDimensions_ReturnsEmptyDimensions() + { + // Arrange + using DaInvalidDimensionsContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, "not valid json!!")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Contains(result, a => a.Type == typeof(HypertableAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(DimensionAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_ChunkTimeInterval_When_Whitespace + + private class DaChunkIntervalWhitespaceEntity { public DateTime Ts { get; set; } } + + private class DaChunkIntervalWhitespaceContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_cti_ws"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_ChunkTimeInterval_When_Whitespace() + { + // Arrange + using DaChunkIntervalWhitespaceContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, " ")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.False(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.ChunkTimeInterval))); + } + + #endregion + + #region GenerateFluentApiCalls_Skips_ChunkTimeInterval_When_Whitespace + + private class FluentWhitespaceIntervalEntity { public DateTime Ts { get; set; } } + + private class FluentWhitespaceIntervalContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ws_interval"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Skips_ChunkTimeInterval_When_Whitespace() + { + // Arrange + using FluentWhitespaceIntervalContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.ChunkTimeInterval, " ")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.WithChunkTimeInterval), CollectMethodChain(hypertableCall)); + } + + #endregion + + #region GenerateFluentApiCalls_Skips_MigrateData_When_Annotation_Value_Is_False + + private class FluentMigrateDataFalseEntity { public DateTime Ts { get; set; } } + + private class FluentMigrateDataFalseContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_migrate_false"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Skips_MigrateData_When_Annotation_Value_Is_False() + { + // Arrange + using FluentMigrateDataFalseContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.MigrateData, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.WithMigrateData), CollectMethodChain(hypertableCall)); + } + + #endregion + + #region GenerateFluentApiCalls_Skips_Dimensions_When_AdditionalDimensions_Is_Empty_Array + + private class FluentEmptyDimensionsEntity { public DateTime Ts { get; set; } } + + private class FluentEmptyDimensionsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_empty_dims"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Skips_Dimensions_When_AdditionalDimensions_Is_Empty_Array() + { + // Arrange + using FluentEmptyDimensionsContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, "[]")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.HasHashDimension), chain); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.HasRangeDimension), chain); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_HashDimension_With_Null_NumberOfPartitions + + private class FluentHashDimNullPartitionsEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class FluentHashDimNullPartitionsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_hash_null_parts"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_HashDimension_With_Null_NumberOfPartitions() + { + // Arrange + using FluentHashDimNullPartitionsContext context = new(); + IEntityType entityType = GetEntityType(context); + Dimension hashDimWithNullPartitions = new() { ColumnName = "DeviceId", Type = EDimensionType.Hash, NumberOfPartitions = null }; + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(new List { hashDimWithNullPartitions }))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.Contains(nameof(HypertableTypeBuilder.HasHashDimension), CollectMethodChain(hypertableCall)); + } + + #endregion + + #region GenerateFluentApiCalls_Chains_RangeDimension_With_Null_Interval + + private class FluentRangeDimNullIntervalEntity { public DateTime Ts { get; set; } public string Region { get; set; } = ""; } + + private class FluentRangeDimNullIntervalContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_range_null_interval"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_RangeDimension_With_Null_Interval() + { + // Arrange + using FluentRangeDimNullIntervalContext context = new(); + IEntityType entityType = GetEntityType(context); + Dimension rangeDimNullInterval = new() { ColumnName = "Region", Type = EDimensionType.Range, Interval = null }; + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(new List { rangeDimNullInterval }))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.Contains(nameof(HypertableTypeBuilder.HasRangeDimension), CollectMethodChain(hypertableCall)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Skips_MigrateData_When_Annotation_Value_Is_False + + private class DaMigrateDataFalseEntity { public DateTime Ts { get; set; } } + + private class DaMigrateDataFalseContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_migrate_false"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Skips_MigrateData_When_Annotation_Value_Is_False() + { + // Arrange + using DaMigrateDataFalseContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.MigrateData, false)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.False(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.MigrateData))); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CompressionOrderBy_Unmapped_Column_No_Space + + private class DaOrderByNoSpaceEntity { public DateTime Ts { get; set; } } + + private class DaOrderByNoSpaceContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_ob_no_space"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_CompressionOrderBy_Unmapped_Column_No_Space() + { + // Arrange + using DaOrderByNoSpaceContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionOrderBy, "unmapped_col")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment hypertableAttr = Assert.Single(result, a => a.Type == typeof(HypertableAttribute)); + Assert.True(hypertableAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.CompressionOrderBy))); + string[] orderByArray = Assert.IsType(hypertableAttr.NamedArguments[nameof(HypertableAttribute.CompressionOrderBy)]); + Assert.Single(orderByArray); + Assert.Equal("unmapped_col", orderByArray[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Dimension_Unmapped_Column_Returns_RawString_And_Range_With_Null_Interval + + private class DaDimUnmappedEntity { public DateTime Ts { get; set; } } + + private class DaDimUnmappedContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_dim_unmapped"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Dimension_Unmapped_Column_Returns_RawString_And_Range_With_Null_Interval() + { + // Arrange + using DaDimUnmappedContext context = new(); + IEntityType entityType = GetEntityType(context); + Dimension rangeDimNullInterval = new() { ColumnName = "unmapped_dim_col", Type = EDimensionType.Range, Interval = null }; + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(new List { rangeDimNullInterval }))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Contains(result, a => a.Type == typeof(DimensionAttribute)); + AttributeCodeFragment dimAttr = result.First(a => a.Type == typeof(DimensionAttribute)); + Assert.Equal(EDimensionType.Range, dimAttr.Arguments[1]); + Assert.Equal(string.Empty, dimAttr.Arguments[2]); + Assert.IsType(dimAttr.Arguments[0]); + Assert.Equal("unmapped_dim_col", dimAttr.Arguments[0]); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs index 857daa6..eb209ae 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ReorderPolicyAnnotationRendererTests.cs @@ -930,5 +930,27 @@ public void GenerateDataAnnotationAttributes_Does_Not_Consume_When_ShouldRender_ } #endregion + + + #region GenerateFluentApiCalls_Returns_Empty_When_IndexName_Missing + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_IndexName_Missing() + { + // Arrange + using ReorderRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ReorderPolicyAnnotations.HasReorderPolicy, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithReorderPolicy")); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs index 9f083a2..3e3b2f2 100644 --- a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCSharpGeneratorTests.cs @@ -44,7 +44,7 @@ public void CreateContinuousAggregate_AggregateFunction_FullyQualifiedTypedEntry // Act string result = Generate(op); - // Assert — fully qualified type and enum reference. + // Assert Assert.Contains( "new CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.ContinuousAggregateFunction(\"avg_t\", CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.EAggregateFunction.Avg, \"temp\")", result); @@ -57,7 +57,7 @@ public void CreateContinuousAggregate_AggregateFunction_FullyQualifiedTypedEntry [Fact] public void CreateContinuousAggregate_MalformedAggregateString_IsSkipped() { - // Arrange — second entry has only two parts and must be silently skipped. + // Arrange CreateContinuousAggregateOperation op = new() { MaterializedViewName = "hourly", @@ -68,7 +68,7 @@ public void CreateContinuousAggregate_MalformedAggregateString_IsSkipped() // Act string result = Generate(op); - // Assert — only the well-formed entry is emitted. + // Assert Assert.Contains("EAggregateFunction.Avg, \"temp\")", result); Assert.DoesNotContain("malformed", result); } @@ -80,7 +80,7 @@ public void CreateContinuousAggregate_MalformedAggregateString_IsSkipped() [Fact] public void CreateContinuousAggregate_TimeBucketGroupBy_OnlyEmittedWhenDisabled() { - // Arrange — default true must NOT be emitted. + // Arrange CreateContinuousAggregateOperation enabled = new() { MaterializedViewName = "hourly", @@ -94,7 +94,7 @@ public void CreateContinuousAggregate_TimeBucketGroupBy_OnlyEmittedWhenDisabled( // Assert Assert.DoesNotContain("timeBucketGroupBy:", enabledResult); - // Arrange — non-default false must be emitted as false. + // Arrange CreateContinuousAggregateOperation disabled = new() { MaterializedViewName = "hourly", @@ -237,5 +237,55 @@ public void AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault() } #endregion + + #region CreateContinuousAggregate_NullAggregateFunctions_Omits_AggregateFunctionsArg + + [Fact] + public void CreateContinuousAggregate_NullAggregateFunctions_Omits_AggregateFunctionsArg() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "null_agg_ca", + ParentName = "src_null_agg", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "ts", + AggregateFunctions = null!, + GroupByColumns = [] + }; + + // Act + string result = Generate(operation); + + // Assert + Assert.DoesNotContain("aggregateFunctions", result); + } + + #endregion + + #region CreateContinuousAggregate_NullGroupByColumns_Omits_GroupByColumnsArg + + [Fact] + public void CreateContinuousAggregate_NullGroupByColumns_Omits_GroupByColumnsArg() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "null_gbc_ca", + ParentName = "src_null_gbc", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "ts", + AggregateFunctions = [], + GroupByColumns = null! + }; + + // Act + string result = Generate(operation); + + // Assert + Assert.DoesNotContain("groupByColumns", result); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs index 1170230..d5143e8 100644 --- a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs @@ -1,4 +1,5 @@ #pragma warning disable EF1001 +using System.Reflection; using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; @@ -8,9 +9,11 @@ using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Design.Internal; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Internal; using Microsoft.Extensions.DependencyInjection; +using Moq; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; @@ -69,6 +72,30 @@ public void Select_Returns_CSharpModelGenerator_When_TimescaleCSharpModelGenerat #endregion + #region Select_TimescaleSelector_Falls_Back_To_CSharpModelGenerator_When_Timescale_Not_In_Services + + [Fact] + public void Select_TimescaleSelector_Falls_Back_To_CSharpModelGenerator_When_Timescale_Not_In_Services() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + IServiceProvider sp = services.BuildServiceProvider(); + + IEnumerable generators = sp.GetServices(); + + TimescaleModelCodeGeneratorSelector selector = new(generators); + + // Act + IModelCodeGenerator result = selector.Select(DefaultOptions()); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(CSharpModelGenerator), result.GetType()); + } + + #endregion + // ── GenerateModel tests ──────────────────────────────────────────────── #region GenerateModel_UseDataAnnotations_False_Does_Not_Inject_Timescale_Usings @@ -129,7 +156,6 @@ public void GenerateModel_UseDataAnnotations_True_Injects_Hypertable_Namespace() ScaffoldedModel result = generator.GenerateModel(model, options); - // The entity file should contain the Hypertable namespace using bool found = result.AdditionalFiles.Any(f => f.Code.Contains("using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable;")); Assert.True(found); @@ -137,7 +163,6 @@ public void GenerateModel_UseDataAnnotations_True_Injects_Hypertable_Namespace() #endregion - // ── DA mode: suppress hypertable fluent API in context file ─────────────── #region GenerateModel_UseDataAnnotations_True_DoesNotEmitHypertableFluentApiCalls @@ -173,7 +198,6 @@ public void GenerateModel_UseDataAnnotations_True_DoesNotEmitHypertableFluentApi #endregion - // ── Property-level namespace injection for CA entities ──────────────────── #region GenerateModel_UseDataAnnotations_True_Injects_Aggregate_Namespaces @@ -229,5 +253,393 @@ public void GenerateModel_UseDataAnnotations_True_Injects_Aggregate_Namespaces() } #endregion + + + #region Select_Returns_NonCSharpModelGenerator_Unchanged + + private const string TestLanguage = "TestLang"; + + private class TestLangModelGenerator : IModelCodeGenerator + { + public string Language => TestLanguage; + + public ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) => + throw new NotImplementedException(); + } + + [Fact] + public void Select_Returns_NonCSharpModelGenerator_Unchanged() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + services.AddSingleton(); + IServiceProvider sp = services.BuildServiceProvider(); + IModelCodeGeneratorSelector selector = sp.GetRequiredService(); + ModelCodeGenerationOptions options = new() { Language = TestLanguage }; + + // Act + IModelCodeGenerator generator = selector.Select(options); + + // Assert + Assert.IsType(generator); + } + + #endregion + + + #region GenerateModel_NonTimescaleAnnotationGenerator_Does_Not_Throw + + private class NonTsAnnotationSimpleEntity { public DateTime Ts { get; set; } } + + private class NonTsAnnotationContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("non_ts_annotation_gen"); }); + } + + [Fact] + public void GenerateModel_NonTimescaleAnnotationGenerator_Does_Not_Throw() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + IServiceProvider sp = services.BuildServiceProvider(); + + Mock mockAnnotationGen = new(); + mockAnnotationGen + .Setup(g => g.GenerateDataAnnotationAttributes(It.IsAny(), It.IsAny>())) + .Returns([]); + mockAnnotationGen + .Setup(g => g.GenerateDataAnnotationAttributes(It.IsAny(), It.IsAny>())) + .Returns([]); + + ModelCodeGeneratorDependencies deps = sp.GetRequiredService(); + IOperationReporter reporter = sp.GetRequiredService(); + + TimescaleCSharpModelGenerator generator = new(deps, reporter, sp, mockAnnotationGen.Object); + + using NonTsAnnotationContext context = new(); + IModel model = context.GetService().Model; + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: false); + + // Act + ScaffoldedModel result = generator.GenerateModel(model, options); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.ContextFile); + } + + #endregion + + + #region GenerateModel_Does_Not_Include_Design_Namespace_In_Output + + private class NoDesignNsEntity { public DateTime Ts { get; set; } } + + private class NoDesignNsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("no_design_ns_entity"); }); + } + + [Fact] + public void GenerateModel_Does_Not_Include_Design_Namespace_In_Output() + { + // Arrange + using NoDesignNsContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + IModelCodeGenerator generator = selector.Select(options); + + // Act + ScaffoldedModel result = generator.GenerateModel(model, options); + + // Assert + const string designNamespacePrefix = "using CmdScale.EntityFrameworkCore.TimescaleDB.Design"; + Assert.DoesNotContain(designNamespacePrefix, result.ContextFile.Code); + Assert.All(result.AdditionalFiles, file => + Assert.DoesNotContain(designNamespacePrefix, file.Code)); + } + + #endregion + + + #region GenerateModel_DoesNotDuplicate_ContextUsings + + private class NoDuplicateUsingsEntity { public DateTime Ts { get; set; } } + + private class NoDuplicateUsingsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("no_dup_usings_entity"); }); + } + + [Fact] + public void GenerateModel_DoesNotDuplicate_ContextUsings() + { + // Arrange + using NoDuplicateUsingsContext context = new(); + IModel model = context.GetService().Model; + IModelCodeGeneratorSelector selector = CreateSelector(); + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + IModelCodeGenerator generator = selector.Select(options); + + // Act + ScaffoldedModel result = generator.GenerateModel(model, options); + + // Assert + string contextCode = result.ContextFile.Code; + string newLine = contextCode.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + IEnumerable usingLines = contextCode.Split(newLine) + .Where(line => line.StartsWith("using ", StringComparison.Ordinal) && line.EndsWith(';')); + IEnumerable> duplicateGroups = usingLines + .GroupBy(line => line) + .Where(g => g.Count() > 1); + Assert.Empty(duplicateGroups); + } + + #endregion + + + #region AddArgumentNamespace_NonEnum_NonType_Argument_Does_Not_Add_Namespace + + [Fact] + public void AddArgumentNamespace_NonEnum_NonType_Argument_Does_Not_Add_Namespace() + { + // Arrange + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddArgumentNamespace", BindingFlags.NonPublic | BindingFlags.Static)!; + + List namespaces = ["System"]; + string argument = "plain_string_source"; + + // Act + method.Invoke(null, [namespaces, argument]); + + // Assert + Assert.Equal(["System"], namespaces); + } + + #endregion + + + #region AddNamespace_Does_Not_Duplicate_Namespace_When_Already_Present + + [Fact] + public void AddNamespace_Does_Not_Duplicate_Namespace_When_Already_Present() + { + // Arrange + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddNamespace", BindingFlags.NonPublic | BindingFlags.Static)!; + + string ns = typeof(EAggregateFunction).Namespace!; + List namespaces = [ns]; // pre-populated + + // Act + method.Invoke(null, [namespaces, typeof(EAggregateFunction)]); + + // Assert + Assert.Single(namespaces); + Assert.Equal(ns, namespaces[0]); + } + + #endregion + + + #region AddArgumentNamespace_Type_Argument_Adds_Namespace + + [Fact] + public void AddArgumentNamespace_Type_Argument_Adds_Namespace() + { + // Arrange + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddArgumentNamespace", BindingFlags.NonPublic | BindingFlags.Static)!; + + List namespaces = []; + Type typeArgument = typeof(EAggregateFunction); + + // Act + method.Invoke(null, [namespaces, typeArgument]); + + // Assert + Assert.Contains(typeArgument.Namespace!, namespaces); + } + + #endregion + + + #region RemoveDesignUsings_Should_Remove_Design_Using_When_Code_Has_LF_Only_Line_Endings + + [Fact] + public void RemoveDesignUsings_Should_Remove_Design_Using_When_Code_Has_LF_Only_Line_Endings() + { + // Arrange + string code = + "using CmdScale.EntityFrameworkCore.TimescaleDB.Design;\n" + + "using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators;\n" + + "using System;\n" + + "\n" + + "namespace MyApp { }"; + + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("RemoveDesignUsings", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + string result = (string)method.Invoke(null, [code])!; + + // Assert + Assert.DoesNotContain("using CmdScale.EntityFrameworkCore.TimescaleDB.Design;", result); + Assert.DoesNotContain("using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators;", result); + Assert.Contains("using System;", result); + Assert.Contains("namespace MyApp { }", result); + } + + #endregion + + + #region AddMissingUsings_Should_Not_Duplicate_Usings_When_Already_Present + + [Fact] + public void AddMissingUsings_Should_Not_Duplicate_Usings_When_Already_Present() + { + // Arrange + string ns = typeof(TimescaleDbContextOptionsBuilderExtensions).Namespace!; + string code = + $"using {ns};\r\n" + + "using System;\r\n" + + "\r\n" + + "namespace MyApp { }"; + + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddMissingUsings", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + string result = (string)method.Invoke(null, [code, new List { ns }])!; + + // Assert + Assert.Equal(code, result); + } + + #endregion + + + #region AddMissingUsings_Should_Prepend_Usings_When_No_Using_Block_Exists + + [Fact] + public void AddMissingUsings_Should_Prepend_Usings_When_No_Using_Block_Exists() + { + // Arrange + string ns = typeof(TimescaleDbContextOptionsBuilderExtensions).Namespace!; + string code = + "namespace MyApp\n" + + "{\n" + + " class Foo { }\n" + + "}"; + + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddMissingUsings", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + string result = (string)method.Invoke(null, [code, new List { ns }])!; + + // Assert + Assert.Contains($"using {ns};", result); + int usingIndex = result.IndexOf($"using {ns};", StringComparison.Ordinal); + int namespaceIndex = result.IndexOf("namespace MyApp", StringComparison.Ordinal); + Assert.True(usingIndex < namespaceIndex, "using directive must appear before namespace declaration"); + } + + #endregion + + + #region AddMissingUsings_Should_Insert_Usings_Into_LF_Only_Code + + [Fact] + public void AddMissingUsings_Should_Insert_Usings_Into_LF_Only_Code() + { + // Arrange + string ns = typeof(TimescaleDbContextOptionsBuilderExtensions).Namespace!; + string code = + "using System;\n" + + "\n" + + "namespace MyApp { }"; + + MethodInfo method = typeof(TimescaleCSharpModelGenerator) + .GetMethod("AddMissingUsings", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + string result = (string)method.Invoke(null, [code, new List { ns }])!; + + // Assert + Assert.Contains($"using {ns};", result); + Assert.Contains("using System;", result); + Assert.DoesNotContain("\r\n", result); + } + + #endregion + + + #region GenerateModel_NonTimescaleAnnotationGenerator_Returns_Valid_Context_File + + private class NonTsAnnotationFallbackEntity { public DateTime Ts { get; set; } } + + private class NonTsAnnotationFallbackContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("non_ts_fallback_entity"); }); + } + + [Fact] + public void GenerateModel_NonTimescaleAnnotationGenerator_Returns_Valid_Context_File() + { + // Arrange + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + IServiceProvider sp = services.BuildServiceProvider(); + + Mock mockAnnotationGen = new(); + mockAnnotationGen + .Setup(g => g.GenerateDataAnnotationAttributes(It.IsAny(), It.IsAny>())) + .Returns([]); + mockAnnotationGen + .Setup(g => g.GenerateDataAnnotationAttributes(It.IsAny(), It.IsAny>())) + .Returns([]); + + ModelCodeGeneratorDependencies deps = sp.GetRequiredService(); + IOperationReporter reporter = sp.GetRequiredService(); + TimescaleCSharpModelGenerator generator = new(deps, reporter, sp, mockAnnotationGen.Object); + + using NonTsAnnotationFallbackContext context = new(); + IModel model = context.GetService().Model; + ModelCodeGenerationOptions options = DefaultOptions(useDataAnnotations: true); + + // Act + ScaffoldedModel result = generator.GenerateModel(model, options); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.ContextFile); + Assert.NotEmpty(result.ContextFile.Code); + } + + #endregion } #pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleDbAnnotationCodeGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleDbAnnotationCodeGeneratorTests.cs new file mode 100644 index 0000000..46e099f --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleDbAnnotationCodeGeneratorTests.cs @@ -0,0 +1,1696 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +/// +/// Tests for the guard conditions, attribute caching, and source-argument resolution of +/// . Feature-specific rendering is tested in the +/// renderer test classes. +/// +public class TimescaleDbAnnotationCodeGeneratorTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static TimescaleDbAnnotationCodeGenerator CreateGenerator(bool scaffoldMode = false) + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + generator.ScaffoldMode = scaffoldMode; + return generator; + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + + #region GenerateFluentApiCalls_Entity_ScaffoldMode_False_Returns_Base_Calls + + private class ScaffoldFalseEntity1 { public int Id { get; set; } } + + private class ScaffoldFalseContext1 : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => { e.HasKey(x => x.Id); e.ToTable("scaffold_false_1"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Entity_ScaffoldMode_False_Returns_Base_Calls() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: false); + using ScaffoldFalseContext1 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "scaffold_false_1"), + (ContinuousAggregateAnnotations.ParentName, "some_parent"), + (ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, t.time) AS bucket FROM some_parent t GROUP BY 1")); + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => + { + for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) + if (c.Method == "IsContinuousAggregate") return true; + return false; + }); + } + + #endregion + + + #region GenerateDataAnnotationAttributes_Entity_ScaffoldMode_False_Returns_Base_Attributes + + private class ScaffoldFalseEntity2 { public int Id { get; set; } } + + private class ScaffoldFalseContext2 : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => { e.HasKey(x => x.Id); e.ToTable("scaffold_false_2"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Entity_ScaffoldMode_False_Returns_Base_Attributes() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: false); + using ScaffoldFalseContext2 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "scaffold_false_2"), + (ContinuousAggregateAnnotations.ParentName, "some_parent"), + (ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, t.time) AS bucket FROM some_parent t GROUP BY 1")); + + // Act + IReadOnlyList result = generator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + } + + #endregion + + + #region GenerateDataAnnotationAttributes_Property_ScaffoldMode_False_Returns_Base_Attributes + + private class PropScaffoldFalseSource { public DateTime Time { get; set; } } + private class PropScaffoldFalseCa { public double AvgValue { get; set; } } + + private class PropScaffoldFalseContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("prop_sf_source"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("prop_sf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "prop_sf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "prop_sf_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "AvgValue:Avg:Time" }); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_Property_ScaffoldMode_False_Returns_Base_Attributes() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: false); + using PropScaffoldFalseContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(PropScaffoldFalseCa.AvgValue))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_NonEntityDeclaringType_Returns_Empty + + private class ComplexOwnerForGen { public int Id { get; set; } public NestedAddress Address { get; set; } = null!; } + private class NestedAddress { public string City { get; set; } = ""; } + + private class ComplexTypeGenContext : DbContext + { + public DbSet Owners => 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(e => + { + e.HasKey(x => x.Id); + e.ToTable("complex_owner_gen"); + e.ComplexProperty(x => x.Address); + }); + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_NonEntityDeclaringType_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using ComplexTypeGenContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty cityProperty = entityType.GetComplexProperties().Single().ComplexType.GetProperties() + .Single(p => p.Name == nameof(NestedAddress.City)); + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(cityProperty, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(GroupByColumnAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_EntityWithout_MaterializedViewName_Returns_Empty + + private class NoMvnEntity { public int Id { get; set; } public double Amount { get; set; } } + + private class NoMvnContext : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => { e.HasKey(x => x.Id); e.ToTable("no_mvn_gen"); }); + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_EntityWithout_MaterializedViewName_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using NoMvnContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(NoMvnEntity.Amount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(GroupByColumnAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_AggregateEntry_With_Too_Few_Parts_Returns_Empty + + private class TooFewPartsSource { public DateTime Time { get; set; } } + private class TooFewPartsCa { public double AvgValue { get; set; } } + + private class TooFewPartsContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("too_few_parts_source"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("too_few_parts_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "too_few_parts_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "too_few_parts_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "AvgValue:Avg" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_AggregateEntry_With_Too_Few_Parts_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using TooFewPartsContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(TooFewPartsCa.AvgValue))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + } + + #endregion + + + #region GenerateDataAnnotationAttributes_Entity_Cache_Hit_Returns_Same_Result + + private class CacheHitEntitySource { public DateTime Time { get; set; } public double Value { get; set; } } + private class CacheHitCaEntity { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class CacheHitEntityContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("cache_hit_source"); + e.Property(x => x.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("cache_hit_ca"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_Entity_Cache_Hit_Returns_Same_Result() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using CacheHitEntityContext context = new(); + IEntityType entityType = GetEntityType(context); + + Dictionary annotations1 = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "cache_hit_ca"), + (ContinuousAggregateAnnotations.ParentName, "cache_hit_source"), + (ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, s.time) AS bucket, avg(s.value) AS avg_value FROM cache_hit_source s GROUP BY 1")); + + // Act + IReadOnlyList first = generator.GenerateDataAnnotationAttributes(entityType, annotations1); + + Dictionary annotations2 = Annotations(); + IReadOnlyList second = generator.GenerateDataAnnotationAttributes(entityType, annotations2); + + // Assert + IEnumerable firstTypes = first.Select(a => a.Type).OrderBy(t => t.Name); + IEnumerable secondTypes = second.Select(a => a.Type).OrderBy(t => t.Name); + Assert.Equal(firstTypes, secondTypes); + } + + #endregion + + + #region GenerateDataAnnotationAttributes_Property_Cache_Hit_Returns_Same_Result + + private class PropCacheHitSource { public DateTime Time { get; set; } public double Price { get; set; } } + private class PropCacheHitCa { public double MaxPrice { get; set; } } + + private class PropCacheHitContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("prop_cache_hit_src"); + e.Property(x => x.Price).HasColumnName("price"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("prop_cache_hit_ca", "public"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "prop_cache_hit_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "prop_cache_hit_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, s.time) AS bucket, max(s.price) AS max_price FROM prop_cache_hit_src s GROUP BY 1"); + e.Property(x => x.MaxPrice).HasColumnName("max_price"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_Property_Cache_Hit_Returns_Same_Result() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using PropCacheHitContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(PropCacheHitCa.MaxPrice))!; + + // Act + IReadOnlyList first = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + IReadOnlyList second = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.Equal( + first.Select(a => a.Type).OrderBy(t => t.Name), + second.Select(a => a.Type).OrderBy(t => t.Name)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_CountStar_Returns_Wildcard_Source + + private class CountStarSource { public DateTime Time { get; set; } public double Value { get; set; } } + private class CountStarCa { public long TradeCount { get; set; } } + + private class CountStarContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("count_star_src"); + e.Property(x => x.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("count_star_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "count_star_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "count_star_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "TradeCount:Count:*" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_CountStar_Returns_Wildcard_Source() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using CountStarContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CountStarCa.TradeCount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Count, attr.Arguments[0]); + Assert.Equal("*", attr.Arguments[1]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_NoParentEntityType_Returns_RawClrName + + private class NoParentCa { public double AvgValue { get; set; } } + + private class NoParentContext : DbContext + { + public DbSet CaViews => 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(e => + { + e.HasNoKey(); + e.ToView("no_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "no_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "AvgValue:Avg:SomeSourceColumn" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_NoParentEntityType_Returns_RawClrName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + using NoParentContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(NoParentCa.AvgValue))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Avg, attr.Arguments[0]); + string? sourceArg = attr.Arguments[1] as string; + Assert.NotNull(sourceArg); + Assert.Equal("SomeSourceColumn", sourceArg); + } + + #endregion + + + #region GenerateFluentApiCalls_Entity_ConsumeAnnotations_When_DataAnnotationsMode_True + + private class DaModeSuppressSource { public DateTime Time { get; set; } public double Revenue { get; set; } } + private class DaModeSuppressCa { public double SumRevenue { get; set; } } + + private class DaModeSuppressContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("da_mode_suppress_src"); + e.Property(x => x.Revenue).HasColumnName("revenue"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("da_mode_suppress_ca"); + e.Property(x => x.SumRevenue).HasColumnName("sum_revenue"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_Entity_ConsumeAnnotations_When_DataAnnotationsMode_True() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DaModeSuppressContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "da_mode_suppress_ca"), + (ContinuousAggregateAnnotations.ParentName, "da_mode_suppress_src"), + (ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, sum(s.revenue) AS sum_revenue FROM da_mode_suppress_src s GROUP BY 1")); + + // Act + IReadOnlyList result = generator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => + { + for (MethodCallCodeFragment? c = f; c != null; c = c.ChainedCall) + if (c.Method == "IsContinuousAggregate") return true; + return false; + }); + Assert.DoesNotContain(annotations.Keys, k => k.StartsWith("TimescaleDB:", StringComparison.Ordinal)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_Aggregate_Match_Returns_AggregateAttribute + + private class DbFirstAggSource { public DateTime Time { get; set; } public double Val { get; set; } } + private class DbFirstAggCa { public double AvgVal { get; set; } } + + private class DbFirstAggContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_agg_source"); + e.Property(x => x.Val).HasColumnName("val"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_agg_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_agg_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_agg_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.val) AS avg_val FROM dbf_agg_source s GROUP BY 1"); + e.Property(x => x.AvgVal).HasColumnName("avg_val"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_Aggregate_Match_Returns_AggregateAttribute() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstAggContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstAggCa.AvgVal))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Avg, attr.Arguments[0]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_Match_Returns_GroupByAttribute + + private class DbFirstGbSource { public DateTime Time { get; set; } public string Region { get; set; } = ""; } + private class DbFirstGbCa { public string Region { get; set; } = ""; } + + private class DbFirstGbContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_gb_source"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Region).HasColumnName("region"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_gb_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_gb_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_gb_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, s.region AS region" + + " FROM dbf_gb_source s GROUP BY time_bucket('1 hour'::interval, s.time), s.region"); + e.Property(x => x.Region).HasColumnName("region"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_Match_Returns_GroupByAttribute() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstGbContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstGbCa.Region))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.Contains(result, a => a.Type == typeof(GroupByColumnAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NoMatch_Returns_Empty + + private class DbFirstNoMatchSource { public DateTime Time { get; set; } public double Score { get; set; } } + private class DbFirstNoMatchCa { public string ExtraCol { get; set; } = ""; } + + private class DbFirstNoMatchContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_nomatch_source"); + e.Property(x => x.Score).HasColumnName("score"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_nomatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_nomatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_nomatch_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.score) AS avg_score FROM dbf_nomatch_source s GROUP BY 1"); + e.Property(x => x.ExtraCol).HasColumnName("extra_col"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NoMatch_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstNoMatchContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNoMatchCa.ExtraCol))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(GroupByColumnAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_CountStar_Aggregate_Returns_Wildcard + + private class DbFirstCsSource { public DateTime Time { get; set; } public double Amount { get; set; } } + private class DbFirstCsCa { public long EventCount { get; set; } } + + private class DbFirstCsContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_cs_source"); + e.Property(x => x.Amount).HasColumnName("amount"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_cs_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_cs_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_cs_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, count(*) AS event_count FROM dbf_cs_source s GROUP BY 1"); + e.Property(x => x.EventCount).HasColumnName("event_count"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_CountStar_Aggregate_Returns_Wildcard() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstCsContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstCsCa.EventCount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Count, attr.Arguments[0]); + Assert.Equal("*", attr.Arguments[1]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_Default_NoArg_When_NameMatches + + private class DbFirstGbMatchSource { public DateTime Time { get; set; } public string Region { get; set; } = ""; } + private class DbFirstGbMatchCa { public string Region { get; set; } = ""; } + + private class DbFirstGbMatchContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_gbmatch_src"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Region).HasColumnName("region"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_gbmatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_gbmatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_gbmatch_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, s.region AS region" + + " FROM dbf_gbmatch_src s GROUP BY time_bucket('1 hour'::interval, s.time), s.region"); + e.Property(x => x.Region).HasColumnName("region"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_Default_NoArg_When_NameMatches() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstGbMatchContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstGbMatchCa.Region))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(GroupByColumnAttribute)); + Assert.NotNull(attr); + Assert.Empty(attr.Arguments); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewDefinition_Returns_Empty + + private class DbFirstNvdSource { public DateTime Time { get; set; } public double Amount { get; set; } } + private class DbFirstNvdCa { public double TotalAmount { get; set; } } + + private class DbFirstNvdContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_nvd_source"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_nvd_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_nvd_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_nvd_source"); + e.Property(x => x.TotalAmount).HasColumnName("total_amount"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewDefinition_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstNvdContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNvdCa.TotalAmount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(GroupByColumnAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NullParent_Returns_RawColumnName + + private class DbFirstNullParentCa { public double AvgScore { get; set; } } + + private class DbFirstNullParentContext : DbContext + { + public DbSet CaViews => 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(e => + { + e.HasNoKey(); + e.ToView("dbf_null_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_null_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.score) AS avg_score FROM some_source s GROUP BY 1"); + e.Property(x => x.AvgScore).HasColumnName("avg_score"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NullParent_Returns_RawColumnName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstNullParentContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNullParentCa.AvgScore))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Avg, attr.Arguments[0]); + string? sourceArg = attr.Arguments[1] as string; + Assert.NotNull(sourceArg); + Assert.Equal("score", sourceArg); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_ParentPropNotFound_Returns_RawColumnName + + private class DbFirstPpnfSource { public DateTime Time { get; set; } public double Revenue { get; set; } } + private class DbFirstPpnfCa { public double AvgObscureColumn { get; set; } } + + private class DbFirstPpnfContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_ppnf_source"); + e.Property(x => x.Revenue).HasColumnName("revenue"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_ppnf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_ppnf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_ppnf_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.obscure_column) AS avg_obscure_column FROM dbf_ppnf_source s GROUP BY 1"); + e.Property(x => x.AvgObscureColumn).HasColumnName("avg_obscure_column"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_ParentPropNotFound_Returns_RawColumnName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstPpnfContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstPpnfCa.AvgObscureColumn))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Avg, attr.Arguments[0]); + string? sourceArg = attr.Arguments[1] as string; + Assert.NotNull(sourceArg); + Assert.Equal("obscure_column", sourceArg); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_CountStar_Wildcard_Via_ColumnName_Resolver + + private class DbFirstCsWcSource { public DateTime Time { get; set; } } + private class DbFirstCsWcCa { public long TotalCount { get; set; } } + + private class DbFirstCsWcContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_cs_wc_source"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_cs_wc_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_cs_wc_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_cs_wc_source"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, count(*) AS total_count FROM dbf_cs_wc_source s GROUP BY 1"); + e.Property(x => x.TotalCount).HasColumnName("total_count"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_CountStar_Wildcard_Via_ColumnName_Resolver() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstCsWcContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstCsWcCa.TotalCount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Count, attr.Arguments[0]); + Assert.Equal("*", attr.Arguments[1]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_WithArg_When_NameMismatch + + private class DbFirstGbMismatchSource { public DateTime Time { get; set; } public string ServiceName { get; set; } = ""; } + private class DbFirstGbMismatchCa { public string Svc { get; set; } = ""; } + + private class DbFirstGbMismatchContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_gbmismatch_src"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.ServiceName).HasColumnName("service_name"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_gbmismatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_gbmismatch_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_gbmismatch_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, s.service_name AS service_name" + + " FROM dbf_gbmismatch_src s GROUP BY time_bucket('1 hour'::interval, s.time), s.service_name"); + e.Property(x => x.Svc).HasColumnName("service_name"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_WithArg_When_NameMismatch() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstGbMismatchContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstGbMismatchCa.Svc))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(GroupByColumnAttribute)); + Assert.NotNull(attr); + Assert.NotEmpty(attr.Arguments); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_CodeFirst_Entry_Null_Returns_Empty + + private class CfEntryNullSource { public DateTime Time { get; set; } public double Val { get; set; } } + private class CfEntryNullCa { public double AvgVal { get; set; } public string? UnlistedProp { get; set; } } + + private class CfEntryNullContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("cf_entry_null_src"); + e.Property(x => x.Val).HasColumnName("val"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("cf_entry_null_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "cf_entry_null_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "cf_entry_null_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "AvgVal:Avg:val" }); + e.Property(x => x.UnlistedProp).HasColumnName("unlisted_prop"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_CodeFirst_Entry_Null_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using CfEntryNullContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CfEntryNullCa.UnlistedProp))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(GroupByColumnAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewName_UsesTableName + + private class DbFirstNoViewNameSource { public DateTime Time { get; set; } public double Amount { get; set; } } + private class DbFirstNoViewNameCa { public double SumAmount { get; set; } } + + private class DbFirstNoViewNameContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_no_view_src"); + e.Property(x => x.Amount).HasColumnName("amount"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToTable("dbf_no_view_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_no_view_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_no_view_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, sum(s.amount) AS sum_amount FROM dbf_no_view_src s GROUP BY 1"); + e.Property(x => x.SumAmount).HasColumnName("sum_amount"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewName_UsesTableName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstNoViewNameContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNoViewNameCa.SumAmount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.NotNull(result); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_CodeFirst_WithParent_Returns_NameOf + + private class CfWithParentSource { public DateTime Time { get; set; } public double Revenue { get; set; } } + private class CfWithParentCa { public double TotalRevenue { get; set; } } + + private class CfWithParentContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("cf_with_parent_src"); + e.Property(x => x.Revenue).HasColumnName("revenue"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("cf_with_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "cf_with_parent_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "cf_with_parent_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "TotalRevenue:Sum:Revenue" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_CodeFirst_WithParent_Returns_NameOf() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using CfWithParentContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CfWithParentCa.TotalRevenue))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Sum, attr.Arguments[0]); + Assert.IsNotType(attr.Arguments[1]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_CodeFirst_WithParent_PropNotFound_Returns_RawString + + private class CfPropNotFoundSource { public DateTime Time { get; set; } public double Amount { get; set; } } + private class CfPropNotFoundCa { public double TotalAmount { get; set; } } + + private class CfPropNotFoundContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("cf_prop_nf_src"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("cf_prop_nf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "cf_prop_nf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "cf_prop_nf_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "TotalAmount:Sum:NonExistentProp" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_CodeFirst_WithParent_PropNotFound_Returns_RawString() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using CfPropNotFoundContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CfPropNotFoundCa.TotalAmount))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(AggregateAttribute)); + Assert.NotNull(attr); + Assert.Equal(EAggregateFunction.Sum, attr.Arguments[0]); + string? sourceArg = attr.Arguments[1] as string; + Assert.NotNull(sourceArg); + Assert.Equal("NonExistentProp", sourceArg); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_CodeFirst_InvalidFunctionName_Returns_Empty + + private class CfInvalidFuncSource { public DateTime Time { get; set; } public double Val { get; set; } } + private class CfInvalidFuncCa { public double BadAgg { get; set; } } + + private class CfInvalidFuncContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("cf_invalid_func_src"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("cf_invalid_func_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "cf_invalid_func_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "cf_invalid_func_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, + new List { "BadAgg:INVALID_FUNCTION:val" }); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_CodeFirst_InvalidFunctionName_Returns_Empty() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using CfInvalidFuncContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CfInvalidFuncCa.BadAgg))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(AggregateAttribute)); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NullColumnName_FallsBack_To_PropertyName + + private class DbFirstNullColNameSource { public DateTime Time { get; set; } public double Price { get; set; } } + + private class DbFirstNullColNameCa { public double Price { get; set; } } + + private class DbFirstNullColNameContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_null_col_src"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_null_col_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_null_col_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_null_col_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.price) AS price FROM dbf_null_col_src s GROUP BY 1"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NullColumnName_FallsBack_To_PropertyName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstNullColNameContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNullColNameCa.Price))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.NotNull(result); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_ParentColumnNotFound_EmitsWithArg + + private class DbFirstGbPcnfSource { public DateTime Time { get; set; } } + private class DbFirstGbPcnfCa { public string ServiceRegion { get; set; } = ""; } + + private class DbFirstGbPcnfContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasKey(x => x.Time); + e.ToTable("dbf_gbpcnf_src"); + e.Property(x => x.Time).HasColumnName("time"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_gbpcnf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_gbpcnf_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_gbpcnf_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, s.service_region AS service_region" + + " FROM dbf_gbpcnf_src s GROUP BY time_bucket('1 hour'::interval, s.time), s.service_region"); + e.Property(x => x.ServiceRegion).HasColumnName("service_region"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_GroupByColumn_ParentColumnNotFound_EmitsWithArg() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + // Arrange + using DbFirstGbPcnfContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstGbPcnfCa.ServiceRegion))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(GroupByColumnAttribute)); + Assert.NotNull(attr); + Assert.NotEmpty(attr.Arguments); + Assert.IsType(attr.Arguments[0]); + } + + #endregion + + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_ParentViewMapped_GetTableName_Null + + private class DbFirstPvmSource { public DateTime Time { get; set; } public double Temperature { get; set; } } + private class DbFirstPvmCa { public double AvgTemp { get; set; } } + + private class DbFirstPvmContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasNoKey(); + e.ToView("dbf_pvm_src_view"); + e.Property(x => x.Temperature).HasColumnName("temperature"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("dbf_pvm_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_pvm_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_pvm_src_view"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.temperature) AS avg_temp FROM dbf_pvm_src_view s GROUP BY 1"); + e.Property(x => x.AvgTemp).HasColumnName("avg_temp"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_ParentViewMapped_GetTableName_Null() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + using DbFirstPvmContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstPvmCa.AvgTemp))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewNoTable_FallsBackToEntityName + + private class DbFirstNvntSource { public DateTime Time { get; set; } public double Value { get; set; } } + private class DbFirstNvntCa { public double AvgValue { get; set; } } + + private class DbFirstNvntContext : DbContext + { + public DbSet Sources => Set(); + public DbSet CaViews => 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(e => + { + e.HasNoKey(); + e.ToTable("dbf_nvnt_src"); + e.Property(x => x.Value).HasColumnName("value"); + }); + + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToSqlQuery("SELECT bucket, avg_value FROM dbf_nvnt_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "dbf_nvnt_ca"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "dbf_nvnt_src"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour'::interval, s.time) AS bucket, avg(s.value) AS avg_value FROM dbf_nvnt_src s GROUP BY 1"); + e.Property(x => x.AvgValue).HasColumnName("avg_value"); + }); + } + } + + [Fact] + public void GenerateContinuousAggregatePropertyAttributes_DbFirst_NoViewNoTable_FallsBackToEntityName() + { + // Arrange + TimescaleDbAnnotationCodeGenerator generator = CreateGenerator(scaffoldMode: true); + generator.ScaffoldDataAnnotationsMode = true; + + // Arrange + using DbFirstNvntContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(DbFirstNvntCa.AvgValue))!; + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.NotNull(result); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs b/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs index f6d2aa7..97ea2e3 100644 --- a/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ViewDefinitionParserTests.cs @@ -5,7 +5,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; public class ViewDefinitionParserTests { - // A representative TimescaleDB CA view definition with several aggregate functions. private const string FullViewDef = "SELECT time_bucket('01:00:00'::interval, api_log.\"time\") AS bucket," + " api_log.service_name AS service_name," + @@ -70,7 +69,6 @@ public void ParseTimeBucketSourceColumn_Extracts_Column() { string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(FullViewDef); - // Should strip table qualifier (api_log.) and quotes from "time" Assert.Equal("time", result); } @@ -126,7 +124,6 @@ public void ParseAggregates_StripsTableQualifier() { IReadOnlyList result = ViewDefinitionParser.ParseAggregates(FullViewDef); - // api_log.duration_ms → duration_ms Assert.All(result.Where(a => a.SourceColumn != "*"), a => Assert.DoesNotContain(".", a.SourceColumn)); } @@ -383,7 +380,6 @@ public void ParseTimeBucketSourceColumn_SchemaQualified() [Fact] public void ParseTimeBucketSourceColumn_ThreeArg_Timezone() { - // TimescaleDB >= 2.8 supports time_bucket(width, ts, timezone). const string viewDef = "SELECT time_bucket('01:00:00'::interval, t.\"time\", 'Europe/Berlin'::text) AS bucket" + " FROM t GROUP BY 1"; @@ -454,7 +450,6 @@ public void ParseAggregates_QuotedAlias_And_QuotedColumn() [Fact] public void ParseAggregates_PlainFirst_UsesFirstArgumentAsSource() { - // Finalized continuous aggregates (TimescaleDB >= 2.7, the default) store first/last in plain form. const string viewDef = "SELECT time_bucket('1 day'::interval, t.\"time\") AS bucket," + " first(t.temperature, t.\"time\") AS first_temperature" + @@ -588,4 +583,581 @@ public void Parse_Returns_CompleteResult_And_Memoizes() } #endregion + + + #region ParseAggregates_UnknownFunction_Is_Skipped + + [Fact] + public void Should_Skip_Unknown_Aggregate_Function() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket," + + " stddev(t.val) AS stddev_val" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + Assert.DoesNotContain(result, a => a.Alias == "stddev_val"); + } + + #endregion + + #region ParseAggregates_First_Function_Parsed + + [Fact] + public void Should_Parse_First_Function_With_Two_Args() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " first(t.price, t.ts) AS first_price" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "first_price"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.First, agg.Function); + Assert.Equal("price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_Last_Function_Parsed + + [Fact] + public void Should_Parse_Last_Function_With_Two_Args() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " last(t.price, t.ts) AS last_price" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "last_price"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Last, agg.Function); + Assert.Equal("price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_First_EmptyFirstArg_IsSkipped + + [Fact] + public void Should_Skip_First_When_First_Arg_Is_Empty() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " first(, t.ts) AS first_val" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + Assert.DoesNotContain(result, a => a.Alias == "first_val"); + } + + #endregion + + #region ParseAggregates_LegacyFinalizeAgg_First_Parsed + + [Fact] + public void Should_Parse_Legacy_FinalizeAgg_First_And_Strip_Prefix() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket," + + " _timescaledb_internal.finalize_agg('first(double precision,timestamp with time zone)'::text," + + " null::text, array[]) AS first_price" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "first_price"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.First, agg.Function); + Assert.Equal("price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_LegacyFinalizeAgg_NoPrefixAlias_UsesAlias + + [Fact] + public void Should_Use_Alias_As_SourceColumn_When_Legacy_FinalizeAgg_Alias_Has_No_Prefix() + { + // Arrange + const string sql = + "SELECT _timescaledb_internal.finalize_agg('last(double precision,timestamp with time zone)'::text," + + " null::text, array[]) AS my_value" + + " FROM t"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "my_value"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Last, agg.Function); + Assert.Equal("my_value", agg.SourceColumn); + } + + #endregion + + #region ParseGroupByColumns_Parenthesised_TimeBucket_Skipped + + [Fact] + public void Should_Skip_Parenthesised_TimeBucket_In_GroupBy() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket, t.region" + + " FROM t GROUP BY (time_bucket('1 hour'::interval, t.ts)), t.region"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains("region", result); + Assert.DoesNotContain(result, c => c.StartsWith("(time_bucket", StringComparison.OrdinalIgnoreCase)); + } + + #endregion + + #region ParseGroupByColumns_Positional_References_Skipped + + [Fact] + public void Should_Return_Empty_When_All_GroupBy_Entries_Are_Positional() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket, avg(t.val) AS avg_val" + + " FROM t GROUP BY 1, 2"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region ParseGroupByColumns_NonSimple_Expression_Kept_Verbatim + + [Fact] + public void Should_Keep_NonSimple_GroupBy_Expression_Verbatim() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket" + + " FROM t GROUP BY time_bucket('1 day'::interval, t.ts), date_trunc('hour', t.ts)"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains("date_trunc('hour', t.ts)", result); + } + + #endregion + + + #region ParseAggregates_Min_Function_Parsed + + [Fact] + public void ParseAggregates_Min_Function_Parsed() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " min(t.temperature) AS min_temperature" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "min_temperature"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Min, agg.Function); + Assert.Equal("temperature", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_BareColumnName_NoTableQualifier + + [Fact] + public void ParseAggregates_BareColumnName_NoTableQualifier() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, ts) AS bucket," + + " avg(temperature) AS avg_temp" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "avg_temp"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Avg, agg.Function); + Assert.Equal("temperature", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_NoFrom_SelectClauseIsWholeString + + [Fact] + public void ParseAggregates_NoFrom_SelectClauseIsWholeString() + { + // Arrange + const string sql = "avg(t.value) AS avg_val"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "avg_val"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Avg, agg.Function); + } + + #endregion + + #region ParseGroupByColumns_BareColumnName_NoTableQualifier + + [Fact] + public void ParseGroupByColumns_BareColumnName_NoTableQualifier() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, ts) AS bucket, region" + + " FROM t GROUP BY time_bucket('1 hour'::interval, ts), region"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains("region", result); + } + + #endregion + + #region ParseTimeBucketSourceColumn_BareColumnWithoutQuotes + + [Fact] + public void ParseTimeBucketSourceColumn_BareColumnWithoutQuotes() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, readings.timestamp) AS bucket" + + " FROM readings GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(sql); + + // Assert + Assert.Equal("timestamp", result); + } + + #endregion + + #region ParseWhereClause_With_Having_Clause + + [Fact] + public void ParseWhereClause_With_Having_Clause() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " avg(t.value) AS avg_value" + + " FROM t" + + " WHERE t.active = true" + + " HAVING avg(t.value) > 0"; + + // Act + string? result = ViewDefinitionParser.ParseWhereClause(sql); + + // Assert + Assert.NotNull(result); + Assert.Contains("active", result); + Assert.DoesNotContain("HAVING", result, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region ParseGroupByColumns_QuotedBareColumn_NoTablePrefix + + [Fact] + public void ParseGroupByColumns_QuotedBareColumn_NoTablePrefix() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket, t.\"Region\" AS \"Region\"" + + " FROM t GROUP BY time_bucket('1 hour'::interval, t.ts), \"Region\""; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains("Region", result); + } + + #endregion + + + #region ParseAggregates_Sum_Function_Parsed_Standalone + + [Fact] + public void ParseAggregates_Sum_Function_Parsed_Standalone() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket," + + " sum(t.amount) AS total_amount" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "total_amount"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Sum, agg.Function); + Assert.Equal("amount", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_Max_Function_Parsed_Standalone + + [Fact] + public void ParseAggregates_Max_Function_Parsed_Standalone() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " max(t.price) AS max_price" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "max_price"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Max, agg.Function); + Assert.Equal("price", agg.SourceColumn); + } + + #endregion + + #region ParseAggregates_Count_Function_With_Column_Reference + + [Fact] + public void ParseAggregates_Count_Function_With_Column_Reference() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " count(t.event_id) AS event_count" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "event_count"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Count, agg.Function); + Assert.Equal("event_id", agg.SourceColumn); + } + + #endregion + + #region ParseGroupByColumns_EmptyToken_After_Split_Is_Skipped + + [Fact] + public void ParseGroupByColumns_EmptyToken_After_Split_Is_Skipped() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket, t.region AS region" + + " FROM t GROUP BY time_bucket('1 hour'::interval, t.ts), t.region"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains("region", result); + Assert.All(result, c => Assert.False(string.IsNullOrWhiteSpace(c))); + } + + #endregion + + #region ParseWhereClause_ReturnsNull_When_No_Where_Keyword + + [Fact] + public void ParseWhereClause_ReturnsNull_When_No_Where_Keyword() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket," + + " avg(t.value) AS avg_val" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseWhereClause(sql); + + // Assert + Assert.Null(result); + } + + #endregion + + #region ParseAggregates_QuotedAlias_Strips_Quotes + + [Fact] + public void ParseAggregates_QuotedAlias_Strips_Quotes() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " avg(t.\"TotalRevenue\") AS \"AvgRevenue\"" + + " FROM t GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "AvgRevenue"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Avg, agg.Function); + Assert.Equal("TotalRevenue", agg.SourceColumn); + } + + #endregion + + // ── StripQuotes tests ───────────────────────────────────────────────────── + + #region ParseTimeBucketSourceColumn_SingleChar_Column_Not_Stripped + + [Fact] + public void ParseTimeBucketSourceColumn_SingleChar_Column_Not_Stripped() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.x) AS bucket" + + " FROM t_single_col_src GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketSourceColumn(sql); + + // Assert + Assert.Equal("x", result); + } + + #endregion + + #region ParseAggregates_Min_Function_Parsed_Standalone + + [Fact] + public void ParseAggregates_Min_Function_Parsed_Standalone() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.ts) AS bucket," + + " min(t.temperature) AS min_temp" + + " FROM t_min_standalone GROUP BY 1"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + ViewDefinitionParser.ParsedAggregate? agg = result.FirstOrDefault(a => a.Alias == "min_temp"); + Assert.NotNull(agg); + Assert.Equal(EAggregateFunction.Min, agg.Function); + Assert.Equal("temperature", agg.SourceColumn); + } + + #endregion + + #region ParseGroupByColumns_NonSimpleExpression_Verbatim + + [Fact] + public void ParseGroupByColumns_NonSimpleExpression_Verbatim() + { + // Arrange + const string sql = + "SELECT time_bucket('1 day'::interval, t.ts) AS bucket," + + " t.status AS status" + + " FROM t_non_simple GROUP BY time_bucket('1 day'::interval, t.ts), (CASE WHEN t.active THEN 1 ELSE 0 END)"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseGroupByColumns(sql); + + // Assert + Assert.Contains(result, c => c.Contains("CASE")); + } + + #endregion + + #region ParseAggregates_First_With_WhitespaceOnlyFirstArg_IsSkipped + + [Fact] + public void ParseAggregates_First_With_WhitespaceOnlyFirstArg_IsSkipped() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, ts) AS time_bucket," + + " first( , ts) AS first_ws_arg" + + " FROM src_first_ws GROUP BY time_bucket('1 hour'::interval, ts)"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + Assert.DoesNotContain(result, a => a.Alias == "first_ws_arg"); + } + + #endregion + + #region ParseAggregates_LegacyFinalizeAgg_AlreadyParsedAlias_IsSkipped + + [Fact] + public void ParseAggregates_LegacyFinalizeAgg_AlreadyParsedAlias_IsSkipped() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, ts) AS time_bucket," + + " first(value, ts) AS dup_alias," + + " _timescaledb_internal.finalize_agg('first(double precision,bigint)'::text, null) AS dup_alias" + + " FROM src_dup_finalize GROUP BY time_bucket('1 hour'::interval, ts)"; + + // Act + IReadOnlyList result = ViewDefinitionParser.ParseAggregates(sql); + + // Assert + Assert.Single(result, a => a.Alias == "dup_alias"); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs index 805cf9e..c0bfaf8 100644 --- a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs @@ -1,6 +1,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; @@ -2167,7 +2168,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void Should_Drop_And_Recreate_When_ViewDefinition_Changes() { - // Arrange - regression for bug #5: differ now compares ViewDefinition as a structural change + // Arrange using RawDefinitionContextA24 sourceContext = new(); using RawDefinitionContextB24 targetContext = new(); @@ -2179,7 +2180,7 @@ public void Should_Drop_And_Recreate_When_ViewDefinition_Changes() // Act IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); - // Assert - drop must come before create + // Assert DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault(); CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault(); Assert.NotNull(dropOp); @@ -2273,7 +2274,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void Should_Emit_No_Operation_When_ViewDefinition_Identical() { - // Arrange - counter-test for bug #5: identical ViewDefinition must not trigger drop+recreate + // Arrange using IdenticalRawDefinitionContextA25 sourceContext = new(); using IdenticalRawDefinitionContextB25 targetContext = new(); @@ -2291,4 +2292,1056 @@ public void Should_Emit_No_Operation_When_ViewDefinition_Identical() } #endregion + + #region Should_Not_Drop_And_Recreate_When_Same_GroupByColumns_And_Same_Count + + private class MetricEntity26 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregateWithCategory26 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class WithGroupByColumnsContext26 : 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) + .AddGroupByColumn(x => x.Category); + }); + } + } + + [Fact] + public void Should_Not_Drop_And_Recreate_When_Same_GroupByColumns_And_Same_Count() + { + // Arrange + using WithGroupByColumnsContext26 sourceContext = new(); + using WithGroupByColumnsContext26 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_GroupByColumns_Same_Count_Different_Values + + private class MetricEntity27 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + public string? Region { get; set; } + } + + private class MetricAggregateWithCategory27 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + } + + private class MetricAggregateWithRegion27 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Region { get; set; } + } + + private class GroupByCategoryContext27 : 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) + .AddGroupByColumn(x => x.Category); + }); + } + } + + private class GroupByRegionContext27 : 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) + .AddGroupByColumn(x => x.Region); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_GroupByColumns_Same_Count_Different_Values() + { + // Arrange + using GroupByCategoryContext27 sourceContext = new(); + using GroupByRegionContext27 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Accept_Explicit_Non_Null_FeatureDiffContext + + private class MetricEntity28 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate28 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BasicContinuousAggregateContext28 : 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_Accept_Explicit_Non_Null_FeatureDiffContext() + { + // Arrange + using BasicContinuousAggregateContext28 sourceContext = new(); + using BasicContinuousAggregateContext28 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + FeatureDiffContext context = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + Assert.Empty(operations); + } + + #endregion + + #region Should_Drop_And_Recreate_When_WhereClause_Changes + + private class MetricEntity29 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate29 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoWhereClauseContext29 : 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 WithWhereClauseContext29 : 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) + .Where("value > 0"); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_WhereClause_Changes() + { + // Arrange + using NoWhereClauseContext29 sourceContext = new(); + using WithWhereClauseContext29 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_WithNoData_Changes + + private class MetricEntity30 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate30 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class WithDataContext30 : 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 WithNoDataContext30 : 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) + .WithNoData(); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_WithNoData_Changes() + { + // Arrange + using WithDataContext30 sourceContext = new(); + using WithNoDataContext30 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_TimeBucketGroupBy_Changes + + private class MetricEntity31 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate31 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class TimeBucketGroupByEnabledContext31 : 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, + timeBucketGroupBy: true) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class TimeBucketGroupByDisabledContext31 : 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, + timeBucketGroupBy: false) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_TimeBucketGroupBy_Changes() + { + // Arrange + using TimeBucketGroupByEnabledContext31 sourceContext = new(); + using TimeBucketGroupByDisabledContext31 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_AggregateFunctions_Same_Count_Different_Values + + private class MetricEntity32 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MetricAggregateAvg32 + { + public DateTime TimeBucket { get; set; } + public double AggValue { get; set; } + } + + private class MetricAggregateMax32 + { + public DateTime TimeBucket { get; set; } + public double AggValue { get; set; } + } + + private class AvgAggregateFunctionContext32 : 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.AggValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class MaxAggregateFunctionContext32 : 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.AggValue, x => x.Value, EAggregateFunction.Max); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_AggregateFunctions_Same_Count_Different_Values() + { + // Arrange + using AvgAggregateFunctionContext32 sourceContext = new(); + using MaxAggregateFunctionContext32 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Not_Recreate_When_GroupByColumns_Same_Values_Different_List_Order + + private class MetricEntity33 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string? Category { get; set; } + public string? Region { get; set; } + } + + private class MetricAggregateMultiGroupBy33 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string? Category { get; set; } + public string? Region { get; set; } + } + + private class GroupByCategoryFirstContext33 : 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) + .AddGroupByColumn(x => x.Category) + .AddGroupByColumn(x => x.Region); + }); + } + } + + private class GroupByRegionFirstContext33 : 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) + .AddGroupByColumn(x => x.Region) + .AddGroupByColumn(x => x.Category); + }); + } + } + + [Fact] + public void Should_Not_Recreate_When_GroupByColumns_Same_Values_Different_List_Order() + { + // Arrange + using GroupByCategoryFirstContext33 sourceContext = new(); + using GroupByRegionFirstContext33 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_TimeBucketSourceColumn_Changes + + private class MetricEntity34 + { + public DateTime Timestamp { get; set; } + public DateTime CreatedAt { get; set; } + public double Value { get; set; } + } + + private class MetricAggregate34 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class TimestampSourceColumnContext34 : 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 CreatedAtSourceColumnContext34 : 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.CreatedAt) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_TimeBucketSourceColumn_Changes() + { + // Arrange + using TimestampSourceColumnContext34 sourceContext = new(); + using CreatedAtSourceColumnContext34 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_AggregateFunction_Count_Differs + + private class MetricEntity35 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SingleAggregate35 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DoubleAggregate35 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public double MaxValue { get; set; } + } + + private class SingleAggregateContext35 : 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 DoubleAggregateContext35 : 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) + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_AggregateFunction_Count_Differs() + { + // Arrange + using SingleAggregateContext35 sourceContext = new(); + using DoubleAggregateContext35 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_And_Recreate_When_GroupByColumn_Count_Differs + + private class MetricEntity36 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string Category { get; set; } = string.Empty; + public string Region { get; set; } = string.Empty; + } + + private class SingleGroupByAggregate36 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Category { get; set; } = string.Empty; + } + + private class DoubleGroupByAggregate36 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Category { get; set; } = string.Empty; + public string Region { get; set; } = string.Empty; + } + + private class SingleGroupByContext36 : 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) + .AddGroupByColumn(x => x.Category); + }); + } + } + + private class DoubleGroupByContext36 : 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) + .AddGroupByColumn(x => x.Category) + .AddGroupByColumn(x => x.Region); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_GroupByColumn_Count_Differs() + { + // Arrange + using SingleGroupByContext36 sourceContext = new(); + using DoubleGroupByContext36 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Contains(operations, op => op is DropContinuousAggregateOperation); + Assert.Contains(operations, op => op is CreateContinuousAggregateOperation); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index cfd7da9..7fdf1db 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -1331,13 +1331,11 @@ public void Should_Detect_CompressionSegmentBy_Added() AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); Assert.NotNull(alterOp); - // Ensure SegmentBy was detected Assert.Null(alterOp.OldCompressionSegmentBy); Assert.NotNull(alterOp.CompressionSegmentBy); Assert.Single(alterOp.CompressionSegmentBy); Assert.Equal("TenantId", alterOp.CompressionSegmentBy[0]); - // Ensure Compression was implicitly enabled Assert.True(alterOp.EnableCompression); } @@ -1345,9 +1343,6 @@ public void Should_Detect_CompressionSegmentBy_Added() #region Should_Detect_Change_When_SegmentBy_Order_Different - // Unlike ChunkSkipColumns, SegmentBy order matters for physical storage layout. - // This test ensures the differ detects a change even if the set of columns is the same. - private class MetricEntity20 { public DateTime Timestamp { get; set; } @@ -1567,7 +1562,6 @@ public void Should_Detect_CompressionSettings_Removed() AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); Assert.NotNull(alterOp); - // Verify Old Values are present Assert.NotNull(alterOp.OldCompressionSegmentBy); Assert.NotNull(alterOp.OldCompressionOrderBy); Assert.NotEmpty(alterOp.OldCompressionSegmentBy); @@ -1718,7 +1712,7 @@ public void Should_Detect_AdditionalDimensions_ColumnName_Changed() // Act IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); - // Assert + // Assert AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); Assert.NotNull(alterOp); Assert.NotNull(alterOp.OldAdditionalDimensions); @@ -1728,4 +1722,346 @@ public void Should_Detect_AdditionalDimensions_ColumnName_Changed() } #endregion + + #region Should_Not_Emit_Alter_When_ChunkSkipColumns_Both_Null + + private class MetricEntity24 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext24 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Not_Emit_Alter_When_ChunkSkipColumns_Both_Null() + { + // Arrange + using BasicHypertableContext24 sourceContext = new(); + using BasicHypertableContext24 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Detect_Alter_When_CompressionOrderBy_Count_Differs + + private class MetricEntity25 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int AnotherColumn { get; set; } + } + + private class OneOrderByContext25 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [s.By(x => x.Value)]); + }); + } + } + + private class TwoOrderByContext25 : 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("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.By(x => x.Value), + s.By(x => x.AnotherColumn) + ]); + }); + } + } + + [Fact] + public void Should_Detect_Alter_When_CompressionOrderBy_Count_Differs() + { + // Arrange + using OneOrderByContext25 sourceContext = new(); + using TwoOrderByContext25 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldCompressionOrderBy); + Assert.NotNull(alterOp.CompressionOrderBy); + Assert.Single(alterOp.OldCompressionOrderBy!); + Assert.Equal(2, alterOp.CompressionOrderBy!.Count); + } + + #endregion + + #region Should_Treat_OrderBy_With_NullsFirst_As_Equal_To_Explicit_Asc_NullsFirst + + private class MetricEntity27 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullsFirstImplicitContext27 : 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("orderby_nulls_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [s.By(x => x.Value, nullsFirst: true)]); + }); + } + } + + private class NullsFirstExplicitContext27 : 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("orderby_nulls_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [s.ByAscending(x => x.Value, nullsFirst: true)]); + }); + } + } + + [Fact] + public void Should_Treat_OrderBy_With_NullsFirst_As_Equal_To_Explicit_Asc_NullsFirst() + { + // Arrange + using NullsFirstImplicitContext27 sourceContext = new(); + using NullsFirstExplicitContext27 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.Null(alterOp); + } + + #endregion + + #region Should_Detect_Alter_When_Dimension_Interval_Changes + + private class MetricEntity28 + { + public DateTime Timestamp { get; set; } + public long SequenceId { get; set; } + } + + private class SmallIntervalDimensionContext28 : 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("dim_interval_change_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("SequenceId", "1000")); + }); + } + } + + private class LargeIntervalDimensionContext28 : 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("dim_interval_change_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("SequenceId", "5000")); + }); + } + } + + [Fact] + public void Should_Detect_Alter_When_Dimension_Interval_Changes() + { + // Arrange + using SmallIntervalDimensionContext28 sourceContext = new(); + using LargeIntervalDimensionContext28 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.NotNull(alterOp.AdditionalDimensions); + Assert.Equal("1000", alterOp.OldAdditionalDimensions![0].Interval); + Assert.Equal("5000", alterOp.AdditionalDimensions![0].Interval); + } + + #endregion + + #region Should_Detect_Alter_When_AdditionalDimensions_One_Null + + private class MetricEntity26 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class WithDimensionContext26 : 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("dim_one_null_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("DeviceId", 4)); + }); + } + } + + private class WithoutDimensionContext26 : 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("dim_one_null_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Alter_When_AdditionalDimensions_One_Null() + { + // Arrange + using WithDimensionContext26 sourceContext = new(); + using WithoutDimensionContext26 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotNull(alterOp.OldAdditionalDimensions); + Assert.Null(alterOp.AdditionalDimensions); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs index f21b7ac..1493313 100644 --- a/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; @@ -1000,6 +1001,65 @@ public void Should_Handle_Both_Null_Models() #endregion + #region Should_ReAdd_RetentionPolicy_When_CA_Is_Recreated + + private class MetricEntity17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ContinuousAggregateWithPolicyContext17 : 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("ca_recreate_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_ReAdd_RetentionPolicy_When_CA_Is_Recreated() + { + // Arrange + using ContinuousAggregateWithPolicyContext17 sourceContext = new(); + using ContinuousAggregateWithPolicyContext17 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + RecreatedAggregates = new HashSet<(string, string)> { ("public", "ca_recreate_metrics") } + }; + + RetentionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + AddRetentionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("ca_recreate_metrics", addOp.TableName); + Assert.Equal("7 days", addOp.DropAfter); + + Assert.Empty(operations.OfType()); + } + + #endregion + #region Should_Detect_MaxRuntime_Change private class MetricEntity15 diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index 76d5b6c..2fdd2e2 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -60,7 +60,7 @@ SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""value"") AS "" [Fact] public void DesignTime_Create_WithAllStandardAggregates_GeneratesCorrectCode() { - // Arrange - Test all standard aggregate functions (AVG, MAX, MIN, SUM, COUNT) + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "daily_stats", @@ -102,7 +102,7 @@ GROUP BY time_bucket [Fact] public void DesignTime_Create_WithCountStarAggregate_GeneratesUnquotedWildcard() { - // Arrange - the COUNT(*) wildcard is not an identifier and must not be quoted + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "hourly_counts", @@ -136,7 +136,7 @@ SELECT time_bucket('1 hour', ""event_time"") AS time_bucket, COUNT(*) AS ""recor [Fact] public void DesignTime_Create_WithTimescaleDBFirstLastFunctions_GeneratesCorrectSyntax() { - // Arrange - TimescaleDB first() and last() require (value, time) parameter ordering + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "price_aggregates", @@ -174,7 +174,7 @@ SELECT time_bucket('5 minutes', ""timestamp"") AS time_bucket, first(""price"", [Fact] public void DesignTime_Create_WithGroupByColumns_GeneratesCorrectGrouping() { - // Arrange - Test GROUP BY with multiple columns + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "sales_by_region", @@ -208,7 +208,7 @@ SELECT time_bucket('1 hour', ""sale_time"") AS time_bucket, ""region"", ""store_ [Fact] public void DesignTime_Create_WithWhereClause_GeneratesCorrectFiltering() { - // Arrange - Test WHERE clause filtering + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "high_value_trades", @@ -244,7 +244,7 @@ SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""price"") AS "" [Fact] public void DesignTime_Create_WithChunkInterval_GeneratesCorrectOption() { - // Arrange - Test custom chunk_interval + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "monthly_summary", @@ -302,7 +302,7 @@ public void Runtime_Create_MinimalAggregate_GeneratesCorrectSQL() // Act string result = GetRuntimeSql(operation); - // Assert - Runtime uses single quotes for SQL + // Assert Assert.Contains("CREATE MATERIALIZED VIEW \"public\".\"hourly_metrics\"", result); Assert.Contains("WITH (timescaledb.continuous", result); Assert.Contains("time_bucket('1 hour', \"timestamp\")", result); @@ -315,7 +315,7 @@ public void Runtime_Create_MinimalAggregate_GeneratesCorrectSQL() [Fact] public void Runtime_Create_WithFirstLast_UsesCorrectParameterOrder() { - // Arrange - Verify TimescaleDB first()/last() parameter ordering + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "price_extremes", @@ -338,7 +338,7 @@ public void Runtime_Create_WithFirstLast_UsesCorrectParameterOrder() // Act string result = GetRuntimeSql(operation); - // Assert - first() and last() must have (value, time) ordering + // Assert Assert.Contains("first(\"price\", \"time\") AS \"opening_price\"", result); Assert.Contains("last(\"price\", \"time\") AS \"closing_price\"", result); } @@ -346,7 +346,7 @@ public void Runtime_Create_WithFirstLast_UsesCorrectParameterOrder() [Fact] public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() { - // Arrange - Test comprehensive continuous aggregate + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "comprehensive_stats", @@ -375,7 +375,7 @@ public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() // Act string result = GetRuntimeSql(operation); - // Assert all SQL components + // Assert Assert.Contains("CREATE MATERIALIZED VIEW \"analytics\".\"comprehensive_stats\"", result); Assert.Contains("timescaledb.continuous", result); Assert.Contains("timescaledb.create_group_indexes = true", result); @@ -491,7 +491,7 @@ public void DesignTime_Alter_MaterializedOnly_GeneratesCorrectCode() [Fact] public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() { - // Arrange - Test altering multiple properties at once + // Arrange AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "complex_view", @@ -507,7 +507,7 @@ public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() // Act string result = GetDesignTimeCode(operation); - // Assert - Should generate three separate ALTER statements + // Assert Assert.Contains("timescaledb.chunk_interval = '60 days'", result); Assert.Contains("timescaledb.create_group_indexes = true", result); Assert.Contains("timescaledb.materialized_only = false", result); @@ -516,7 +516,7 @@ public void DesignTime_Alter_MultipleProperties_GeneratesMultipleStatements() [Fact] public void Alter_NoChanges_GeneratesNoSQL() { - // Arrange - Nothing changed + // Arrange AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "unchanged_view", @@ -532,7 +532,7 @@ public void Alter_NoChanges_GeneratesNoSQL() // Act string result = GetRuntimeSql(operation); - // Assert - Should generate empty result + // Assert Assert.Empty(result.Trim()); } @@ -582,7 +582,7 @@ public void Runtime_Drop_GeneratesCorrectSQL() [Fact] public void Runtime_Drop_UsesIfExists_ForSafety() { - // Arrange - Verify IF EXISTS is always used for safety + // Arrange DropContinuousAggregateOperation operation = new() { MaterializedViewName = "maybe_exists", @@ -592,7 +592,7 @@ public void Runtime_Drop_UsesIfExists_ForSafety() // Act string result = GetRuntimeSql(operation); - // Assert - IF EXISTS prevents errors if view doesn't exist + // Assert Assert.Contains("IF EXISTS", result); } @@ -603,7 +603,7 @@ public void Runtime_Drop_UsesIfExists_ForSafety() [Fact] public void Create_With_Malformed_AggregateFunction_SkipsInvalidFunction() { - // Arrange - Aggregate function with wrong number of parts (only 2 instead of 3) + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "bad_agg", @@ -612,7 +612,6 @@ public void Create_With_Malformed_AggregateFunction_SkipsInvalidFunction() TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "time", TimeBucketGroupBy = true, - // Malformed: missing source column (only 2 parts) AggregateFunctions = ["alias_only:Avg", "valid_agg:Sum:value"], GroupByColumns = [], CreateGroupIndexes = false, @@ -623,16 +622,15 @@ public void Create_With_Malformed_AggregateFunction_SkipsInvalidFunction() // Act string result = GetRuntimeSql(operation); - // Assert - Should skip malformed and only include valid + // Assert Assert.Contains("SUM(\"value\") AS \"valid_agg\"", result); - // Malformed function should be skipped Assert.DoesNotContain("alias_only", result); } [Fact] public void Create_With_ExtraColon_In_AggregateFunction_SkipsFunction() { - // Arrange - Too many parts (4 instead of 3) + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "malformed_view", @@ -641,7 +639,6 @@ public void Create_With_ExtraColon_In_AggregateFunction_SkipsFunction() TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "time", TimeBucketGroupBy = true, - // Too many colons - will be skipped AggregateFunctions = ["alias:Avg:value:extra", "valid:Sum:amount"], GroupByColumns = [], CreateGroupIndexes = false, @@ -652,17 +649,16 @@ public void Create_With_ExtraColon_In_AggregateFunction_SkipsFunction() // Act string result = GetRuntimeSql(operation); - // Assert - Malformed should be skipped + // Assert Assert.DoesNotContain("extra", result); Assert.DoesNotContain("alias", result); - // Valid one should be present Assert.Contains("SUM(\"amount\") AS \"valid\"", result); } [Fact] public void Create_With_SinglePartAggregateFunction_SkipsFunction() { - // Arrange - Only 1 part (just alias, no function or column) + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "single_part", @@ -689,7 +685,7 @@ public void Create_With_SinglePartAggregateFunction_SkipsFunction() [Fact] public void Create_WithoutTimeBucketInGroupBy_GeneratesCorrectSQL() { - // Arrange - TimeBucketGroupBy = false + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "no_time_bucket_gb", @@ -709,9 +705,7 @@ public void Create_WithoutTimeBucketInGroupBy_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - // time_bucket should still be in SELECT Assert.Contains("time_bucket('1 hour', \"time\") AS time_bucket", result); - // GROUP BY should only have region, not time_bucket Assert.Contains("GROUP BY \"region\"", result); Assert.DoesNotContain("GROUP BY time_bucket", result); } @@ -719,7 +713,7 @@ public void Create_WithoutTimeBucketInGroupBy_GeneratesCorrectSQL() [Fact] public void Create_WithRawSQLGroupByExpression_IncludesAsIs() { - // Arrange - GROUP BY with raw SQL expression (contains parentheses) + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "raw_sql_groupby", @@ -738,7 +732,7 @@ public void Create_WithRawSQLGroupByExpression_IncludesAsIs() // Act string result = GetRuntimeSql(operation); - // Assert - Raw SQL should be included as-is, quoted column should be quoted + // Assert Assert.Contains("EXTRACT(HOUR FROM time)", result); Assert.Contains("\"region\"", result); } @@ -746,7 +740,7 @@ public void Create_WithRawSQLGroupByExpression_IncludesAsIs() [Fact] public void Create_WithRawSQLGroupByExpression_ContainingComma_IncludesAsIs() { - // Arrange - GROUP BY with raw SQL expression containing comma + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "comma_sql", @@ -765,7 +759,7 @@ public void Create_WithRawSQLGroupByExpression_ContainingComma_IncludesAsIs() // Act string result = GetRuntimeSql(operation); - // Assert - Raw SQL should be included as-is + // Assert Assert.Contains("COALESCE(region, 'unknown')", result); } @@ -793,7 +787,6 @@ public void Create_WithEmptyGroupByColumns_OnlyIncludesTimeBucket() // Assert Assert.Contains("GROUP BY time_bucket", result); - // Ensure there's no trailing comma after time_bucket Assert.DoesNotContain("GROUP BY time_bucket,", result); } @@ -881,7 +874,7 @@ public void Create_WithEmptyWhereClause_OmitsWhereClause() [Fact] public void Create_WithUnsupportedAggregateFunction_ThrowsNotSupportedException() { - // Arrange - Using an unsupported aggregate function name + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "unsupported", @@ -897,7 +890,6 @@ public void Create_WithUnsupportedAggregateFunction_ThrowsNotSupportedException( WithNoData = false }; - // Act & Assert NotSupportedException ex = Assert.Throws(() => ContinuousAggregateSqlGenerator.Generate(operation)); @@ -908,7 +900,7 @@ public void Create_WithUnsupportedAggregateFunction_ThrowsNotSupportedException( [Fact] public void Create_WithInvalidAggregateEnum_ThrowsNotSupportedException() { - // Arrange - Using an unrecognized aggregate function name + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "bad_func", @@ -924,7 +916,6 @@ public void Create_WithInvalidAggregateEnum_ThrowsNotSupportedException() WithNoData = false }; - // Act & Assert NotSupportedException ex = Assert.Throws(() => ContinuousAggregateSqlGenerator.Generate(operation)); @@ -934,7 +925,7 @@ public void Create_WithInvalidAggregateEnum_ThrowsNotSupportedException() [Fact] public void Create_WithAllAggregateFunctionsMalformed_GeneratesViewWithNoAggregates() { - // Arrange - All aggregate functions are malformed + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "all_malformed", @@ -953,7 +944,7 @@ public void Create_WithAllAggregateFunctionsMalformed_GeneratesViewWithNoAggrega // Act string result = GetRuntimeSql(operation); - // Assert - Should still generate the view structure, just without aggregate columns + // Assert Assert.Contains("CREATE MATERIALIZED VIEW", result); Assert.Contains("time_bucket", result); Assert.DoesNotContain("bad1", result); @@ -967,7 +958,7 @@ public void Create_WithAllAggregateFunctionsMalformed_GeneratesViewWithNoAggrega [Fact] public void Alter_With_NullChunkInterval_And_OldChunkIntervalExists_RestoresOldValue() { - // Arrange - ChunkInterval set to null but OldChunkInterval exists + // Arrange AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "restore_chunk", @@ -979,14 +970,14 @@ public void Alter_With_NullChunkInterval_And_OldChunkIntervalExists_RestoresOldV // Act string result = GetRuntimeSql(operation); - // Assert - Should restore old value + // Assert Assert.Contains("SET (timescaledb.chunk_interval = '7 days')", result); } [Fact] public void Alter_With_EmptyChunkInterval_And_EmptyOldChunkInterval_GeneratesNothing() { - // Arrange - Both intervals are empty + // Arrange AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "empty_intervals", @@ -998,14 +989,14 @@ public void Alter_With_EmptyChunkInterval_And_EmptyOldChunkInterval_GeneratesNot // Act string result = GetRuntimeSql(operation); - // Assert - Should generate nothing for chunk interval + // Assert Assert.DoesNotContain("chunk_interval", result); } [Fact] public void Alter_With_NullChunkInterval_And_NullOldChunkInterval_GeneratesNothing() { - // Arrange - Both intervals are null + // Arrange AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "null_intervals", @@ -1017,7 +1008,7 @@ public void Alter_With_NullChunkInterval_And_NullOldChunkInterval_GeneratesNothi // Act string result = GetRuntimeSql(operation); - // Assert - Should generate nothing for chunk interval + // Assert Assert.DoesNotContain("chunk_interval", result); } @@ -1094,10 +1085,9 @@ public void Runtime_UsesSingleQuotesForEscaping() List statements = ContinuousAggregateSqlGenerator.Generate(operation); string result = string.Join("\n", statements); - // Assert - Runtime should use single quotes (standard SQL quoting) + // Assert Assert.Contains("\"public\"", result); Assert.Contains("\"test_view\"", result); - // Should not have escaped quotes Assert.DoesNotContain("\"\"public\"\"", result); } @@ -1108,7 +1098,7 @@ public void Runtime_UsesSingleQuotesForEscaping() [Fact] public void Create_RequiresTimeBucket_InSelectClause() { - // Arrange - TimescaleDB requires time_bucket in continuous aggregates + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "test_view", @@ -1127,7 +1117,7 @@ public void Create_RequiresTimeBucket_InSelectClause() // Act string result = GetRuntimeSql(operation); - // Assert - time_bucket is required by TimescaleDB + // Assert Assert.Contains("time_bucket(", result); Assert.Contains("AS time_bucket", result); } @@ -1154,14 +1144,14 @@ public void Create_RequiresTimeBucket_InGroupByClause() // Act string result = GetRuntimeSql(operation); - // Assert - GROUP BY must include time_bucket + // Assert Assert.Contains("GROUP BY time_bucket", result); } [Fact] public void Create_FirstAndLast_RequireTimeParameter() { - // Arrange - TimescaleDB first() and last() must have time column + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "first_last_test", @@ -1184,7 +1174,7 @@ public void Create_FirstAndLast_RequireTimeParameter() // Act string result = GetRuntimeSql(operation); - // Assert - first() and last() MUST have (value, time) signature + // Assert Assert.Contains("first(\"value\", \"ts\")", result); Assert.Contains("last(\"value\", \"ts\")", result); Assert.DoesNotContain("first(\"value\")", result); @@ -1194,7 +1184,7 @@ public void Create_FirstAndLast_RequireTimeParameter() [Fact] public void Create_StandardAggregates_DoNotRequireTimeParameter() { - // Arrange - Standard SQL aggregates (AVG, MAX, MIN, SUM, COUNT) don't need time + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "standard_agg_test", @@ -1218,7 +1208,7 @@ public void Create_StandardAggregates_DoNotRequireTimeParameter() // Act string result = GetRuntimeSql(operation); - // Assert - Standard aggregates use single parameter + // Assert Assert.Contains("AVG(\"temperature\")", result); Assert.Contains("MAX(\"temperature\")", result); Assert.Contains("COUNT(\"id\")", result); @@ -1232,7 +1222,7 @@ public void Create_StandardAggregates_DoNotRequireTimeParameter() [Fact] public void Runtime_Create_WithViewDefinition_GeneratesRawCreateMaterializedView() { - // Arrange - scaffolded round-trip path: raw view body, no structured fields needed + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "hourly_metrics", @@ -1251,13 +1241,10 @@ public void Runtime_Create_WithViewDefinition_GeneratesRawCreateMaterializedView string sql = Assert.Single(statements); Assert.Contains("CREATE MATERIALIZED VIEW \"public\".\"hourly_metrics\"", sql); Assert.Contains("timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = true", sql); - // Raw body present (with leading semicolon stripped) and a final terminating semicolon appended Assert.Contains("SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\"", sql); Assert.Contains("GROUP BY bucket", sql); Assert.EndsWith(";", sql); - // Raw body's own trailing ';' is stripped, so only one ';' at the end Assert.False(sql.EndsWith(";;")); - // No structured SELECT synthesis appears Assert.DoesNotContain("AS time_bucket,", sql); } @@ -1280,15 +1267,13 @@ public void Runtime_Create_WithViewDefinition_AndWithNoData_AppendsWithNoDataBef // Assert string sql = Assert.Single(statements); Assert.Contains("WITH NO DATA", sql); - // WITH NO DATA must come immediately before the trailing ';' Assert.EndsWith("WITH NO DATA;", sql); } [Fact] public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields() { - // Arrange - even when structured fields are populated with nonsense values, - // the raw ViewDefinition must take precedence and they must not appear in output. + // Arrange CreateContinuousAggregateOperation operation = new() { MaterializedViewName = "hourly_metrics", @@ -1306,7 +1291,7 @@ public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields() // Act List statements = ContinuousAggregateSqlGenerator.Generate(operation); - // Assert - raw body is present, structured fields are not + // Assert string sql = Assert.Single(statements); Assert.Contains("SELECT raw_only AS bucket FROM \"src\" GROUP BY bucket", sql); Assert.DoesNotContain("BOGUS_WIDTH", sql); @@ -1318,5 +1303,60 @@ public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields() } #endregion + + #region Create_WithTimeBucketGroupByFalse_And_EmptyGroupByColumns_OmitsGroupByClause + + [Fact] + public void Create_WithTimeBucketGroupByFalse_And_EmptyGroupByColumns_OmitsGroupByClause() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "no_group_by_ca", + Schema = "public", + ParentName = "src_no_gb", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "ts", + TimeBucketGroupBy = false, + AggregateFunctions = ["total:Sum:amount"], + GroupByColumns = [], + CreateGroupIndexes = false, + MaterializedOnly = false, + WithNoData = false + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("time_bucket('1 hour', \"ts\") AS time_bucket", result); + Assert.Contains("SUM(\"amount\") AS \"total\"", result); + Assert.DoesNotContain("GROUP BY", result); + } + + #endregion + + #region Alter_NullChunkInterval_And_EmptyOldChunkInterval_GeneratesNoStatement + + [Fact] + public void Alter_NullChunkInterval_And_EmptyOldChunkInterval_GeneratesNoStatement() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "null_vs_empty_ca", + Schema = "public", + ChunkInterval = null, + OldChunkInterval = "" + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.DoesNotContain("chunk_interval", result); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs index ebc3c36..670c240 100644 --- a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorTests.cs @@ -68,8 +68,6 @@ FROM timescaledb_information.jobs Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } - // --- Tests for DropReorderPolicyOperation --- - [Fact] public void Generate_Drop_creates_correct_remove_policy_sql() { @@ -91,8 +89,6 @@ public void Generate_Drop_creates_correct_remove_policy_sql() Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } - // --- Tests for AlterReorderPolicyOperation --- - [Fact] public void Generate_Alter_when_only_job_settings_change_creates_only_alter_job_sql() { @@ -101,12 +97,10 @@ public void Generate_Alter_when_only_job_settings_change_creates_only_alter_job_ { Schema = "metrics", TableName = "TestTable", - // Fundamental properties are the same IndexName = "IX_TestTable_Time", OldIndexName = "IX_TestTable_Time", InitialStart = null, OldInitialStart = null, - // Job properties have changed ScheduleInterval = "2 days", OldScheduleInterval = "1 day" }; @@ -185,5 +179,127 @@ FROM timescaledb_information.jobs // Assert Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } + + #region Should_Recreate_Without_AlterJob_When_Only_InitialStart_Changes + + [Fact] + public void Should_Recreate_Without_AlterJob_When_Only_InitialStart_Changes() + { + // Arrange + DateTime oldDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime newDate = new(2025, 6, 15, 0, 0, 0, DateTimeKind.Utc); + AlterReorderPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + IndexName = "IX_TestTable_Time", + OldIndexName = "IX_TestTable_Time", + InitialStart = newDate, + OldInitialStart = oldDate, + }; + + string expected = @" + SELECT remove_reorder_policy('public.""TestTable""', if_exists => true); + SELECT add_reorder_policy('public.""TestTable""', 'IX_TestTable_Time', initial_start => '2025-06-15T00:00:00.0000000Z'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("alter_job", result); + } + + #endregion + + #region Should_Return_Empty_When_No_Reorder_Changes + + [Fact] + public void Should_Return_Empty_When_No_Reorder_Changes() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + IndexName = "IX_TestTable_Time", + OldIndexName = "IX_TestTable_Time", + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day", + MaxRuntime = "00:00:00", + OldMaxRuntime = "00:00:00", + MaxRetries = -1, + OldMaxRetries = -1, + RetryPeriod = "1 day", + OldRetryPeriod = "1 day", + }; + + // Act + List statements = ReorderPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Empty(statements); + } + + #endregion + + #region Should_Not_Include_AlterJob_When_Recreated_Without_JobClauses + + [Fact] + public void Should_Not_Include_AlterJob_When_Recreated_Without_JobClauses() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + IndexName = "IX_New_Name", + OldIndexName = "IX_Old_Name", + }; + + string expected = @" + SELECT remove_reorder_policy('public.""TestTable""', if_exists => true); + SELECT add_reorder_policy('public.""TestTable""', 'IX_New_Name'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("alter_job", result); + } + + #endregion + + #region Should_Recreate_Policy_When_Only_InitialStart_Changes + + [Fact] + public void Should_Recreate_Policy_When_Only_InitialStart_Changes() + { + // Arrange + AlterReorderPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + IndexName = "IX_TestTable_Time", + OldIndexName = "IX_TestTable_Time", + InitialStart = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + OldInitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Contains("remove_reorder_policy", result); + Assert.Contains("add_reorder_policy", result); + Assert.Contains("initial_start => '2026-01-01T00:00:00.0000000Z'", result); + } + + #endregion } -} \ No newline at end of file +} diff --git a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs index 0ba4fd6..69c1fd9 100644 --- a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs @@ -15,8 +15,6 @@ private static string GetGeneratedCode(dynamic operation) return string.Join("\n", statements); } - // --- Tests for AddRetentionPolicyOperation --- - #region Generate_Add_DropAfter_with_minimal_config_creates_only_add_policy_sql [Fact] @@ -168,8 +166,6 @@ FROM timescaledb_information.jobs #endregion - // --- Tests for AlterRetentionPolicyOperation --- - #region Generate_Alter_when_only_job_settings_change_creates_only_alter_job_sql [Fact] @@ -256,7 +252,6 @@ public void Generate_Alter_changed_to_DropCreatedBefore_creates_remove_add_and_a OldScheduleInterval = "1 day" }; - // During recreation, alter_job is emitted to reapply the final-state job settings string expected = @" SELECT remove_retention_policy('public.""TestTable""', if_exists => true); SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '30 days'); @@ -311,8 +306,6 @@ FROM timescaledb_information.jobs #endregion - // --- Tests for DropRetentionPolicyOperation --- - #region Generate_Drop_creates_correct_remove_policy_sql [Fact] @@ -517,8 +510,6 @@ FROM timescaledb_information.jobs #endregion - // --- Tests for runtime quoting --- - private static List GetRuntimeStatements(dynamic operation) { return RetentionPolicySqlGenerator.Generate(operation); @@ -569,8 +560,6 @@ public void Generate_Drop_with_runtime_quoting_uses_single_quotes() #endregion - // --- Tests for alter no-change path --- - #region Generate_Add_DropAfter_uses_drop_after_interval_argument [Fact] @@ -587,7 +576,7 @@ public void Generate_Add_DropAfter_uses_drop_after_interval_argument() // Act List statements = RetentionPolicySqlGenerator.Generate(operation); - // Assert — DropAfter maps to the drop_after => INTERVAL argument + // Assert Assert.Single(statements); Assert.Contains("drop_after => INTERVAL '7 days'", statements[0]); Assert.DoesNotContain("drop_created_before", statements[0]); @@ -611,7 +600,7 @@ public void Generate_Add_DropCreatedBefore_uses_drop_created_before_interval_arg // Act List statements = RetentionPolicySqlGenerator.Generate(operation); - // Assert — DropCreatedBefore maps to the drop_created_before => INTERVAL argument + // Assert Assert.Single(statements); Assert.Contains("drop_created_before => INTERVAL '30 days'", statements[0]); Assert.DoesNotContain("drop_after", statements[0]); @@ -653,5 +642,150 @@ public void Generate_Alter_when_no_changes_returns_empty_list() } #endregion + + #region Should_Recreate_When_DropCreatedBefore_Changes + + [Fact] + public void Should_Recreate_When_DropCreatedBefore_Changes() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = null, + OldDropAfter = null, + DropCreatedBefore = "60 days", + OldDropCreatedBefore = "30 days", + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day" + }; + + string expected = @" + SELECT remove_retention_policy('public.""TestTable""', if_exists => true); + SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '60 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); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Should_Generate_Add_SQL_With_DropCreatedBefore + + [Fact] + public void Should_Generate_Add_SQL_With_DropCreatedBefore() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropCreatedBefore = "30 days" + }; + + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_created_before => INTERVAL '30 days'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("drop_after", result); + } + + #endregion + + #region Should_Recreate_Without_AlterJob_When_No_Job_Settings + + [Fact] + public void Should_Recreate_Without_AlterJob_When_No_Job_Settings() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "30 days", + OldDropAfter = "7 days", + DropCreatedBefore = null, + OldDropCreatedBefore = null, + InitialStart = null, + OldInitialStart = null + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("remove_retention_policy", statements[0]); + Assert.Contains("add_retention_policy", statements[1]); + Assert.DoesNotContain("alter_job", string.Join(" ", statements)); + } + + #endregion + + #region Should_Generate_Add_SQL_With_No_DropAfter_Or_DropCreatedBefore + + [Fact] + public void Should_Generate_Add_SQL_With_No_DropAfter_Or_DropCreatedBefore() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = null, + DropCreatedBefore = null + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("add_retention_policy", statements[0]); + Assert.DoesNotContain("drop_after", statements[0]); + Assert.DoesNotContain("drop_created_before", statements[0]); + } + + #endregion + + #region Should_Recreate_Policy_When_Only_DropCreatedBefore_Changes + + [Fact] + public void Should_Recreate_Policy_When_Only_DropCreatedBefore_Changes() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropCreatedBefore = "60 days", + OldDropCreatedBefore = "30 days", + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("remove_retention_policy", statements[0]); + Assert.Contains("drop_created_before => INTERVAL '60 days'", statements[1]); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs index 54d59b1..9bbbb4b 100644 --- a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs +++ b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs @@ -132,6 +132,42 @@ public void BuildQueryString_MigrationCommandListBuilder_WritesNothingForEmptyLi mockBuilder.Verify(b => b.Append(It.IsAny()), Times.Never); mockBuilder.Verify(b => b.EndCommand(It.IsAny()), Times.Never); } + #region ReplaceSelectWithPerform_NonSelect_Returns_Unchanged + + [Fact] + public void ReplaceSelectWithPerform_NonSelect_Returns_Unchanged() + { + // Arrange + string input = "INSERT INTO x VALUES (1);"; + + // Act + string result = SqlBuilderHelper.ReplaceSelectWithPerform(input); + + // Assert + Assert.Equal(input, result); + } + + #endregion + + #region BuildQueryString_IndentedStringBuilder_SuppressTransaction_True + + [Fact] + public void BuildQueryString_IndentedStringBuilder_SuppressTransaction_True() + { + // Arrange + List statements = ["SELECT 1;"]; + IndentedStringBuilder indentedBuilder = new(); + + // Act + SqlBuilderHelper.BuildQueryString(statements, indentedBuilder, suppressTransaction: true); + string result = indentedBuilder.ToString(); + + // Assert + Assert.Contains("suppressTransaction: true", result); + } + + #endregion + #region ReplaceSelectWithPerform [Fact] @@ -370,4 +406,4 @@ public void BuildQueryString_MigrationCommandListBuilder_UsePerform_And_Suppress #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 2efab4a..edb8e7c 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -188,7 +188,6 @@ public void Generate_CreateContinuousAggregate_GeneratesValidCSharp() 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); @@ -293,7 +292,6 @@ public void Generate_AlterReorderPolicy_WithNoChanges_GeneratesValidCSharpOrNoOp TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); IndentedStringBuilder builder = new(); - // An alter operation with no actual changes AlterReorderPolicyOperation operation = new() { TableName = "sensor_data", @@ -428,7 +426,6 @@ public void Generate_AlterContinuousAggregate_WithNoChanges_GeneratesValidCSharp TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); IndentedStringBuilder builder = new(); - // An alter operation with no actual changes AlterContinuousAggregateOperation operation = new() { MaterializedViewName = "hourly_stats", @@ -782,6 +779,239 @@ public void Generate_DropRetentionPolicy_GeneratesValidCSharp() #endregion + #region AlterHypertable With Actual Changes Tests + + [Fact] + public void Should_EmitChunkTimeIntervalArgs_When_AlterHypertableHasIntervalChange() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + ChunkTimeInterval = "14 days", + OldChunkTimeInterval = "7 days" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterHypertable(", result); + Assert.Contains("chunkTimeInterval:", result); + Assert.Contains("oldChunkTimeInterval:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Should_EmitEnableCompressionArg_When_AlterHypertableEnablesCompression() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterHypertable(", result); + Assert.Contains("enableCompression:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + #endregion + + #region Multi-Operation Generate Call Tests + + [Fact] + public void Should_GenerateBothOperations_When_CreateAndAlterHypertablePassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateHypertableOperation createOperation = new() + { + TableName = "metrics", + Schema = "public", + TimeColumnName = "recorded_at", + ChunkTimeInterval = "7 days" + }; + + AlterHypertableOperation alterOperation = new() + { + TableName = "metrics", + Schema = "public", + ChunkTimeInterval = "14 days", + OldChunkTimeInterval = "7 days" + }; + + // Act + generator.Generate("migrationBuilder", [createOperation, alterOperation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".CreateHypertable(", result); + Assert.Contains(".AlterHypertable(", result); + Assert.DoesNotContain(".Sql(", result); + } + + [Fact] + public void Should_GenerateBothOperations_When_AddAndDropReorderPolicyPassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddReorderPolicyOperation addOperation = new() + { + TableName = "sensor_data", + Schema = "public", + IndexName = "sensor_data_time_idx" + }; + + DropReorderPolicyOperation dropOperation = new() + { + TableName = "old_sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [addOperation, dropOperation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AddReorderPolicy(", result); + Assert.Contains(".DropReorderPolicy(", result); + Assert.DoesNotContain(".Sql(", result); + } + + [Fact] + public void Should_GenerateBothOperations_When_AddAndDropRetentionPolicyPassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddRetentionPolicyOperation addOperation = new() + { + TableName = "sensor_data", + Schema = "public", + DropAfter = "30 days" + }; + + DropRetentionPolicyOperation dropOperation = new() + { + TableName = "old_sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [addOperation, dropOperation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AddRetentionPolicy(", result); + Assert.Contains(".DropRetentionPolicy(", result); + Assert.DoesNotContain(".Sql(", result); + } + + [Fact] + public void Should_GenerateBothOperations_When_CreateAndDropContinuousAggregatePassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + CreateContinuousAggregateOperation createOperation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + ParentName = "sensor_data", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "timestamp", + TimeBucketGroupBy = true, + AggregateFunctions = ["total_count:Count:id"] + }; + + DropContinuousAggregateOperation dropOperation = new() + { + MaterializedViewName = "old_hourly_stats", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [createOperation, dropOperation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".CreateContinuousAggregate(", result); + Assert.Contains(".DropContinuousAggregate(", result); + Assert.DoesNotContain(".Sql(", result); + } + + [Fact] + public void Should_GenerateBothOperations_When_AddAndRemoveContinuousAggregatePolicyPassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddContinuousAggregatePolicyOperation addOperation = new() + { + MaterializedViewName = "hourly_stats", + Schema = "public", + StartOffset = "1 month", + EndOffset = "1 hour" + }; + + RemoveContinuousAggregatePolicyOperation removeOperation = new() + { + MaterializedViewName = "old_hourly_stats", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [addOperation, removeOperation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AddContinuousAggregatePolicy(", result); + Assert.Contains(".RemoveContinuousAggregatePolicy(", result); + Assert.DoesNotContain(".Sql(", result); + } + + #endregion + #region Generate_StandardCreateTableOperation_Falls_Through_To_Base [Fact] @@ -810,6 +1040,65 @@ public void Generate_StandardCreateTableOperation_Falls_Through_To_Base() #endregion + #region Null Guard Tests + + [Fact] + public void Generate_NullOperation_ThrowsArgumentNullException() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TestableTimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + // Act & Assert + Assert.Throws(() => + generator.CallGenerate(null!, builder)); + } + + [Fact] + public void Generate_NullBuilder_ThrowsArgumentNullException() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TestableTimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + TimeColumnName = "timestamp" + }; + + // Act & Assert + Assert.Throws(() => + generator.CallGenerate(operation, null!)); + } + + #endregion + + + #region Generate_UnknownCustomOperation_Falls_Through_To_Base + + private class CustomUnknownOperation : MigrationOperation { } + + [Fact] + public void Generate_UnknownCustomOperation_Falls_Through_To_Base() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TestableTimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + CustomUnknownOperation operation = new(); + + // Act + InvalidOperationException ex = Assert.Throws( + () => generator.CallGenerate(operation, builder)); + + // Assert + Assert.Contains("cannot scaffold operations of type", ex.Message); + } + + #endregion + #region Helper Methods private static CSharpMigrationOperationGeneratorDependencies CreateDependencies() @@ -818,6 +1107,16 @@ private static CSharpMigrationOperationGeneratorDependencies CreateDependencies( return new CSharpMigrationOperationGeneratorDependencies(mockCSharpHelper.Object); } + /// + /// Exposes the protected Generate overload for null-guard testing. + /// + private class TestableTimescaleCSharpMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) + : TimescaleCSharpMigrationOperationGenerator(dependencies) + { + public void CallGenerate(MigrationOperation operation, IndentedStringBuilder builder) + => Generate(operation, builder); + } + #endregion } diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs index fbfe3e1..9285275 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -638,4 +638,80 @@ public async Task Should_Extract_MaterializedOnly_True_From_ContinuousAggregate( } #endregion + + #region Should_Extract_ContinuousAggregate_When_Connection_Already_Open + + private class PreOpenedConnSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class PreOpenedConnAggMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class PreOpenedConnAggregateContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("pre_opened_ca_source"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("pre_opened_ca_hourly"); + entity.IsContinuousAggregate( + "pre_opened_ca_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public async Task Should_Extract_ContinuousAggregate_When_Connection_Already_Open() + { + // Arrange + string testConnectionString = await GetTestConnectionStringAsync(); + await using PreOpenedConnAggregateContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + + // Act + await connection.OpenAsync(TestContext.Current.CancellationToken); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "pre_opened_ca_hourly"))); + + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "pre_opened_ca_hourly")]; + Assert.Equal("pre_opened_ca_hourly", info.MaterializedViewName); + + // Assert + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs index 5a5c178..c258e26 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs @@ -497,4 +497,60 @@ public async Task Should_Extract_Fully_Configured_ReorderPolicy() } #endregion + + #region Should_Extract_ReorderPolicy_When_Connection_Already_Open + + private class PreOpenedConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class PreOpenedConnectionContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("pre_opened_reorder_metrics"); + entity.HasIndex(x => x.Timestamp, "pre_opened_reorder_idx"); + entity.IsHypertable(x => x.Timestamp) + .WithReorderPolicy("pre_opened_reorder_idx"); + }); + } + } + + [Fact] + public async Task Should_Extract_ReorderPolicy_When_Connection_Already_Open() + { + // Arrange + await using PreOpenedConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + ReorderPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + await connection.OpenAsync(TestContext.Current.CancellationToken); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "pre_opened_reorder_metrics"))); + + ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo info = + (ReorderPolicyScaffoldingExtractor.ReorderPolicyInfo)result[("public", "pre_opened_reorder_metrics")]; + Assert.Equal("pre_opened_reorder_idx", info.IndexName); + + // Assert + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs index 86b4271..53df2c7 100644 --- a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs +++ b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs @@ -153,14 +153,14 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) [Fact] public void Should_Order_DropContinuousAggregate_Before_DropHypertable() { - // Arrange — source has a hypertable + a dependent continuous aggregate; target is empty. + // Arrange 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. + // Assert int dropAggregateIndex = operations.FindIndex(op => op is DropContinuousAggregateOperation); int dropTableIndex = operations.FindIndex(op => op is DropTableOperation dropTable && dropTable.Name == "order_metrics_b"); @@ -229,9 +229,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) [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(); @@ -254,7 +251,6 @@ public void Should_Order_Drop_Policies_Before_DropContinuousAggregate() 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})"); } @@ -408,4 +404,142 @@ public void Should_Order_AlterHypertable_After_CreateHypertable_Through_Orchestr } #endregion + + #region Should_Order_AlterRetentionPolicy_After_CreateHypertable + + private class OrderMetricF + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicySourceContextF : 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("order_metrics_f"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class RetentionPolicyChangedContextF : 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("order_metrics_f"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "14 days"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("order_metrics_g"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class OrderMetricG + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [Fact] + public void Should_Order_AlterRetentionPolicy_After_CreateHypertable() + { + + // Arrange + using RetentionPolicySourceContextF sourceContext = new(); + using RetentionPolicyChangedContextF targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + int createHypertableIndex = operations.FindIndex(op => op is CreateHypertableOperation); + int alterRetentionIndex = operations.FindIndex(op => op is AlterRetentionPolicyOperation); + + Assert.NotEqual(-1, createHypertableIndex); + Assert.NotEqual(-1, alterRetentionIndex); + Assert.True(createHypertableIndex < alterRetentionIndex, + $"CreateHypertable (priority 10) should precede AlterRetentionPolicy (priority 60), " + + $"but found indices {createHypertableIndex} and {alterRetentionIndex}."); + } + + #endregion + + #region Should_Order_CreateTable_Before_CreateHypertable + + private class OrderMetricH + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreateTableAndHypertableContextH : 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("order_metrics_h"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Order_CreateTable_Before_CreateHypertable() + { + + // Arrange + using CreateTableAndHypertableContextH targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(null, targetContext)]; + + // Assert + int createTableIndex = operations.FindIndex(op => + op is CreateTableOperation ct && ct.Name == "order_metrics_h"); + int createHypertableIndex = operations.FindIndex(op => + op is CreateHypertableOperation ht && ht.TableName == "order_metrics_h"); + + Assert.NotEqual(-1, createTableIndex); + Assert.NotEqual(-1, createHypertableIndex); + Assert.True(createTableIndex < createHypertableIndex, + $"CreateTable (priority 0) should precede CreateHypertable (priority 10), " + + $"but found indices {createTableIndex} and {createHypertableIndex}."); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Internals/ParentEntityTypeResolverTests.cs b/tests/Eftdb.Tests/Internals/ParentEntityTypeResolverTests.cs new file mode 100644 index 0000000..ef99010 --- /dev/null +++ b/tests/Eftdb.Tests/Internals/ParentEntityTypeResolverTests.cs @@ -0,0 +1,192 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +/// +/// Unit tests for : null/whitespace guard and the three +/// match strategies (CLR type name, EF short name, database table name). +/// +public class ParentEntityTypeResolverTests +{ + private static IModel GetModel(TContext context) where TContext : DbContext + => context.GetService().Model; + + #region Resolve_NullParentName_Returns_Null + + private class NullLookupEntity { public int Id { get; set; } } + + private class NullLookupContext : DbContext + { + public DbSet Items => 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().ToTable("null_lookup_items"); + } + + [Fact] + public void Resolve_NullParentName_Returns_Null() + { + // Arrange + using NullLookupContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, null); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Resolve_WhitespaceParentName_Returns_Null + + private class WhitespaceLookupEntity { public int Id { get; set; } } + + private class WhitespaceLookupContext : DbContext + { + public DbSet Items => 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().ToTable("whitespace_lookup_items"); + } + + [Fact] + public void Resolve_WhitespaceParentName_Returns_Null() + { + // Arrange + using WhitespaceLookupContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, " "); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Resolve_EmptyParentName_Returns_Null() + { + // Arrange + using WhitespaceLookupContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, string.Empty); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Resolve_ByClrTypeName_Returns_EntityType + + private class ClrNameMatchEntity { public int Id { get; set; } } + + private class ClrNameMatchContext : DbContext + { + public DbSet Items => 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().ToTable("clr_name_match_items"); + } + + [Fact] + public void Resolve_ByClrTypeName_Returns_EntityType() + { + // Arrange + using ClrNameMatchContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, nameof(ClrNameMatchEntity)); + + // Assert + Assert.NotNull(result); + Assert.Equal(typeof(ClrNameMatchEntity), result.ClrType); + } + + #endregion + + #region Resolve_ByTableName_Returns_EntityType + + private class TableNameMatchEntity { public int Id { get; set; } } + + private class TableNameMatchContext : DbContext + { + public DbSet Items => 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().ToTable("sensor_data_table_name"); + } + + [Fact] + public void Resolve_ByTableName_Returns_EntityType() + { + // Arrange + using TableNameMatchContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, "sensor_data_table_name"); + + // Assert + Assert.NotNull(result); + Assert.Equal("sensor_data_table_name", result.GetTableName()); + } + + #endregion + + #region Resolve_NoMatch_Returns_Null + + private class NoMatchEntity { public int Id { get; set; } } + + private class NoMatchContext : DbContext + { + public DbSet Items => 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().ToTable("no_match_items"); + } + + [Fact] + public void Resolve_NoMatch_Returns_Null() + { + // Arrange + using NoMatchContext context = new(); + IModel model = GetModel(context); + + // Act + IEntityType? result = ParentEntityTypeResolver.Resolve(model, "does_not_exist_anywhere"); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs index 855282a..204a402 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateMigrationExtensionsTests.cs @@ -81,7 +81,6 @@ public void CreateContinuousAggregate_NullCollections_CoalesceToEmptyLists() 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); } @@ -137,5 +136,44 @@ public void DropContinuousAggregate_MapsArguments() } #endregion + + #region AlterContinuousAggregate_NullSchema_CoalescesToEmpty + + [Fact] + public void AlterContinuousAggregate_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterContinuousAggregate(materializedViewName: "hourly", schema: null); + + // Assert + AlterContinuousAggregateOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + + #region CreateContinuousAggregate_NullSchema_CoalescesToEmpty + + [Fact] + public void CreateContinuousAggregate_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.CreateContinuousAggregate( + materializedViewName: "hourly", + parentName: "sensor_data", + schema: null); + + // Assert + CreateContinuousAggregateOperation 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 index cfa420a..cb38645 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/RetentionPolicyMigrationExtensionsTests.cs @@ -134,5 +134,24 @@ public void DropRetentionPolicy_NullSchema_CoalescesToEmpty() } #endregion + + #region AlterRetentionPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void AlterRetentionPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterRetentionPolicy(tableName: "sensor_data", schema: null, dropAfter: "30 days"); + + // Assert + AlterRetentionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + Assert.Equal("30 days", op.DropAfter); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs b/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs index 10a3c11..c2c4b7b 100644 --- a/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/IntervalParsingHelperTests.cs @@ -504,7 +504,7 @@ public void Should_Handle_TimeSpan_With_Hours_And_Minutes() [Fact] public void Should_Handle_TimeSpan_Exceeding_24_Hours() { - // Arrange - 48 hours should be 2 days + // Arrange string input = "2.00:00:00"; // Act @@ -538,14 +538,13 @@ public void Should_Handle_Zero_Minutes_With_NonZero_Seconds() [Fact] public void Should_Handle_TimeSpan_At_60_Minutes() { - // Arrange - Exactly 60 minutes (1 hour) + // Arrange string input = "01:00:00"; // Act string result = IntervalParsingHelper.NormalizeInterval(input); // Assert - // TotalMinutes is 60 which is NOT < 60, so it should go to hours check Assert.Equal("1 hour", result); } @@ -556,7 +555,7 @@ public void Should_Handle_TimeSpan_At_60_Minutes() [Fact] public void Should_Handle_Mixed_TimeSpan_With_Days_Hours_Minutes() { - // Arrange - 1 day, 5 hours, 30 minutes + // Arrange string input = "1.05:30:00"; // Act @@ -721,7 +720,7 @@ public void NormalizeInterval_Returns_Correct_Value_For_Negative_Day_Prefix() // Act string result = IntervalParsingHelper.NormalizeInterval("-1.00:00:00"); - // Assert — cannot be parsed; returned unchanged + // Assert Assert.Equal("-1.00:00:00", result); } @@ -769,4 +768,490 @@ public void TryGetTotalMicroseconds_Parses_Week_Unit() } #endregion + + #region TryGetTotalMicroseconds_Negative_Day_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Negative_Day_Returns_False() + { + // Arrange + string input = "-1.00:00:00"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_Negative_Minutes_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Negative_Minutes_Returns_False() + { + // Arrange + string input = "00:-01:00"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_Minutes_60_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Minutes_60_Returns_False() + { + // Arrange + string input = "00:60:00"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_Empty_Fraction_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Empty_Fraction_Returns_False() + { + // Arrange + string input = "00:00:30."; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_NonDigit_Fraction_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_NonDigit_Fraction_Returns_False() + { + // Arrange + string input = "00:00:30.abc"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_Seconds_60_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Seconds_60_Returns_False() + { + // Arrange + string input = "00:00:60"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_Negative_Seconds_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Negative_Seconds_Returns_False() + { + // Arrange + string input = "00:00:-1"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_DayDot_Hours_Over23_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_DayDot_Hours_Over23_Returns_False() + { + // Arrange + string input = "1.25:00:00"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region NormalizeInterval_ThreeMinutes_Returns_3_Minutes + + [Fact] + public void NormalizeInterval_ThreeMinutes_Returns_3_Minutes() + { + // Arrange + string input = "00:03:00"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("3 minutes", result); + } + + #endregion + + #region NormalizeInterval_FractionPresent_Returns_Unchanged + + [Fact] + public void NormalizeInterval_FractionPresent_Returns_Unchanged() + { + // Arrange + string input = "00:00:01.500000"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("00:00:01.500000", result); + } + + #endregion + + // ── TryParseTimeParts edge cases ──────────────────────────────────────── + + #region TryParseTimeParts_Returns_False_For_Negative_Hours_Without_Day_Dot + + [Fact] + public void TryParseTimeParts_Returns_False_For_Negative_Hours_Without_Day_Dot() + { + // Arrange + string input = "-01:00:00"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("-01:00:00", result); + } + + #endregion + + #region TryParseTimeParts_Returns_False_For_NonParseable_Hour + + [Fact] + public void TryParseTimeParts_Returns_False_For_NonParseable_Hour() + { + // Arrange + string input = "XX:00:00"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("XX:00:00", result); + } + + #endregion + + #region TryParseTimeParts_Returns_False_For_NonParseable_Day_Part + + [Fact] + public void TryParseTimeParts_Returns_False_For_NonParseable_Day_Part() + { + // Arrange + string input = "X.12:00:00"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("X.12:00:00", result); + } + + #endregion + + #region TryParseTimeParts_Handles_Fraction_Longer_Than_Six_Digits + + [Fact] + public void TryParseTimeParts_Handles_Fraction_Longer_Than_Six_Digits() + { + // Arrange + string input = "00:00:01.123456789"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("00:00:01.123456789", result); + } + + #endregion + + #region TryParseTimeParts_Handles_Short_Fraction_Padded_To_Six + + [Fact] + public void TryParseTimeParts_Handles_Short_Fraction_Padded_To_Six() + { + // Arrange + string input = "00:00:01.1"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("00:00:01.1", result); + } + + #endregion + + // ── NormalizeInterval — seconds plural / singular ──────────────────────── + + #region NormalizeInterval_Single_Second_Returns_Singular + + [Fact] + public void NormalizeInterval_Single_Second_Returns_Singular() + { + // Arrange + string input = "00:00:01"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("1 second", result); + } + + #endregion + + #region NormalizeInterval_Multiple_Seconds_Returns_Plural + + [Fact] + public void NormalizeInterval_Multiple_Seconds_Returns_Plural() + { + // Arrange + string input = "00:00:02"; + + // Act + string result = IntervalParsingHelper.NormalizeInterval(input); + + // Assert + Assert.Equal("2 seconds", result); + } + + #endregion + + // ── TryGetTotalMicroseconds — alternate unit spellings ─────────────────── + + #region TryGetTotalMicroseconds_Parses_Microsecond_Abbreviations + + [Theory] + [InlineData("1 us", 1L)] + [InlineData("1 usec", 1L)] + [InlineData("2 usecs", 2L)] + [InlineData("3 microsecond", 3L)] + [InlineData("4 microseconds", 4L)] + public void TryGetTotalMicroseconds_Parses_Microsecond_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Millisecond_Abbreviations + + [Theory] + [InlineData("1 ms", 1_000L)] + [InlineData("2 msec", 2_000L)] + [InlineData("3 msecs", 3_000L)] + [InlineData("4 millisecond", 4_000L)] + public void TryGetTotalMicroseconds_Parses_Millisecond_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Second_Abbreviations + + [Theory] + [InlineData("1 s", 1_000_000L)] + [InlineData("2 sec", 2_000_000L)] + [InlineData("3 secs", 3_000_000L)] + [InlineData("4 second", 4_000_000L)] + public void TryGetTotalMicroseconds_Parses_Second_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Minute_Abbreviations + + [Theory] + [InlineData("1 min", 60_000_000L)] + [InlineData("2 mins", 120_000_000L)] + [InlineData("3 minute", 180_000_000L)] + public void TryGetTotalMicroseconds_Parses_Minute_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Hour_Abbreviations + + [Theory] + [InlineData("1 h", 3_600_000_000L)] + [InlineData("2 hr", 7_200_000_000L)] + [InlineData("3 hrs", 10_800_000_000L)] + [InlineData("4 hour", 14_400_000_000L)] + public void TryGetTotalMicroseconds_Parses_Hour_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Day_Abbreviations + + [Theory] + [InlineData("1 d", 86_400_000_000L)] + [InlineData("2 day", 172_800_000_000L)] + public void TryGetTotalMicroseconds_Parses_Day_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Parses_Week_Abbreviations + + [Theory] + [InlineData("1 w", 604_800_000_000L)] + [InlineData("2 week", 1_209_600_000_000L)] + public void TryGetTotalMicroseconds_Parses_Week_Abbreviations(string input, long expected) + { + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long result); + + // Assert + Assert.True(success); + Assert.Equal(expected, result); + } + + #endregion + + #region TryGetTotalMicroseconds_Whitespace_Only_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_Whitespace_Only_Returns_False() + { + // Arrange + string input = " "; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_ColonPath_Returns_False_For_Invalid_Time + + [Fact] + public void TryGetTotalMicroseconds_ColonPath_Returns_False_For_Invalid_Time() + { + // Arrange + string input = "bad:value"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion + + #region TryGetTotalMicroseconds_NumberUnit_Regex_No_Match_Returns_False + + [Fact] + public void TryGetTotalMicroseconds_NumberUnit_Regex_No_Match_Returns_False() + { + // Arrange + string input = "days 7"; + + // Act + bool success = IntervalParsingHelper.TryGetTotalMicroseconds(input, out long microseconds); + + // Assert + Assert.False(success); + Assert.Equal(0L, microseconds); + } + + #endregion } diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs index 1422ef6..940da3d 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using System.Linq.Expressions; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; @@ -2230,6 +2231,131 @@ public void StringBuilder_WithNoData_Should_Set_WithNoData_Annotation() #endregion + #region Should_Handle_Cast_Lambda_In_AddAggregateFunction + + private class CastLambdaAggFunction_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CastLambdaAggFunction_HourlyAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CastLambdaAggFunction_Context : 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("cast_lambda_agg_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cast_lambda_agg_hourly", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => (object)x.AvgValue, x => (object)x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Handle_Cast_Lambda_In_AddAggregateFunction() + { + // Arrange + using CastLambdaAggFunction_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CastLambdaAggFunction_HourlyAggregate))!; + + // Act + List? aggregateFunctions = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value as List; + + // Assert + Assert.NotNull(aggregateFunctions); + Assert.Single(aggregateFunctions); + Assert.Contains("AvgValue:Avg:Value", aggregateFunctions); + } + + #endregion + + #region Should_Handle_Cast_Lambda_In_AddGroupByColumn + + private class CastLambdaGroupBy_MetricEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public int DeviceId { get; set; } + } + + private class CastLambdaGroupBy_GroupedAggregate + { + public DateTime TimeBucket { get; set; } + public int DeviceId { get; set; } + public double AvgValue { get; set; } + } + + private class CastLambdaGroupBy_Context : 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("cast_lambda_gb_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cast_lambda_gb_hourly", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => (object)x.DeviceId); + }); + } + } + + [Fact] + public void Should_Handle_Cast_Lambda_In_AddGroupByColumn() + { + // Arrange + using CastLambdaGroupBy_Context context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CastLambdaGroupBy_GroupedAggregate))!; + + // Act + List? groupByColumns = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value as List; + + // Assert + Assert.NotNull(groupByColumns); + Assert.Single(groupByColumns); + Assert.Contains("DeviceId", groupByColumns); + } + + #endregion + #region GenericBuilder_And_StringBuilder_Should_Produce_Identical_Annotations private class BuilderParity_MetricEntity @@ -2327,4 +2453,29 @@ public void GenericBuilder_And_StringBuilder_Should_Produce_Identical_Annotation } #endregion + + #region GetPropertyName_Should_Throw_When_Body_Is_Binary_Expression + + private class BinaryExpressionEntity + { + public int Id { get; set; } + } + + [Fact] + public void GetPropertyName_Should_Throw_When_Body_Is_Binary_Expression() + { + // Arrange + Expression> expression = e => e.Id + 1; + + // Act & Assert + ArgumentException ex = Assert.Throws((Action)(() => + { + _ = ContinuousAggregateBuilder + .GetPropertyName(expression); + })); + + Assert.Contains("simple property access expression", ex.Message); + } + + #endregion } From 37a7c5fcbf356d35e774af680766b29407b31fea Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Wed, 22 Jul 2026 18:25:00 +0200 Subject: [PATCH 12/12] ci: apply coverlet.runsettings in coverage workflow --- .github/workflows/run-tests.yml | 4 +- .../TimescaleDatabaseModelFactoryTests.cs | 177 ++++++++ .../RetentionPolicyTypeBuilderTests.cs | 415 ++++++++++++++++++ 3 files changed, 594 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c070400..67419b6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,11 +33,11 @@ jobs: run: dotnet build --no-restore - name: Run tests with coverage - run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage + run: dotnet test --no-build --verbosity normal --settings tests/Eftdb.Tests/coverlet.runsettings --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: ./coverage + files: ./coverage/**/coverage.cobertura.xml fail_ci_if_error: true \ No newline at end of file diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index 90bb81c..bc91a67 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -1166,6 +1166,183 @@ public async Task Should_Preserve_Base_Model_Structure() #endregion + #region Should_Remove_Internal_Sequences_When_No_Schema_Filter_Specified + + private class InternalSeqHypertableMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InternalSeqHypertableContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("InternalSeqMetrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Remove_Internal_Sequences_When_No_Schema_Filter_Specified() + { + // Arrange + string testConnectionString = await GetTestConnectionStringAsync(); + await using InternalSeqHypertableContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + // Act + DatabaseModelFactoryOptions options = new(tables: [], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + HashSet internalSchemas = + [ + "_timescaledb_internal", + "_timescaledb_catalog", + "_timescaledb_config", + "_timescaledb_cache" + ]; + + Assert.DoesNotContain(model.Sequences, s => s.Schema != null && internalSchemas.Contains(s.Schema)); + Assert.DoesNotContain(model.Tables, t => t.Schema != null && internalSchemas.Contains(t.Schema)); + } + + #endregion + + #region Should_Preserve_User_Sequences_When_No_Schema_Filter_Specified + + private class UserSeqMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class UserSeqEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class UserSeqContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("UserSeqMetrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.ToTable("UserSeqEntities"); + }); + + modelBuilder.HasSequence("user_explicit_seq", "public"); + } + } + + [Fact] + public async Task Should_Preserve_User_Sequences_When_No_Schema_Filter_Specified() + { + // Arrange + string testConnectionString = await GetTestConnectionStringAsync(); + await using UserSeqContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + // Act + DatabaseModelFactoryOptions options = new(tables: [], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + HashSet internalSchemas = + [ + "_timescaledb_internal", + "_timescaledb_catalog", + "_timescaledb_config", + "_timescaledb_cache" + ]; + + Assert.DoesNotContain(model.Sequences, s => s.Schema != null && internalSchemas.Contains(s.Schema)); + Assert.Contains(model.Sequences, s => s.Name == "user_explicit_seq" && s.Schema == "public"); + } + + #endregion + + #region Should_Not_Filter_Tables_Or_Sequences_When_Explicit_Schema_Filter_Is_Set + + private class SchemaFilterMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SchemaFilterContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("SchemaFilterMetrics", schema: "public"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Not_Filter_Tables_Or_Sequences_When_Explicit_Schema_Filter_Is_Set() + { + // Arrange + string testConnectionString = await GetTestConnectionStringAsync(); + await using SchemaFilterContext context = new(testConnectionString); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(testConnectionString); + + // Act + DatabaseModelFactoryOptions options = new(tables: [], schemas: ["public"]); + DatabaseModel model = factory.Create(connection, options); + + // Assert + Assert.Contains(model.Tables, t => t.Name == "SchemaFilterMetrics" && t.Schema == "public"); + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "SchemaFilterMetrics"); + Assert.NotNull(metricsTable); + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + } + + #endregion + #region Should_Scaffold_Multiple_Continuous_Aggregates private class MultiCaggSource diff --git a/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs index bfa0611..4146598 100644 --- a/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using Microsoft.EntityFrameworkCore; @@ -1288,6 +1289,420 @@ public void StringBuilder_Repeated_WithInitialStart_Latest_Value_Wins() #endregion + // ── ContinuousAggregateStringBuilder overload tests ───────────────────── + + #region CaggStringBuilder_WithRetentionPolicy_Should_Set_HasRetentionPolicy_And_DropAfter + + private class CaggDropAfterSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggDropAfterView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggDropAfterContext : 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("cagg_drop_after_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_drop_after_view"); + entity.IsContinuousAggregate( + "cagg_drop_after_view", + "cagg_drop_after_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Set_HasRetentionPolicy_And_DropAfter() + { + // Arrange + using CaggDropAfterContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggDropAfterView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)); + } + + #endregion + + #region CaggStringBuilder_WithRetentionPolicy_Should_Set_DropCreatedBefore + + private class CaggDropCreatedBeforeSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggDropCreatedBeforeView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggDropCreatedBeforeContext : 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("cagg_drop_created_before_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_drop_created_before_view"); + entity.IsContinuousAggregate( + "cagg_drop_created_before_view", + "cagg_drop_created_before_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: null, + dropCreatedBefore: "30 days", + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Set_DropCreatedBefore() + { + // Arrange + using CaggDropCreatedBeforeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggDropCreatedBeforeView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)); + } + + #endregion + + #region CaggStringBuilder_WithRetentionPolicy_Should_Set_Optional_Intervals + + private class CaggOptionalIntervalsSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggOptionalIntervalsView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggOptionalIntervalsContext : 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("cagg_optional_intervals_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_optional_intervals_view"); + entity.IsContinuousAggregate( + "cagg_optional_intervals_view", + "cagg_optional_intervals_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: "14 days", + dropCreatedBefore: null, + scheduleInterval: "1 day", + maxRuntime: "01:00:00", + maxRetries: 3, + retryPeriod: "00:10:00"); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Set_Optional_Intervals() + { + // Arrange + using CaggOptionalIntervalsContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggOptionalIntervalsView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("14 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("01:00:00", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:10:00", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region CaggStringBuilder_WithRetentionPolicy_Should_Return_RetentionPolicyStringBuilder_For_Chaining + + private class CaggChainingSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggChainingView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggChainingContext : 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("cagg_chaining_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_chaining_view"); + RetentionPolicyStringBuilder builder = + entity.IsContinuousAggregate( + "cagg_chaining_view", + "cagg_chaining_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + + builder.WithInitialStart(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Return_RetentionPolicyStringBuilder_For_Chaining() + { + // Arrange + using CaggChainingContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggChainingView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.NotNull(entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)); + Assert.Equal(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)!.Value); + } + + #endregion + + #region CaggStringBuilder_WithRetentionPolicy_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore + + private class CaggBothSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggBothView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggBothContext : 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("cagg_both_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_both_view"); + entity.IsContinuousAggregate( + "cagg_both_view", + "cagg_both_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: "7 days", + dropCreatedBefore: "30 days", + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore() + { + // Arrange + using CaggBothContext context = new(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("mutually exclusive", exception.Message); + } + + #endregion + + #region CaggStringBuilder_WithRetentionPolicy_Should_Throw_When_Neither_Specified + + private class CaggNeitherSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggNeitherView + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggNeitherContext : 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("cagg_neither_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_neither_view"); + entity.IsContinuousAggregate( + "cagg_neither_view", + "cagg_neither_source", + "1 hour", + "Timestamp") + .WithRetentionPolicy( + dropAfter: null, + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null); + }); + } + } + + [Fact] + public void CaggStringBuilder_WithRetentionPolicy_Should_Throw_When_Neither_Specified() + { + // Arrange + using CaggNeitherContext context = new(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("Exactly one", exception.Message); + } + + #endregion + // ── Parity test: scaffold overload vs. named-param overload ────────────── #region ScaffoldOverload_Produces_Identical_Annotations_To_NamedParam_Overload