From 81d6117f5bf6ec928eaf99548e5558e0459b8153 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Fri, 4 Sep 2026 20:10:58 +0200 Subject: [PATCH] feat: hierarchical continuous aggregates and custom bucket column names --- .claude/reference/architecture.md | 3 + .../data-annotations/continuous-aggregates.md | 94 +++ docs/fluent-api/continuous-aggregates.md | 146 +++- .../PowerMeterReadingConfiguration.cs | 24 + .../PowerUsageDailyConfiguration.cs | 38 + .../PowerUsageHourlyConfiguration.cs | 37 + .../Models/PowerMeterReading.cs | 26 + .../Models/PowerUsageDaily.cs | 40 + .../Models/PowerUsageHourly.cs | 41 + .../Eftdb.Samples.Shared/TimescaleContext.cs | 3 + .../ContinuousAggregateAnnotationRenderer.cs | 34 +- .../ContinuousAggregateCSharpGenerator.cs | 4 +- ...ContinuousAggregateScaffoldingExtractor.cs | 23 + .../TimescaleDbAnnotationCodeGenerator.cs | 11 + .../Scaffolding/ViewDefinitionParser.cs | 17 + .../ContinuousAggregateAnnotations.cs | 1 + .../ContinuousAggregateBuilder.cs | 15 + .../ContinuousAggregateBuilderCore.cs | 8 + .../ContinuousAggregateConvention.cs | 9 + .../ContinuousAggregateStringBuilder.cs | 19 + .../TimeBucketAttribute.cs | 9 +- ...AggregateViewColumnValidationConvention.cs | 177 ++++ ...TimeColumnStoreTypeValidationConvention.cs | 6 +- src/Eftdb/DefaultValues.cs | 2 + .../ContinuousAggregateSqlGenerator.cs | 5 +- ...ContinuousAggregatePolicyModelExtractor.cs | 10 +- .../ContinuousAggregateDiffer.cs | 189 +++-- .../ContinuousAggregateModelExtractor.cs | 69 +- .../Internals/ParentEntityTypeResolver.cs | 8 +- .../ContinuousAggregateMigrationExtensions.cs | 2 + .../CreateContinuousAggregateOperation.cs | 1 + ...escaleDbContextOptionsBuilderExtensions.cs | 1 + ...gateViewColumnValidationConventionTests.cs | 382 +++++++++ ...tinuousAggregateAnnotationRendererTests.cs | 492 +++++++++++ ...ContinuousAggregateCSharpGeneratorTests.cs | 36 + .../Scaffolding/ViewDefinitionParserTests.cs | 164 ++++ .../Differs/ContinuousAggregateDifferTests.cs | 770 ++++++++++++++++++ .../ContinuousAggregateModelExtractorTests.cs | 482 ++++++++++- ...tinuousAggregateOperationGeneratorTests.cs | 104 ++- .../ContinuousAggregateIntegrationTests.cs | 529 ++++++++++++ ...nuousAggregateScaffoldingExtractorTests.cs | 53 ++ .../ContinuousAggregateStringBuilderTests.cs | 137 ++++ 42 files changed, 4104 insertions(+), 117 deletions(-) create mode 100644 samples/Eftdb.Samples.Shared/Configurations/PowerMeterReadingConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Configurations/PowerUsageDailyConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Configurations/PowerUsageHourlyConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/PowerMeterReading.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/PowerUsageDaily.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/PowerUsageHourly.cs create mode 100644 src/Eftdb/Configuration/ContinuousAggregateViewColumnValidationConvention.cs create mode 100644 tests/Eftdb.Tests/Conventions/ContinuousAggregateViewColumnValidationConventionTests.cs create mode 100644 tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateStringBuilderTests.cs diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index d8fe0ad..d6ede39 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -58,6 +58,7 @@ Runtime (`src/Eftdb/`): - `Configuration/ConventionValidationHelper` — `ValidateExclusiveFields` (XOR constraints like `After`/`CreatedBefore`), `ParseInitialStart` - `Configuration/PolicyJobBuilderCore` — base class for reorder/retention/CA-policy builder cores (ScheduleInterval, MaxRuntime, MaxRetries, RetryPeriod, InitialStart annotations) - `Configuration/TimeColumnStoreTypeValidationConvention` + `Internals/TimeColumnStoreTypeValidator` — model-finalized validation that time columns resolve to timestamp/timestamptz/date/integer store types +- `Configuration/ContinuousAggregateViewColumnValidationConvention` — model-finalized validation of structured CA views: rejects duplicate output columns (bucket + group-by + aggregate aliases, resolved to store column names) and a `WithTimeBucketProperty` target that does not exist; raw-view-definition aggregates are exempt - `Internals/ColumnNameResolver` — **single resolution authority** for column names: accepts CLR property name, dot-separated complex-type path, or the column name itself; recursive complex-type traversal both directions; complex collections skipped - `Internals/ExpressionHelper` — `GetPropertyName` from selector lambdas; chained member access yields dot-paths for `ColumnNameResolver` - `Internals/ParentEntityTypeResolver` — resolves a CA's parent entity by CLR name, EF short name, or table name @@ -120,3 +121,5 @@ Drops negative (before EF table drops, reverse dependency order); adds/alters po - Operation properties: `MaterializedViewName`, `ParentName` (entity name, resolved via EF metadata), `TimeBucketWidth`, `TimeBucketSourceColumn`, `AggregateFunctions` (colon-delimited wire format, see patterns.md), `GroupByColumns`, `WhereClause` (raw SQL, emitted verbatim — quoted identifiers must match resolved column names) - `first()`/`last()` take the time-bucket column as second argument: `last("price", "timestamp")` - Aggregate column aliases must match property names for EF mapping +- **Bucket column naming**: `WithTimeBucketProperty` designates the property whose mapped column becomes the view's bucket alias; without it the alias falls back to `time_bucket` (snapshot compatibility). Renaming the bucket column is a structural change (drop + recreate). +- **Hierarchical CAs** (CA-on-CA): the extractor (`ContinuousAggregateModelExtractor.SortParentsFirst`) is the single source of parent-first topological order; the differ's `TopologicalIndexByViewName` relies on it. `ContinuousAggregateDiffer` cascades drop+recreate to descendants on a parent structural change. `ParentEntityTypeResolver` is the single parent-resolution authority (CLR name, EF short name, or table name). diff --git a/docs/data-annotations/continuous-aggregates.md b/docs/data-annotations/continuous-aggregates.md index 8c5f211..77e09e6 100644 --- a/docs/data-annotations/continuous-aggregates.md +++ b/docs/data-annotations/continuous-aggregates.md @@ -110,6 +110,58 @@ public class SensorDailyAggregate } ``` +### Naming the Time-Bucket Column + +The materialized view's bucket column is named `time_bucket` by default, matching the TimescaleDB `time_bucket()` function-name default. Querying the aggregate entity requires a property mapped to that column — either explicitly via `[Column("time_bucket")]`, or implicitly through a naming convention on a property named `TimeBucket`. + +The `[TimeBucket]` attribute can be placed on either the class or a property: + +- **Class placement** configures only the bucket width, source column, and GROUP BY behavior. The bucket column keeps the default `time_bucket` name. +- **Property placement** additionally designates that property as the bucket target. The generated view aliases its bucket column to the property's mapped column name — so custom names such as `hour_start` work — and no separate `[Column("time_bucket")]` is needed. + +A property-level `[TimeBucket]` wins over a class-level one if both are present. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; + +[Keyless] +[ContinuousAggregate( + MaterializedViewName = "power_usage_hourly", + ParentName = nameof(PowerMeterReading))] +public class PowerUsageHourly +{ + // Property-level [TimeBucket]: configures the bucket AND aliases the view's + // bucket column to HourStart's mapped column name (hour_start under snake_case). + [TimeBucket("1 hour", nameof(PowerMeterReading.Timestamp))] + public DateTime HourStart { get; set; } + + [Aggregate(EAggregateFunction.Avg, nameof(PowerMeterReading.PowerKw))] + public double AvgPowerKw { get; set; } +} + +public class PowerMeterReading +{ + public string MeterId { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } +} +``` + +> :warning: **Note:** The bucket column name is part of the view's structural definition. Moving `[TimeBucket]` onto a property whose mapped column differs from `time_bucket` on an **existing** aggregate changes that column name, which forces a drop and recreate of the aggregate (materialized data is rebuilt). In a hierarchy the drop cascades to every descendant aggregate. See [Migration Ordering](../fluent-api/continuous-aggregates#migration-ordering). + +> :warning: **Note:** Class-level `[TimeBucket]` is unaffected: the bucket column stays `time_bucket`, byte-for-byte identical to earlier versions. + +### Model Validation + +Structured aggregates (those configured through attributes rather than a raw view definition) are validated at model finalization: + +- Duplicate output column names are rejected with an `InvalidOperationException`. The check compares the bucket column, all `[GroupByColumn]` columns, and every `[Aggregate]` alias after resolving them to database column names. A source column that collides with the bucket column name — previously surfacing only at `migrate` time — is now caught at model build. +- A property designated by a property-level `[TimeBucket]` that does not exist on the entity raises an `InvalidOperationException` (this cannot occur through attributes alone, but the same check guards Fluent-configured models). + +> :warning: **Note:** Entities scaffolded with a raw view definition are exempt from both checks, because the structured projection fields are unused on that path. + ## Configuration Options The `[ContinuousAggregate]` attribute provides several configuration properties: @@ -242,6 +294,48 @@ public class TradeHourlyAggregate } ``` +## Hierarchical Continuous Aggregates + +A continuous aggregate can aggregate from another continuous aggregate. The child sets `ParentName` to the parent aggregate entity and its `[TimeBucket]` source column references the parent's bucket column. The parent's bucket property must resolve to a known column so the child can reference it by name. Two equivalent options exist: + +- Place `[TimeBucket]` on the parent's bucket property (see [Naming the Time-Bucket Column](#naming-the-time-bucket-column)). The designated name flows through resolution automatically: set the child's `[TimeBucket]` source column to that property's mapped column name (for example `hour_start`). +- Map the parent's bucket property to the default column with `[Column("time_bucket")]`, keeping a class-level `[TimeBucket]`, and set the child's source column to `"time_bucket"`. + +The example below uses the second option; the property-level approach mirrors [Naming the Time-Bucket Column](#naming-the-time-bucket-column). + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations.Schema; + +[Keyless] +[ContinuousAggregate(MaterializedViewName = "trade_hourly", ParentName = nameof(Trade))] +[TimeBucket("1 hour", nameof(Trade.Timestamp))] +public class TradeHourly +{ + [Column("time_bucket")] + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, nameof(Trade.Price))] + public decimal AvgPrice { get; set; } +} + +[Keyless] +[ContinuousAggregate(MaterializedViewName = "trade_daily", ParentName = nameof(TradeHourly))] +[TimeBucket("1 day", "time_bucket")] +public class TradeDaily +{ + [Column("time_bucket")] + public DateTime TimeBucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, nameof(TradeHourly.AvgPrice))] + public decimal AvgPrice { get; set; } +} +``` + +Migration ordering, descendant recreation, and scaffolding behave identically to the Fluent API. See [Hierarchical Continuous Aggregates](../fluent-api/continuous-aggregates#hierarchical-continuous-aggregates) for the shared behavior and the TimescaleDB server-side bucket-width constraints. + ## Grouping by Additional Columns 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: diff --git a/docs/fluent-api/continuous-aggregates.md b/docs/fluent-api/continuous-aggregates.md index 869b63c..e484da2 100644 --- a/docs/fluent-api/continuous-aggregates.md +++ b/docs/fluent-api/continuous-aggregates.md @@ -148,6 +148,150 @@ public void Configure(EntityTypeBuilder builder) > :warning: **Note:** The WHERE clause should be a valid SQL expression without the "WHERE" keyword. Use double quotes for column identifiers if needed. +## Naming the Time-Bucket Column + +The materialized view's bucket column is named `time_bucket` by default, matching the TimescaleDB `time_bucket()` function-name default. Querying the aggregate entity requires a property mapped to that column — either explicitly via `.HasColumnName("time_bucket")`, or implicitly through a naming convention on a property named `TimeBucket`. + +`.WithTimeBucketProperty(agg => agg.Prop)` designates a property as the bucket target. The generated view then aliases the bucket column to that property's mapped column name, so no `.HasColumnName("time_bucket")` magic string is needed, and custom names such as `hour_start` work. Resolution respects the active naming convention: a `HourStart` property under snake_case maps to `hour_start`, and the view's bucket is aliased accordingly. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class PowerUsageHourlyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + "power_usage_hourly", + "1 hour", + x => x.Timestamp) + // The view aliases its bucket column to HourStart's mapped column name + // (hour_start under snake_case) instead of the default "time_bucket". + .WithTimeBucketProperty(x => x.HourStart) + .AddAggregateFunction(x => x.AvgPowerKw, x => x.PowerKw, EAggregateFunction.Avg); + } +} + +public class PowerMeterReading +{ + public string MeterId { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } +} + +public class PowerUsageHourly +{ + public DateTime HourStart { get; set; } + public double AvgPowerKw { get; set; } +} +``` + +The string-based builder used by scaffolded code exposes an equivalent `.WithTimeBucketProperty("HourStart")` overload. + +> :warning: **Note:** The bucket column name is part of the view's structural definition. Designating a property whose mapped column differs from `time_bucket` on an **existing** aggregate changes that column name, which forces a drop and recreate of the aggregate (materialized data is rebuilt). In a hierarchy the drop cascades to every descendant aggregate. See [Migration Ordering](#migration-ordering). + +> :warning: **Note:** Undesignated aggregates are unaffected: without `.WithTimeBucketProperty(...)` the bucket column stays `time_bucket`, byte-for-byte identical to earlier versions. + +## Model Validation + +Structured aggregates (those configured through the builders rather than a raw view definition) are validated at model finalization: + +- Duplicate output column names are rejected with an `InvalidOperationException`. The check compares the bucket column, all GROUP BY columns, and every aggregate alias after resolving them to database column names. A source column that collides with the bucket column name is caught at model build. +- A property designated via `.WithTimeBucketProperty(...)` that does not exist on the entity raises an `InvalidOperationException`. + +> :warning: **Note:** Entities scaffolded with a raw view definition are exempt from both checks, because the structured projection fields are unused on that path. + +## Hierarchical Continuous Aggregates + +A continuous aggregate can aggregate from another continuous aggregate rather than from the raw hypertable, forming a rollup chain (for example hourly → daily). This reduces the work of coarse-grained rollups: the daily aggregate reads pre-computed hourly buckets instead of every raw row. + +The child is configured with the ordinary `.IsContinuousAggregate()` overload. The source type parameter is the **parent aggregate entity** (not the raw hypertable), and the time-bucket selector picks the parent aggregate's bucket property. + +The child's `time_bucket()` call references the parent's bucket column by name, so the parent's bucket property must resolve to a known column. Two equivalent options exist: + +- Designate the parent's bucket property with `.WithTimeBucketProperty(x => x.HourStart)` (see [Naming the Time-Bucket Column](#naming-the-time-bucket-column)). The designated name flows through resolution automatically: the child's `propertyExpression: parent => parent.HourStart` selector picks the same property, and the generated SQL agrees on the column name. +- Map the bucket property to the default column explicitly via `.Property(x => x.TimeBucket).HasColumnName("time_bucket")`. The view exposes its bucket under `time_bucket`, and the child references it by that name. + +Either mapping is also what makes LINQ queries against an aggregate's bucket column work. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; + +public class MarketDataContext : DbContext +{ + public DbSet Trades => Set(); + public DbSet TradesHourly => Set(); + public DbSet TradesDaily => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Source hypertable + modelBuilder.Entity(entity => + { + entity.HasKey(x => new { x.Ticker, x.Timestamp }); + entity.IsHypertable(x => x.Timestamp); + }); + + // Level 1: hourly aggregate over the raw hypertable + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate("trade_hourly", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); + }); + + // Level 2: daily aggregate whose source is the hourly aggregate + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate("trade_daily", "1 day", x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgPrice, x => x.AvgPrice, EAggregateFunction.Avg); + }); + } +} + +public class Trade +{ + public string Ticker { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + public decimal Price { get; set; } +} + +public class TradeHourly +{ + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } +} + +public class TradeDaily +{ + public DateTime TimeBucket { get; set; } + public decimal AvgPrice { get; set; } +} +``` + +### Migration Ordering + +Ordering across the chain is handled automatically: + +- Parents are created before their children; children are dropped before their parents. +- A structural change to a parent (bucket width, bucket column name, aggregate functions, GROUP BY, or WHERE) drops and recreates all of its descendants as well, and their refresh policies are re-added afterwards. + +### Scaffolding + +Database-first scaffolding of hierarchical aggregates is supported. The scaffolder resolves the child's parent to the parent aggregate's view (not the internal `_materialized_hypertable_N` table), so the generated `ParentName` refers to the parent aggregate entity. + +> :warning: **Note:** TimescaleDB imposes server-side constraints on the child bucket width: it must be greater than, and an integer multiple of, the parent's bucket width. Calendar-based buckets (months, years, time zones) have additional rules. See the [TimescaleDB documentation on hierarchical continuous aggregates](https://docs.tigerdata.com/use-timescale/latest/continuous-aggregates/hierarchical-continuous-aggregates/) for the exact rules. + ## Configuration Options ### WithNoData @@ -452,7 +596,7 @@ public class TradeAggregate - The source entity must be a TimescaleDB hypertable. - The time bucket width determines the aggregation granularity (e.g., "1 hour", "1 day", "15 minutes"). - Chunk interval for the aggregate's underlying materialized hypertable defaults to 10 times the source hypertable's chunk interval if not specified. -- Continuous aggregates support hierarchical aggregation (aggregating from another continuous aggregate). +- Continuous aggregates support [hierarchical aggregation](#hierarchical-continuous-aggregates) (aggregating from another continuous aggregate). - Refresh policies can be configured to automatically keep the aggregate up-to-date. ## Common Use Cases diff --git a/samples/Eftdb.Samples.Shared/Configurations/PowerMeterReadingConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/PowerMeterReadingConfiguration.cs new file mode 100644 index 0000000..32d7b96 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/PowerMeterReadingConfiguration.cs @@ -0,0 +1,24 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + /// + /// Fluent API configuration for , the source hypertable + /// at the base of the hierarchical continuous aggregate chain + /// (see and ). + /// + public class PowerMeterReadingConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("power_meter_readings"); + builder.HasKey(x => new { x.MeterId, x.Timestamp }); + + builder.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/PowerUsageDailyConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/PowerUsageDailyConfiguration.cs new file mode 100644 index 0000000..3fe4934 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/PowerUsageDailyConfiguration.cs @@ -0,0 +1,38 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + /// + /// Level 2 of the hierarchical continuous aggregate chain: a daily rollup whose source is + /// the continuous aggregate, not the raw hypertable. This is + /// what makes the aggregate "hierarchical" — the source type parameter of + /// IsContinuousAggregate<TChild, TParent> is another aggregate entity. + /// + public class PowerUsageDailyConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + materializedViewName: "power_usage_daily", + timeBucketWidth: "1 day", + propertyExpression: source => source.HourStart, + timeBucketGroupBy: true) + .WithTimeBucketProperty(x => x.DayStart) + // The daily view aliases its bucket column to this property's mapped column name + // (day_start under the snake_case convention) instead of the default "time_bucket". + .AddAggregateFunction(agg => agg.MinPowerKw, source => source.MinPowerKw, EAggregateFunction.Min) + .AddAggregateFunction(agg => agg.MaxPowerKw, source => source.MaxPowerKw, EAggregateFunction.Max) + .AddAggregateFunction(agg => agg.TotalPowerKw, source => source.TotalPowerKw, EAggregateFunction.Sum) + .AddAggregateFunction(agg => agg.ReadingCount, source => source.ReadingCount, EAggregateFunction.Sum) + .AddGroupByColumn(source => source.MeterId) + .WithRefreshPolicy(startOffset: "30 days", endOffset: "1 day", scheduleInterval: "1 hour"); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/PowerUsageHourlyConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/PowerUsageHourlyConfiguration.cs new file mode 100644 index 0000000..001bad2 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/PowerUsageHourlyConfiguration.cs @@ -0,0 +1,37 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + /// + /// Level 1 of the hierarchical continuous aggregate chain: an hourly rollup materialized + /// directly from the hypertable. + /// + public class PowerUsageHourlyConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + materializedViewName: "power_usage_hourly", + timeBucketWidth: "1 hour", + propertyExpression: source => source.Timestamp, + timeBucketGroupBy: true) + // The generated view aliases its bucket column to this property's mapped column + // name (hour_start under the snake_case convention) instead of the default + // "time_bucket", so the level 2 daily aggregate references it by that name. + .WithTimeBucketProperty(x => x.HourStart) + .AddAggregateFunction(agg => agg.MinPowerKw, source => source.PowerKw, EAggregateFunction.Min) + .AddAggregateFunction(agg => agg.MaxPowerKw, source => source.PowerKw, EAggregateFunction.Max) + .AddAggregateFunction(agg => agg.TotalPowerKw, source => source.PowerKw, EAggregateFunction.Sum) + .AddAggregateFunction(agg => agg.ReadingCount, source => source.Timestamp, EAggregateFunction.Count) + .AddGroupByColumn(source => source.MeterId) + .WithRefreshPolicy(startOffset: "3 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/PowerMeterReading.cs b/samples/Eftdb.Samples.Shared/Models/PowerMeterReading.cs new file mode 100644 index 0000000..f2a70dc --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/PowerMeterReading.cs @@ -0,0 +1,26 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + /// + /// Raw smart-meter measurement emitted by a power meter on the electrical grid. + /// This is the source hypertable at the base of the hierarchical continuous + /// aggregate rollup chain reading → hourly → daily. + /// + public class PowerMeterReading + { + /// + /// The precise UTC timestamp at which the meter emitted the sample. + /// + public DateTime Timestamp { get; set; } + + /// + /// Identifier of the physical meter that produced the reading. + /// Used as the grouping dimension across every level of the rollup chain. + /// + public string MeterId { get; set; } = string.Empty; + + /// + /// Instantaneous active power draw in kilowatts at . + /// + public double PowerKw { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/PowerUsageDaily.cs b/samples/Eftdb.Samples.Shared/Models/PowerUsageDaily.cs new file mode 100644 index 0000000..1ff4a02 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/PowerUsageDaily.cs @@ -0,0 +1,40 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + /// + /// Level 2 of the hierarchical rollup: daily power statistics materialized from the + /// continuous aggregate rather than from the raw + /// hypertable. + /// + public class PowerUsageDaily + { + /// + /// Start of the day-wide bucket this row summarizes. + /// + public DateTime DayStart { get; set; } + + /// + /// Identifier of the meter these daily statistics belong to. + /// + public string MeterId { get; set; } = string.Empty; + + /// + /// Minimum instantaneous power for the day (min of the hourly minima). + /// + public double MinPowerKw { get; set; } + + /// + /// Maximum instantaneous power for the day (max of the hourly maxima). + /// + public double MaxPowerKw { get; set; } + + /// + /// Total sampled power for the day (sum of the hourly sums). + /// + public double TotalPowerKw { get; set; } + + /// + /// Number of raw readings for the day (sum of the hourly reading counts). + /// + public long ReadingCount { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/PowerUsageHourly.cs b/samples/Eftdb.Samples.Shared/Models/PowerUsageHourly.cs new file mode 100644 index 0000000..ddedf85 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/PowerUsageHourly.cs @@ -0,0 +1,41 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + /// + /// Level 1 of the hierarchical rollup: hourly power statistics materialized + /// directly from the hypertable. + /// + public class PowerUsageHourly + { + /// + /// Start of the hour-wide bucket this row summarizes. + /// + public DateTime HourStart { get; set; } + + /// + /// Identifier of the meter these hourly statistics belong to. + /// + public string MeterId { get; set; } = string.Empty; + + /// + /// Minimum instantaneous power observed during the hour. + /// + public double MinPowerKw { get; set; } + + /// + /// Maximum instantaneous power observed during the hour. + /// + public double MaxPowerKw { get; set; } + + /// + /// Sum of the sampled power values during the hour. Rolling this column up with + /// Sum at the daily level is exact (a sum of sums). + /// + public double TotalPowerKw { get; set; } + + /// + /// Number of raw readings that fell into the hour. Rolling this column up with + /// Sum at the daily level yields the exact daily reading count. + /// + public long ReadingCount { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/TimescaleContext.cs b/samples/Eftdb.Samples.Shared/TimescaleContext.cs index 38f0b40..9e77155 100644 --- a/samples/Eftdb.Samples.Shared/TimescaleContext.cs +++ b/samples/Eftdb.Samples.Shared/TimescaleContext.cs @@ -23,6 +23,9 @@ public class TimescaleContext(DbContextOptions options) : DbCo public DbSet HourlySensorAggregates { get; set; } public DbSet StationReadings { get; set; } public DbSet HourlyStationAggregates { get; set; } + public DbSet PowerMeterReadings { get; set; } + public DbSet PowerUsageHourly { get; set; } + public DbSet PowerUsageDaily { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs index cd8cd6b..0f28d94 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRenderer.cs @@ -43,6 +43,7 @@ private static MethodInfo BuilderMethod(string name) => private static readonly MethodInfo WithCompressionMethod = BuilderMethod("WithCompression"); private static readonly MethodInfo WithCompressionSegmentByMethod = BuilderMethod("WithCompressionSegmentBy"); private static readonly MethodInfo WithCompressionOrderByMethod = BuilderMethod("WithCompressionOrderBy"); + private static readonly MethodInfo WithTimeBucketPropertyMethod = BuilderMethod("WithTimeBucketProperty"); public IReadOnlyList GenerateFluentApiCalls( IEntityType entityType, IDictionary annotations) @@ -83,6 +84,12 @@ public IReadOnlyList GenerateFluentApiCalls( MethodCallCodeFragment call = new(IsContinuousAggregateMethod, materializedViewName, parentNameArg, humanizedWidth, timeBucketArg); + string caEntityClrName = entityType.ShortName(); + if (TryResolveTimeBucketProperty(entityType, parsed.TimeBucketAlias, out string bucketProperty)) + { + call = call.Chain(WithTimeBucketPropertyMethod, new NameOfCodeFragment($"{caEntityClrName}.{bucketProperty}")); + } + if (materializedOnly) { call = call.Chain(MaterializedOnlyMethod, true); @@ -98,8 +105,6 @@ public IReadOnlyList GenerateFluentApiCalls( 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) @@ -248,6 +253,11 @@ public IReadOnlyList GenerateDataAnnotationAttributes( ToArgumentArray([.. SplitColumns(compressionOrderBy).Select(entry => OrderByReference(entityType, entry))]); } + if (TryResolveTimeBucketProperty(entityType, parsed.TimeBucketAlias, out _)) + { + return [new AttributeCodeFragment(typeof(ContinuousAggregateAttribute), [], caNamedArgs)]; + } + return [ new AttributeCodeFragment(typeof(ContinuousAggregateAttribute), [], caNamedArgs), new AttributeCodeFragment(typeof(TimeBucketAttribute), humanizedWidth, timeBucketArg), @@ -284,6 +294,7 @@ public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary + /// Resolves the view's bucket alias to the CLR property whose mapped column matches it, when the + /// alias differs from the default time_bucket. The default alias needs no designation, so it + /// yields false and undesignated aggregates keep rendering without a + /// WithTimeBucketProperty call. + /// + private static bool TryResolveTimeBucketProperty(IEntityType entityType, string? bucketAlias, out string propertyName) + { + propertyName = string.Empty; + if (string.IsNullOrWhiteSpace(bucketAlias) + || string.Equals(bucketAlias, DefaultValues.ContinuousAggregateTimeBucketColumnName, StringComparison.Ordinal)) + { + return false; + } + + return TryResolvePropertyName(entityType, bucketAlias, out propertyName); + } + private static object ResolveParentColumnArg(IEntityType? parentEntityType, string parentClrName, string columnName) => parentEntityType is not null && TryResolvePropertyName(parentEntityType, columnName, out string propName) ? new NameOfCodeFragment($"{parentClrName}.{propName}") @@ -357,6 +386,7 @@ private static void ConsumeAllCaAnnotations(IDictionary ann ContinuousAggregateAnnotations.TimeBucketWidth, ContinuousAggregateAnnotations.TimeBucketSourceColumn, ContinuousAggregateAnnotations.TimeBucketGroupBy, + ContinuousAggregateAnnotations.TimeBucketTargetProperty, ContinuousAggregateAnnotations.AggregateFunctions, ContinuousAggregateAnnotations.GroupByColumns, ContinuousAggregateAnnotations.WhereClause, diff --git a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs index 74dce3e..12218ca 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGenerator.cs @@ -41,7 +41,9 @@ public void Generate(CreateContinuousAggregateOperation operation, IndentedStrin if (!string.IsNullOrEmpty(operation.TimeBucketSourceColumn)) call.Arg("timeBucketSourceColumn", code.Literal(operation.TimeBucketSourceColumn)); - // timeBucketGroupBy defaults to true — only emit when explicitly disabled. + if (operation.TimeBucketColumnName != "time_bucket") + call.Arg("timeBucketColumnName", code.Literal(operation.TimeBucketColumnName)); + if (!operation.TimeBucketGroupBy) call.Arg("timeBucketGroupBy", code.Literal(false)); diff --git a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateScaffoldingExtractor.cs b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateScaffoldingExtractor.cs index 08502a8..433de67 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregate/ContinuousAggregateScaffoldingExtractor.cs @@ -92,6 +92,7 @@ LEFT JOIN timescaledb_information.dimensions dim } } + RewriteHierarchicalSources(continuousAggregates); GetCompressionConfiguration(connection, continuousAggregates); // Convert to object dictionary to match interface @@ -109,6 +110,28 @@ LEFT JOIN timescaledb_information.dimensions dim private readonly Dictionary<(string, string), (string ViewSchema, string ViewName)> _matHypertableToView = []; + /// + /// The catalog reports a hierarchical aggregate's source as the parent aggregate's internal + /// materialization hypertable (_timescaledb_internal._materialized_hypertable_N). + /// Rewrites such sources to the parent's user-facing view so the scaffolded model references + /// the parent continuous aggregate entity. + /// + private void RewriteHierarchicalSources(Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates) + { + foreach ((string, string) key in continuousAggregates.Keys.ToList()) + { + ContinuousAggregateInfo info = continuousAggregates[key]; + if (_matHypertableToView.TryGetValue((info.SourceSchema, info.SourceHypertableName), out (string ViewSchema, string ViewName) parentView)) + { + continuousAggregates[key] = info with + { + SourceHypertableName = parentView.ViewName, + SourceSchema = parentView.ViewSchema, + }; + } + } + } + private void GetCompressionConfiguration( DbConnection connection, Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates) diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index fa28240..7511a6b 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -180,6 +180,17 @@ private static IReadOnlyList GenerateContinuousAggregateP StoreObjectIdentifier caStoreId = StoreObjectIdentifier.View(viewName, viewSchema); string columnName = property.GetColumnName(caStoreId) ?? property.Name; + if (parsed.TimeBucketWidth is not null + && parsed.TimeBucketSourceColumn is not null + && parsed.TimeBucketAlias is not null + && parsed.TimeBucketAlias != DefaultValues.ContinuousAggregateTimeBucketColumnName + && parsed.TimeBucketAlias == columnName) + { + return [new AttributeCodeFragment(typeof(TimeBucketAttribute), + IntervalParsingHelper.NormalizeInterval(parsed.TimeBucketWidth), + ResolveSourceArgByColumnName(parsed.TimeBucketSourceColumn, parentEntityType))]; + } + ViewDefinitionParser.ParsedAggregate? agg = parsed.Aggregates.FirstOrDefault(a => a.Alias == columnName); if (agg is not null) { diff --git a/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs b/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs index 64b3e94..6fdd706 100644 --- a/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs +++ b/src/Eftdb.Design/Scaffolding/ViewDefinitionParser.cs @@ -21,6 +21,7 @@ internal sealed record ParsedAggregate( internal sealed record ParsedViewDefinition( string? TimeBucketWidth, string? TimeBucketSourceColumn, + string? TimeBucketAlias, IReadOnlyList Aggregates, IReadOnlyList GroupByColumns, string? WhereClause); @@ -35,6 +36,7 @@ public static ParsedViewDefinition Parse(string viewDefinition) => Cache.GetOrAdd(viewDefinition, static vd => new ParsedViewDefinition( ParseTimeBucketWidth(vd), ParseTimeBucketSourceColumn(vd), + ParseTimeBucketAlias(vd), ParseAggregates(vd), ParseGroupByColumns(vd), ParseWhereClause(vd))); @@ -60,6 +62,18 @@ public static ParsedViewDefinition Parse(string viewDefinition) return m.Success ? StripQuotes(m.Groups[1].Value) : null; } + /// + /// Extracts the alias the view assigns to the time_bucket(...) expression + /// (the AS <alias> that becomes the view's bucket column name). Table-alias + /// qualifiers and double-quote delimiters are stripped. Returns null when the + /// bucket expression carries no explicit alias. + /// + public static string? ParseTimeBucketAlias(string viewDefinition) + { + Match m = TimeBucketAliasRegex().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. @@ -243,6 +257,9 @@ private static List SplitTopLevel(string input) [GeneratedRegex(@"time_bucket\s*\([^,]+,\s*(?:(?:""[^""]+""|\w+)\.)*(""[^""]+""|\w+)\s*(?:::\w+(?:\s+\w+)*)?\s*[,)]", RegexOptions.IgnoreCase)] private static partial Regex TimeBucketSourceColumnRegex(); + [GeneratedRegex(@"time_bucket\s*\([^)]*\)\s+AS\s+(""[^""]+""|\w+)", RegexOptions.IgnoreCase)] + private static partial Regex TimeBucketAliasRegex(); + [GeneratedRegex(@"\b(avg|sum|min|max|count|first|last)\s*\((\*|[^)]*?)\)\s+AS\s+(""[^""]+""|\w+)", RegexOptions.IgnoreCase)] private static partial Regex AggregateRegex(); diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs index 30a32c0..3faeffc 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs @@ -16,6 +16,7 @@ public static class ContinuousAggregateAnnotations public const string TimeBucketWidth = "TimescaleDB:TimeBucket:BucketWidth"; public const string TimeBucketSourceColumn = "TimescaleDB:TimeBucket:SourceColumn"; public const string TimeBucketGroupBy = "TimescaleDB:TimeBucket:GroupBy"; + public const string TimeBucketTargetProperty = "TimescaleDB:TimeBucket:TargetProperty"; public const string AggregateFunctions = "TimescaleDB:AggregateFunctions"; public const string WhereClause = "TimescaleDB:WhereClause"; diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs index b184252..c5bbed2 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs @@ -101,6 +101,21 @@ public ContinuousAggregateBuilder AddGroupByColumn(strin return this; } + /// + /// Designates the property on the aggregate entity that represents the bucket column. + /// The view's bucket alias derives from that property's mapped column name, removing the + /// need to manually call .HasColumnName("time_bucket"). + /// + /// The type of the designated bucket property. + /// Expression selecting the bucket property on the continuous aggregate. + /// The builder for method chaining. + public ContinuousAggregateBuilder WithTimeBucketProperty( + Expression> propertyExpression) + { + ContinuousAggregateBuilderCore.WithTimeBucketProperty(EntityTypeBuilder, ExpressionHelper.GetPropertyName(propertyExpression)); + return this; + } + /// /// Adds a WHERE clause to filter data in the continuous aggregate. /// diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs index 69b066b..660fb4a 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs @@ -28,6 +28,14 @@ public static void WithChunkInterval(EntityTypeBuilder builder, string chunkInte public static void Where(EntityTypeBuilder builder, string whereClause) => builder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, whereClause); + /// + /// Designates the model property that represents the bucket column, so the view's + /// bucket alias derives from that property's mapped column name rather than the + /// hard-coded time_bucket. + /// + public static void WithTimeBucketProperty(EntityTypeBuilder builder, string propertyName) + => builder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketTargetProperty, propertyName); + /// /// Enables or disables columnstore (compression) on the continuous aggregate materialized view. /// Maps to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs index 4eb5663..8791a0d 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs @@ -84,6 +84,15 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde { groupByColumns.Add(groupByAttr.SourceColumn ?? property.Name); } + + TimeBucketAttribute? propertyTimeBucketAttr = propertyInfo.GetCustomAttribute(); + if (propertyTimeBucketAttr != null) + { + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, propertyTimeBucketAttr.BucketWidth); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, propertyTimeBucketAttr.SourceColumn); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy, propertyTimeBucketAttr.GroupBy); + entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketTargetProperty, property.Name); + } } // Apply the discovered property-level annotations diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs index 2020705..8cd12d8 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs @@ -89,6 +89,25 @@ public ContinuousAggregateStringBuilder AddGroupByColumn(string groupBy return this; } + /// + /// Designates the property on the aggregate entity that represents the bucket column. + /// The view's bucket alias derives from that property's mapped column name, removing the + /// need to manually call .HasColumnName("time_bucket"). + /// + /// The name of the bucket property on the continuous aggregate. + /// The builder for method chaining. + /// Thrown when is null or whitespace. + public ContinuousAggregateStringBuilder WithTimeBucketProperty(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name must not be null or whitespace.", nameof(propertyName)); + } + + ContinuousAggregateBuilderCore.WithTimeBucketProperty(_builder, propertyName); + return this; + } + /// /// Adds a WHERE clause to filter data in the continuous aggregate. /// diff --git a/src/Eftdb/Configuration/ContinuousAggregate/TimeBucketAttribute.cs b/src/Eftdb/Configuration/ContinuousAggregate/TimeBucketAttribute.cs index 24b5e4b..9b35f49 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/TimeBucketAttribute.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/TimeBucketAttribute.cs @@ -4,11 +4,14 @@ /// Define the time bucket column for a continuous aggregate. /// /// - /// Initializes a new instance of the class. + /// Placed on the class, this attribute only configures the bucket width, source column, and GROUP BY behavior. + /// Placed on a property, it additionally designates that property as the bucket column target, so the view's + /// bucket alias derives from the property's mapped column name instead of the hard-coded time_bucket. + /// A property-level attribute takes precedence over a class-level one. /// /// The time interval for the bucket (e.g., "1 hour", "15 minutes"). /// The name of the time column in the source hypertable. - [AttributeUsage(AttributeTargets.Class)] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] public class TimeBucketAttribute(string bucketWidth, string sourceColumn) : Attribute { /// @@ -22,7 +25,7 @@ public class TimeBucketAttribute(string bucketWidth, string sourceColumn) : Attr public string SourceColumn { get; } = sourceColumn; /// - /// Weither the time bucket column should be included in the GROUP BY clause. + /// Whether the time bucket column should be included in the GROUP BY clause. /// public bool GroupBy { get; set; } = true; } diff --git a/src/Eftdb/Configuration/ContinuousAggregateViewColumnValidationConvention.cs b/src/Eftdb/Configuration/ContinuousAggregateViewColumnValidationConvention.cs new file mode 100644 index 0000000..f48fc6d --- /dev/null +++ b/src/Eftdb/Configuration/ContinuousAggregateViewColumnValidationConvention.cs @@ -0,0 +1,177 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration +{ + /// + /// Guards against colliding output column names in a structured continuous aggregate view. + /// The bucket column, group-by columns and aggregate aliases share the view's projection, so + /// two of them resolving to the same database column would produce ambiguous SQL. Validation + /// runs at model finalization against resolved store column names. + /// + internal class ContinuousAggregateViewColumnValidationConvention : IModelFinalizedConvention + { + /// + /// Called once the model has been finalized and relational type mappings are resolved. + /// + /// The finalized model. + /// The unchanged model. + public IModel ProcessModelFinalized(IModel model) + { + foreach (IEntityType entityType in model.GetEntityTypes()) + { + ValidateViewColumns(model, entityType); + } + + return model; + } + + private static void ValidateViewColumns(IModel model, IEntityType entityType) + { + string? materializedViewName = entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value as string; + if (string.IsNullOrWhiteSpace(materializedViewName)) + { + return; + } + + // The raw view-definition path does not use the structured projection fields. + string? viewDefinition = entityType.FindAnnotation(ContinuousAggregateAnnotations.ViewDefinition)?.Value as string; + if (!string.IsNullOrWhiteSpace(viewDefinition)) + { + return; + } + + StoreObjectIdentifier? aggregateStoreIdentifier = GetStoreObjectIdentifier(entityType); + if (aggregateStoreIdentifier == null) + { + return; + } + + string bucketColumnName = ResolveBucketColumnName(entityType, aggregateStoreIdentifier.Value, materializedViewName); + + IEntityType? parentEntityType = ResolveParent(model, entityType); + StoreObjectIdentifier? parentStoreIdentifier = parentEntityType == null ? null : GetStoreObjectIdentifier(parentEntityType); + + List outputColumns = [bucketColumnName]; + outputColumns.AddRange(ResolveGroupByColumns(entityType, parentEntityType, parentStoreIdentifier)); + outputColumns.AddRange(ResolveAggregateAliasColumns(entityType, aggregateStoreIdentifier.Value)); + + HashSet seen = []; + foreach (string column in outputColumns) + { + if (!seen.Add(column)) + { + throw new InvalidOperationException( + $"The continuous aggregate '{DisplayName(entityType)}' (materialized view '{materializedViewName}') " + + $"produces the output column '{column}' more than once. Rename the conflicting property or use " + + $"WithTimeBucketProperty to map the bucket column to a distinct property."); + } + } + } + + /// + /// Resolves the bucket output column from the designated target property, falling back to the + /// function-name-derived default. Throws when the annotation names a property that does not exist. + /// + private static string ResolveBucketColumnName(IEntityType entityType, StoreObjectIdentifier aggregateStoreIdentifier, string materializedViewName) + { + string? targetPropertyName = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketTargetProperty)?.Value as string; + if (string.IsNullOrWhiteSpace(targetPropertyName)) + { + return DefaultValues.ContinuousAggregateTimeBucketColumnName; + } + + IProperty? property = ColumnNameResolver.ResolveProperty(entityType, targetPropertyName, aggregateStoreIdentifier); + if (property == null) + { + throw new InvalidOperationException( + $"The continuous aggregate '{DisplayName(entityType)}' (materialized view '{materializedViewName}') " + + $"designates '{targetPropertyName}' as its time-bucket property, but no such property exists on the entity."); + } + + string? columnName = property.GetColumnName(aggregateStoreIdentifier); + return string.IsNullOrWhiteSpace(columnName) + ? DefaultValues.ContinuousAggregateTimeBucketColumnName + : columnName; + } + + /// + /// Resolves the group-by output columns against the parent entity, skipping raw SQL expressions + /// (entries containing a comma, parenthesis, or space) that are not plain columns. + /// + private static IEnumerable ResolveGroupByColumns(IEntityType entityType, IEntityType? parentEntityType, StoreObjectIdentifier? parentStoreIdentifier) + { + if (entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns)?.Value is not List modelGroupByColumns) + { + yield break; + } + + foreach (string modelColumn in modelGroupByColumns) + { + bool isRawSqlExpression = modelColumn.Contains(',') || modelColumn.Contains('(') || modelColumn.Contains(' '); + if (isRawSqlExpression) + { + continue; + } + + string? dbColumnName = parentEntityType == null || parentStoreIdentifier == null + ? null + : ColumnNameResolver.Resolve(parentEntityType, modelColumn, parentStoreIdentifier.Value); + yield return string.IsNullOrWhiteSpace(dbColumnName) ? modelColumn : dbColumnName; + } + } + + /// + /// Resolves the aggregate alias output columns against the aggregate entity. The annotation stores + /// entries in "alias:function:source" form; only the alias participates in the view projection. + /// + private static IEnumerable ResolveAggregateAliasColumns(IEntityType entityType, StoreObjectIdentifier aggregateStoreIdentifier) + { + if (entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions)?.Value is not List modelAggregateFunctions) + { + yield break; + } + + foreach (string aggInfo in modelAggregateFunctions) + { + string[] parts = aggInfo.Split(':'); + if (parts.Length != 3) + { + continue; + } + + string aliasModelName = parts[0]; + string? aliasDbName = ColumnNameResolver.Resolve(entityType, aliasModelName, aggregateStoreIdentifier); + yield return string.IsNullOrWhiteSpace(aliasDbName) ? aliasModelName : aliasDbName; + } + } + + private static IEntityType? ResolveParent(IModel model, IEntityType entityType) + { + string? parentName = entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value as string; + return string.IsNullOrWhiteSpace(parentName) ? null : ParentEntityTypeResolver.Resolve(model, parentName); + } + + private static StoreObjectIdentifier? GetStoreObjectIdentifier(IEntityType entityType) + { + string? tableName = entityType.GetTableName(); + if (!string.IsNullOrWhiteSpace(tableName)) + { + return StoreObjectIdentifier.Table(tableName, entityType.GetSchema()); + } + + string? viewName = entityType.GetViewName(); + if (!string.IsNullOrWhiteSpace(viewName)) + { + return StoreObjectIdentifier.View(viewName, entityType.GetViewSchema() ?? entityType.GetSchema()); + } + + return null; + } + + private static string DisplayName(IEntityType entityType) => entityType.ClrType?.Name ?? entityType.Name; + } +} diff --git a/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs b/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs index e3411f1..c9e0609 100644 --- a/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs +++ b/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs @@ -82,11 +82,7 @@ private static void ValidateContinuousAggregateTimeColumn(IModel model, IEntityT return; } - IEntityType? parentEntityType = model.GetEntityTypes() - .FirstOrDefault(e => - e.ClrType?.Name == parentName - || e.ShortName() == parentName - || e.GetTableName() == parentName); + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(model, parentName); if (parentEntityType == null) { return; diff --git a/src/Eftdb/DefaultValues.cs b/src/Eftdb/DefaultValues.cs index c6c40d6..e0e53e6 100644 --- a/src/Eftdb/DefaultValues.cs +++ b/src/Eftdb/DefaultValues.cs @@ -6,6 +6,8 @@ public static class DefaultValues { public const string DefaultSchema = "public"; + public const string ContinuousAggregateTimeBucketColumnName = "time_bucket"; + public const string ChunkTimeInterval = "7 days"; public const long ChunkTimeIntervalLong = 604_800_000_000L; public const string ReorderPolicyScheduleInterval = "1 day"; diff --git a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index 60f3989..30e79cc 100644 --- a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -88,7 +88,8 @@ private static List GenerateFromStructuredQuery( // Add time_bucket column string timeBucketColumn = $"{SqlBuilderHelper.QuoteIdentifier(operation.TimeBucketSourceColumn)}"; string timeBucketWidthSql = $"'{SqlBuilderHelper.EscapeStringLiteral(operation.TimeBucketWidth)}'"; - selectList.Add($"time_bucket({timeBucketWidthSql}, {timeBucketColumn}) AS time_bucket"); + string timeBucketAlias = SqlBuilderHelper.QuoteIdentifier(operation.TimeBucketColumnName); + selectList.Add($"time_bucket({timeBucketWidthSql}, {timeBucketColumn}) AS {timeBucketAlias}"); // Add GROUP BY columns to SELECT (only actual columns, not SQL expressions) foreach (string groupByColumn in operation.GroupByColumns) @@ -137,7 +138,7 @@ private static List GenerateFromStructuredQuery( List groupByList = []; if (operation.TimeBucketGroupBy) { - groupByList.Add("time_bucket"); + groupByList.Add("1"); } foreach (string groupByColumn in operation.GroupByColumns) diff --git a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs index 9d23df3..10f3c66 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs @@ -41,15 +41,7 @@ public static IEnumerable GetContinuousAg // Get the parent (source) entity to determine the schema string? parentModelName = entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)?.Value as string; - IEntityType? parentEntityType = null; - if (!string.IsNullOrWhiteSpace(parentModelName)) - { - parentEntityType = relationalModel.Model.GetEntityTypes() - .FirstOrDefault(e => - e.ClrType?.Name == parentModelName - || e.ShortName() == parentModelName - || e.GetTableName() == parentModelName); - } + IEntityType? parentEntityType = ParentEntityTypeResolver.Resolve(relationalModel.Model, parentModelName); string schema = entityType.GetViewSchema() ?? entityType.GetSchema() diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index 0e37ef7..40a7efe 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -22,53 +22,83 @@ public IReadOnlyList GetDifferences(IRelationalModel? source aggregate.CompressionOrderBy = CompressionDiffHelper.RewriteOrderByColumns(aggregate.CompressionOrderBy, aggregate.Schema, aggregate.MaterializedViewName, context); } + List drops = []; + List creates = []; + HashSet droppedNames = []; + // Find new continuous aggregates - only compare by MaterializedViewName, not Schema - IEnumerable newAggregates = targetAggregates - .Where(t => !sourceAggregates.Any(s => s.MaterializedViewName == t.MaterializedViewName)); - operations.AddRange(newAggregates); - - // Find updated continuous aggregates - // Note: Only certain properties can be altered (ChunkInterval, CreateGroupIndexes, - // MaterializedOnly, and compression settings). - // For structural changes (time bucket, aggregates, group by, where), drop and recreate is required. - var updatedAggregates = targetAggregates - .Join( - sourceAggregates, - target => (target.Schema, target.MaterializedViewName), - source => (source.Schema, source.MaterializedViewName), - (target, source) => new { Target = target, Source = source } - ) - .Where(x => - x.Target.ChunkInterval != x.Source.ChunkInterval || - x.Target.CreateGroupIndexes != x.Source.CreateGroupIndexes || - x.Target.MaterializedOnly != x.Source.MaterializedOnly || - x.Target.EnableCompression != x.Source.EnableCompression || - !CompressionDiffHelper.AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || - !CompressionDiffHelper.AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) - ); + creates.AddRange(targetAggregates + .Where(t => !sourceAggregates.Any(s => s.MaterializedViewName == t.MaterializedViewName))); - foreach (var aggregate in updatedAggregates) + // Find structural changes that require drop and recreate + FindStructuralChanges(sourceAggregates, targetAggregates, drops, creates, droppedNames); + + // Find removed continuous aggregates + foreach (CreateContinuousAggregateOperation aggregate in sourceAggregates + .Where(s => !targetAggregates.Any(t => t.MaterializedViewName == s.MaterializedViewName))) { - operations.Add(new AlterContinuousAggregateOperation + droppedNames.Add(aggregate.MaterializedViewName); + drops.Add(new DropContinuousAggregateOperation { - Schema = aggregate.Target.Schema, - MaterializedViewName = aggregate.Target.MaterializedViewName, - ChunkInterval = aggregate.Target.ChunkInterval, - CreateGroupIndexes = aggregate.Target.CreateGroupIndexes, - MaterializedOnly = aggregate.Target.MaterializedOnly, - EnableCompression = aggregate.Target.EnableCompression, - CompressionSegmentBy = aggregate.Target.CompressionSegmentBy, - CompressionOrderBy = aggregate.Target.CompressionOrderBy, - OldChunkInterval = aggregate.Source.ChunkInterval, - OldCreateGroupIndexes = aggregate.Source.CreateGroupIndexes, - OldMaterializedOnly = aggregate.Source.MaterializedOnly, - OldEnableCompression = aggregate.Source.EnableCompression, - OldCompressionSegmentBy = aggregate.Source.CompressionSegmentBy, - OldCompressionOrderBy = aggregate.Source.CompressionOrderBy, + Schema = aggregate.Schema, + MaterializedViewName = aggregate.MaterializedViewName }); } - // Find structural changes that require drop and recreate + // A materialized view cannot be dropped while hierarchical aggregates depend on it, so + // descendants of any dropped aggregate are dropped and recreated as well. Source aggregates + // arrive parent-first from the extractor, so one forward pass propagates transitively. + foreach (CreateContinuousAggregateOperation sourceAggregate in sourceAggregates) + { + if (droppedNames.Contains(sourceAggregate.MaterializedViewName) || !droppedNames.Contains(sourceAggregate.ParentName)) + { + continue; + } + + droppedNames.Add(sourceAggregate.MaterializedViewName); + drops.Add(new DropContinuousAggregateOperation + { + Schema = sourceAggregate.Schema, + MaterializedViewName = sourceAggregate.MaterializedViewName + }); + + CreateContinuousAggregateOperation? recreateTarget = targetAggregates + .FirstOrDefault(t => t.MaterializedViewName == sourceAggregate.MaterializedViewName); + if (recreateTarget != null) + { + creates.Add(recreateTarget); + } + } + + // Find updated continuous aggregates; recreated aggregates already carry their new settings + FindAlterableChanges(sourceAggregates, targetAggregates, droppedNames, operations); + + // The extractor emits aggregates parent-first (topologically sorted), and the model + // differ's priority sort is stable, so emission order decides execution order: drops in + // reverse topological order (children before their parents), creates in forward order + // (parents before their children). + Dictionary sourceOrder = TopologicalIndexByViewName(sourceAggregates); + Dictionary targetOrder = TopologicalIndexByViewName(targetAggregates); + operations.AddRange(drops.OrderByDescending(d => sourceOrder.GetValueOrDefault(d.MaterializedViewName))); + operations.AddRange(creates.OrderBy(c => targetOrder.GetValueOrDefault(c.MaterializedViewName))); + + return operations; + } + + /// + /// Find structural changes that require drop and recreate + /// Note: Only certain properties can be altered (ChunkInterval, CreateGroupIndexes, + /// MaterializedOnly, and compression settings). + /// For structural changes (time bucket, aggregates, group by, where), drop and recreate is required. + /// + private static void FindStructuralChanges( + List sourceAggregates, + List targetAggregates, + List drops, + List creates, + HashSet droppedNames) + { + var structurallyChangedAggregates = targetAggregates .Join( sourceAggregates, @@ -80,6 +110,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source x.Target.ParentName != x.Source.ParentName || x.Target.TimeBucketWidth != x.Source.TimeBucketWidth || x.Target.TimeBucketSourceColumn != x.Source.TimeBucketSourceColumn || + x.Target.TimeBucketColumnName != x.Source.TimeBucketColumnName || x.Target.TimeBucketGroupBy != x.Source.TimeBucketGroupBy || x.Target.WithNoData != x.Source.WithNoData || !AreAggregateFunctionsEqual(x.Target.AggregateFunctions, x.Source.AggregateFunctions) || @@ -90,26 +121,84 @@ public IReadOnlyList GetDifferences(IRelationalModel? source foreach (var aggregate in structurallyChangedAggregates) { - operations.Add(new DropContinuousAggregateOperation + droppedNames.Add(aggregate.Source.MaterializedViewName); + drops.Add(new DropContinuousAggregateOperation { Schema = aggregate.Source.Schema, MaterializedViewName = aggregate.Source.MaterializedViewName }); - operations.Add(aggregate.Target); + creates.Add(aggregate.Target); } + } - // Find removed continuous aggregates - IEnumerable removedAggregates = sourceAggregates - .Where(s => !targetAggregates.Any(t => t.MaterializedViewName == s.MaterializedViewName)) - .Select(s => new DropContinuousAggregateOperation + /// + /// Find changes limited to properties that can be applied in place (ChunkInterval, + /// CreateGroupIndexes, MaterializedOnly, and compression settings) and emit alter operations. + /// Aggregates already marked for drop and recreate are skipped; their recreated definition + /// carries the new settings. + /// + private static void FindAlterableChanges( + List sourceAggregates, + List targetAggregates, + HashSet droppedNames, + List operations) + { + var updatedAggregates = targetAggregates + .Join( + sourceAggregates, + target => (target.Schema, target.MaterializedViewName), + source => (source.Schema, source.MaterializedViewName), + (target, source) => new { Target = target, Source = source } + ) + .Where(x => + !droppedNames.Contains(x.Target.MaterializedViewName) && + ( + x.Target.ChunkInterval != x.Source.ChunkInterval || + x.Target.CreateGroupIndexes != x.Source.CreateGroupIndexes || + x.Target.MaterializedOnly != x.Source.MaterializedOnly || + x.Target.EnableCompression != x.Source.EnableCompression || + !CompressionDiffHelper.AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || + !CompressionDiffHelper.AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) + ) + ); + + foreach (var aggregate in updatedAggregates) + { + operations.Add(new AlterContinuousAggregateOperation { - Schema = s.Schema, - MaterializedViewName = s.MaterializedViewName + Schema = aggregate.Target.Schema, + MaterializedViewName = aggregate.Target.MaterializedViewName, + ChunkInterval = aggregate.Target.ChunkInterval, + CreateGroupIndexes = aggregate.Target.CreateGroupIndexes, + MaterializedOnly = aggregate.Target.MaterializedOnly, + EnableCompression = aggregate.Target.EnableCompression, + CompressionSegmentBy = aggregate.Target.CompressionSegmentBy, + CompressionOrderBy = aggregate.Target.CompressionOrderBy, + OldChunkInterval = aggregate.Source.ChunkInterval, + OldCreateGroupIndexes = aggregate.Source.CreateGroupIndexes, + OldMaterializedOnly = aggregate.Source.MaterializedOnly, + OldEnableCompression = aggregate.Source.EnableCompression, + OldCompressionSegmentBy = aggregate.Source.CompressionSegmentBy, + OldCompressionOrderBy = aggregate.Source.CompressionOrderBy, }); - operations.AddRange(removedAggregates); + } + } - return operations; + /// + /// Captures each aggregate's position in the extractor's parent-first topological order + /// (see ) as a view-name lookup, so drops and + /// creates can be sorted against it. + /// + private static Dictionary TopologicalIndexByViewName(List aggregates) + { + Dictionary order = []; + for (int i = 0; i < aggregates.Count; i++) + { + order.TryAdd(aggregates[i].MaterializedViewName, i); + } + + return order; } private static bool AreAggregateFunctionsEqual(IReadOnlyList? list1, IReadOnlyList? list2) diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index 8630e2d..7dcfaca 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -10,6 +10,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Continuous internal class ContinuousAggregateModelExtractor { public static IEnumerable GetContinuousAggregates(IRelationalModel? relationalModel) + => SortParentsFirst([.. ExtractContinuousAggregates(relationalModel)]); + + private static IEnumerable ExtractContinuousAggregates(IRelationalModel? relationalModel) { if (relationalModel == null) { @@ -39,8 +42,11 @@ public static IEnumerable GetContinuousAggre continue; } + // A parent mapped to a view is itself a continuous aggregate (hierarchical aggregation), + // so the relational name and store identifier must come from the view mapping. string? parentTableName = parentEntityType.GetTableName(); - if (string.IsNullOrWhiteSpace(parentTableName)) + string? parentRelationalName = parentTableName ?? parentEntityType.GetViewName(); + if (string.IsNullOrWhiteSpace(parentRelationalName)) { continue; } @@ -52,13 +58,16 @@ public static IEnumerable GetContinuousAggre // Get time bucket configuration string? timeBucketWidth = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value as string; string? timeBucketSourceColumnModelName = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value as string; + string? timeBucketTargetPropertyName = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketTargetProperty)?.Value as string; if (!useRawDefinition && (string.IsNullOrWhiteSpace(timeBucketWidth) || string.IsNullOrWhiteSpace(timeBucketSourceColumnModelName))) { continue; } - // Get convention-aware store identifier for the parent table - StoreObjectIdentifier parentStoreIdentifier = StoreObjectIdentifier.Table(parentTableName, parentEntityType.GetSchema()); + // Get convention-aware store identifier for the parent table or view + StoreObjectIdentifier parentStoreIdentifier = parentTableName != null + ? StoreObjectIdentifier.Table(parentTableName, parentEntityType.GetSchema()) + : StoreObjectIdentifier.View(parentRelationalName, parentEntityType.GetViewSchema() ?? parentEntityType.GetSchema()); string? viewName = entityType.GetViewName() ?? materializedViewName; StoreObjectIdentifier aggregateStoreIdentifier = StoreObjectIdentifier.View(viewName, entityType.GetViewSchema() ?? entityType.GetSchema()); @@ -73,6 +82,17 @@ public static IEnumerable GetContinuousAggre continue; } + // Resolve the bucket output column alias from the designated target property. + string timeBucketColumnName = DefaultValues.ContinuousAggregateTimeBucketColumnName; + if (!useRawDefinition && !string.IsNullOrWhiteSpace(timeBucketTargetPropertyName)) + { + string? resolvedBucketColumn = ColumnNameResolver.Resolve(entityType, timeBucketTargetPropertyName, aggregateStoreIdentifier); + if (!string.IsNullOrWhiteSpace(resolvedBucketColumn)) + { + timeBucketColumnName = resolvedBucketColumn; + } + } + // Get optional configuration bool timeBucketGroupBy = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy)?.Value as bool? ?? true; string? chunkInterval = entityType.FindAnnotation(ContinuousAggregateAnnotations.ChunkInterval)?.Value as string; @@ -88,6 +108,7 @@ public static IEnumerable GetContinuousAggre // or by the scaffolder), fall back to the parent's schema, finally default. string schema = entityType.GetViewSchema() ?? entityType.GetSchema() + ?? parentEntityType.GetViewSchema() ?? parentEntityType.GetSchema() ?? DefaultValues.DefaultSchema; @@ -99,13 +120,14 @@ public static IEnumerable GetContinuousAggre { Schema = schema, MaterializedViewName = materializedViewName, - ParentName = parentTableName, + ParentName = parentRelationalName, ChunkInterval = chunkInterval, WithNoData = withNoData, CreateGroupIndexes = createGroupIndexes, MaterializedOnly = materializedOnly, TimeBucketWidth = timeBucketWidth ?? string.Empty, TimeBucketSourceColumn = timeBucketSourceColumn ?? string.Empty, + TimeBucketColumnName = timeBucketColumnName, TimeBucketGroupBy = timeBucketGroupBy, AggregateFunctions = aggregateFunctions, GroupByColumns = groupByColumns, @@ -166,6 +188,45 @@ private static List ResolveAggregateFunctions( return aggregateFunctions; } + /// + /// Orders operations so that a continuous aggregate precedes any aggregate built on top of it + /// (hierarchical aggregation). Aggregates are matched by MaterializedViewName, mirroring + /// how the differ pairs source and target aggregates. + /// + private static List SortParentsFirst(List operations) + { + Dictionary byViewName = []; + foreach (CreateContinuousAggregateOperation operation in operations) + { + byViewName.TryAdd(operation.MaterializedViewName, operation); + } + + List sorted = []; + HashSet visited = []; + + void Visit(CreateContinuousAggregateOperation operation) + { + if (!visited.Add(operation.MaterializedViewName)) + { + return; + } + + if (byViewName.TryGetValue(operation.ParentName, out CreateContinuousAggregateOperation? parent)) + { + Visit(parent); + } + + sorted.Add(operation); + } + + foreach (CreateContinuousAggregateOperation operation in operations) + { + Visit(operation); + } + + return sorted; + } + private static List ResolveGroupByColumns( IEntityType entityType, IEntityType parentEntityType, diff --git a/src/Eftdb/Internals/ParentEntityTypeResolver.cs b/src/Eftdb/Internals/ParentEntityTypeResolver.cs index 4a9e83f..1272f6c 100644 --- a/src/Eftdb/Internals/ParentEntityTypeResolver.cs +++ b/src/Eftdb/Internals/ParentEntityTypeResolver.cs @@ -5,8 +5,9 @@ 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. + /// The value may hold the CLR class name (code-first), the EF Core short name, the database table + /// name (scaffolding), or the view name (hierarchical aggregates whose parent is itself a + /// continuous aggregate). /// internal static class ParentEntityTypeResolver { @@ -16,6 +17,7 @@ internal static class ParentEntityTypeResolver : model.GetEntityTypes().FirstOrDefault(e => e.ClrType?.Name == parentName || e.ShortName() == parentName - || e.GetTableName() == parentName); + || e.GetTableName() == parentName + || e.GetViewName() == parentName); } } diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs index 1a29e5b..f91cb49 100644 --- a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs +++ b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs @@ -17,6 +17,7 @@ public static OperationBuilder CreateContinu bool materializedOnly = false, string? timeBucketWidth = null, string? timeBucketSourceColumn = null, + string timeBucketColumnName = "time_bucket", bool timeBucketGroupBy = true, IReadOnlyList? aggregateFunctions = null, IReadOnlyList? groupByColumns = null, @@ -37,6 +38,7 @@ public static OperationBuilder CreateContinu MaterializedOnly = materializedOnly, TimeBucketWidth = timeBucketWidth ?? string.Empty, TimeBucketSourceColumn = timeBucketSourceColumn ?? string.Empty, + TimeBucketColumnName = timeBucketColumnName, TimeBucketGroupBy = timeBucketGroupBy, AggregateFunctions = aggregateFunctions is null ? [] : [.. aggregateFunctions.Select(f => f.ToAnnotationValue())], GroupByColumns = groupByColumns ?? [], diff --git a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs index 5d7d0e4..2338d50 100644 --- a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs @@ -16,6 +16,7 @@ public class CreateContinuousAggregateOperation : MigrationOperation public string TimeBucketWidth { get; set; } = string.Empty; public string TimeBucketSourceColumn { get; set; } = string.Empty; public bool TimeBucketGroupBy { get; set; } + public string TimeBucketColumnName { get; set; } = DefaultValues.ContinuousAggregateTimeBucketColumnName; public IReadOnlyList AggregateFunctions { get; set; } = []; public IReadOnlyList GroupByColumns { get; set; } = []; diff --git a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs index 8b7db9f..b45bf6c 100644 --- a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs +++ b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs @@ -132,6 +132,7 @@ public ConventionSet ModifyConventions(ConventionSet conventionSet) conventionSet.EntityTypeAddedConventions.Add(new RetentionPolicyConvention()); conventionSet.EntityTypeAddedConventions.Add(new CompressionPolicyConvention()); conventionSet.ModelFinalizedConventions.Add(new TimeColumnStoreTypeValidationConvention()); + conventionSet.ModelFinalizedConventions.Add(new ContinuousAggregateViewColumnValidationConvention()); conventionSet.ModelFinalizedConventions.Add(new CompressionPolicyPrerequisiteValidationConvention()); conventionSet.ModelFinalizedConventions.Add(new SparseIndexValidationConvention()); return conventionSet; diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregateViewColumnValidationConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregateViewColumnValidationConventionTests.cs new file mode 100644 index 0000000..0be0d33 --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregateViewColumnValidationConventionTests.cs @@ -0,0 +1,382 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +/// +/// Tests that verify ContinuousAggregateViewColumnValidationConvention rejects colliding view output +/// column names and invalid time-bucket property designations during model finalization. +/// +public class ContinuousAggregateViewColumnValidationConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Not_Throw_For_Clean_Model + + private class CleanRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CleanAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CleanContext : 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("clean_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "clean_hourly", + "1 hour", + x => x.Timestamp + ).WithTimeBucketProperty(x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Not_Throw_For_Clean_Model() + { + using CleanContext context = new(); + + IModel model = GetModel(context); + + Assert.NotNull(model.FindEntityType(typeof(CleanAggregate))); + } + + #endregion + + #region Should_Throw_When_Bucket_Collides_With_AggregateAlias + + private class BucketAliasRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BucketAliasAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketAliasContext : 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("bucket_alias_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.AvgValue).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "bucket_alias_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Throw_When_Bucket_Collides_With_AggregateAlias() + { + InvalidOperationException exception = Assert.Throws(() => + { + using BucketAliasContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("BucketAliasAggregate", exception.Message); + Assert.Contains("bucket_alias_hourly", exception.Message); + Assert.Contains("time_bucket", exception.Message); + } + + #endregion + + #region Should_Throw_When_Bucket_Collides_With_GroupByColumn + + private class BucketGroupByRaw + { + public DateTime Timestamp { get; set; } + public string TimeBucket { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class BucketGroupByAggregate + { + public DateTime Bucket { get; set; } + public string TimeBucket { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + private class BucketGroupByContext : 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("bucket_group_by_raw"); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "bucket_group_by_hourly", + "1 hour", + x => x.Timestamp + ).AddGroupByColumn(x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Throw_When_Bucket_Collides_With_GroupByColumn() + { + InvalidOperationException exception = Assert.Throws(() => + { + using BucketGroupByContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("bucket_group_by_hourly", exception.Message); + Assert.Contains("time_bucket", exception.Message); + } + + #endregion + + #region Should_Throw_When_Designated_BucketProperty_Does_Not_Exist + + private class MissingPropertyRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MissingPropertyAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class MissingPropertyContext : 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("missing_property_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "missing_property_hourly", + "Metrics", + "1 hour", + "Timestamp" + ).WithTimeBucketProperty("NoSuchProperty") + .AddAggregateFunction("avg_value", "Value", EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Throw_When_Designated_BucketProperty_Does_Not_Exist() + { + InvalidOperationException exception = Assert.Throws(() => + { + using MissingPropertyContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("missing_property_hourly", exception.Message); + Assert.Contains("NoSuchProperty", exception.Message); + } + + #endregion + + #region Should_Not_Throw_For_RawViewDefinition_Even_With_Colliding_Columns + + private class RawDefinitionRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RawDefinitionAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class RawDefinitionContext : 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("raw_definition_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.AvgValue).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "raw_definition_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + entity.HasAnnotation( + ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('1 hour', \"timestamp\") AS time_bucket, AVG(\"value\") AS time_bucket FROM \"raw_definition_raw\" GROUP BY 1"); + }); + } + } + + [Fact] + public void Should_Not_Throw_For_RawViewDefinition_Even_With_Colliding_Columns() + { + using RawDefinitionContext context = new(); + + IModel model = GetModel(context); + + Assert.NotNull(model.FindEntityType(typeof(RawDefinitionAggregate))); + } + + #endregion + + #region Should_Not_Throw_When_Aggregate_Has_No_Store_Object + + private class NoStoreRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoStoreAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoStoreContext : 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("no_store_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.AvgValue).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "no_store_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + entity.ToView(null); + entity.ToTable((string?)null); + }); + } + } + + [Fact] + public void Should_Not_Throw_When_Aggregate_Has_No_Store_Object() + { + using NoStoreContext context = new(); + + IModel model = GetModel(context); + + Assert.NotNull(model.FindEntityType(typeof(NoStoreAggregate))); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRendererTests.cs index a6f81e5..399a462 100644 --- a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateAnnotationRendererTests.cs @@ -3423,4 +3423,496 @@ public void GenerateDataAnnotationAttributes_CompressionSegmentBy_AllUnresolvabl } #endregion + + // ── Time bucket property designation (custom bucket alias) ───────────────── + + private class BucketAliasSourceEntity + { + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } + } + + private class BucketAliasCaEntity + { + public DateTime HourStart { get; set; } + public double AvgPowerKw { get; set; } + } + + private class BucketAliasContext : 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.Timestamp); + e.ToTable("power_meter_readings"); + e.Property(x => x.Timestamp).HasColumnName("timestamp"); + e.Property(x => x.PowerKw).HasColumnName("power_kw"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("power_usage_hourly"); + e.Property(x => x.HourStart).HasColumnName("hour_start"); + e.Property(x => x.AvgPowerKw).HasColumnName("avg_power_kw"); + }); + } + } + + private const string CustomBucketAliasViewDef = + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\") AS hour_start," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"; + + private const string DefaultBucketAliasViewDef = + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\") AS time_bucket," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"; + + #region GenerateFluentApiCalls_CustomBucketAlias_ChainsWithTimeBucketProperty + + [Fact] + public void GenerateFluentApiCalls_CustomBucketAlias_ChainsWithTimeBucketProperty() + { + // Arrange + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, CustomBucketAliasViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + MethodCallCodeFragment bucketCall = Assert.Single( + EnumerateChain(root), f => f.Method == "WithTimeBucketProperty"); + NameOfCodeFragment nameOf = Assert.IsType(Assert.Single(bucketCall.Arguments)); + Assert.Equal($"{nameof(BucketAliasCaEntity)}.{nameof(BucketAliasCaEntity.HourStart)}", nameOf.PropertyName); + } + + #endregion + + #region GenerateFluentApiCalls_CustomBucketAlias_WithTimeBucketProperty_FollowsIsContinuousAggregate + + [Fact] + public void GenerateFluentApiCalls_CustomBucketAlias_WithTimeBucketProperty_FollowsIsContinuousAggregate() + { + // Arrange + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, CustomBucketAliasViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Equal(nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate), chain[0]); + Assert.Equal("WithTimeBucketProperty", chain[1]); + } + + #endregion + + #region GenerateFluentApiCalls_DefaultBucketAlias_NoWithTimeBucketProperty + + [Fact] + public void GenerateFluentApiCalls_DefaultBucketAlias_NoWithTimeBucketProperty() + { + // Arrange + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, DefaultBucketAliasViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + Assert.DoesNotContain(CollectMethodChain(root), m => m == "WithTimeBucketProperty"); + } + + #endregion + + #region GenerateFluentApiCalls_BucketAlias_MatchesNoProperty_NoWithTimeBucketProperty + + [Fact] + public void GenerateFluentApiCalls_BucketAlias_MatchesNoProperty_NoWithTimeBucketProperty() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\") AS unmapped_bucket," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"; + + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + Assert.DoesNotContain(CollectMethodChain(root), m => m == "WithTimeBucketProperty"); + } + + #endregion + + #region GenerateFluentApiCalls_NullBucketAlias_NoWithTimeBucketProperty + + [Fact] + public void GenerateFluentApiCalls_NullBucketAlias_NoWithTimeBucketProperty() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"; + + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + Assert.DoesNotContain(CollectMethodChain(root), m => m == "WithTimeBucketProperty"); + } + + #endregion + + #region GenerateFluentApiCalls_CustomBucketAlias_ConsumesTimeBucketTargetProperty + + [Fact] + public void GenerateFluentApiCalls_CustomBucketAlias_ConsumesTimeBucketTargetProperty() + { + // Arrange + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.TimeBucketTargetProperty, "HourStart"), + (ContinuousAggregateAnnotations.ViewDefinition, CustomBucketAliasViewDef)); + + // Act + CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(ContinuousAggregateAnnotations.TimeBucketTargetProperty, annotations.Keys); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CustomBucketAlias_ClassLevel_OnlyContinuousAggregate + + [Fact] + public void GenerateDataAnnotationAttributes_CustomBucketAlias_ClassLevel_OnlyContinuousAggregate() + { + // Arrange + using BucketAliasContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, CustomBucketAliasViewDef)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.Single(result, a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.DoesNotContain(result, a => a.Type == typeof(TimeBucketAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CustomBucketAlias_Property_EmitsTimeBucketAttribute + + private class CustomAliasBucketSourceEntity + { + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } + } + + private class CustomAliasBucketCaEntity + { + public DateTime HourStart { get; set; } + public double AvgPowerKw { get; set; } + } + + private class CustomAliasBucketContext : 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.Timestamp); + e.ToTable("power_meter_readings"); + e.Property(x => x.Timestamp).HasColumnName("timestamp"); + e.Property(x => x.PowerKw).HasColumnName("power_kw"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("power_usage_hourly"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "power_meter_readings"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\") AS hour_start," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"); + e.Property(x => x.HourStart).HasColumnName("hour_start"); + e.Property(x => x.AvgPowerKw).HasColumnName("avg_power_kw"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_CustomBucketAlias_Property_EmitsTimeBucketAttribute() + { + // Arrange + using CustomAliasBucketContext context = new(); + IEntityType entityType = GetEntityType(context); + IProperty property = entityType.FindProperty(nameof(CustomAliasBucketCaEntity.HourStart))!; + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + AttributeCodeFragment attr = Assert.Single(result, a => a.Type == typeof(TimeBucketAttribute)); + Assert.Equal("1 hour", attr.Arguments[0]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_DefaultBucketAlias_ClassLevel_HasTimeBucket_NoPropertyLevel + + private class DefaultAliasBucketSourceEntity + { + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } + } + + private class DefaultAliasBucketCaEntity + { + public DateTime TimeBucket { get; set; } + public double AvgPowerKw { get; set; } + } + + private class DefaultAliasBucketContext : 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.Timestamp); + e.ToTable("power_meter_readings"); + e.Property(x => x.Timestamp).HasColumnName("timestamp"); + e.Property(x => x.PowerKw).HasColumnName("power_kw"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("power_usage_hourly"); + e.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"); + e.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "power_meter_readings"); + e.HasAnnotation(ContinuousAggregateAnnotations.ViewDefinition, + "SELECT time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\") AS time_bucket," + + " avg(power_meter_readings.power_kw) AS avg_power_kw" + + " FROM power_meter_readings" + + " GROUP BY time_bucket('01:00:00'::interval, power_meter_readings.\"timestamp\")"); + e.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + e.Property(x => x.AvgPowerKw).HasColumnName("avg_power_kw"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_DefaultBucketAlias_ClassLevel_HasTimeBucket_NoPropertyLevel() + { + // Arrange + using DefaultAliasBucketContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary classAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "power_usage_hourly"), + (ContinuousAggregateAnnotations.ParentName, "power_meter_readings"), + (ContinuousAggregateAnnotations.ViewDefinition, DefaultBucketAliasViewDef)); + + IAnnotationCodeGenerator generator = CreateAnnotationCodeGenerator(); + IProperty property = entityType.FindProperty(nameof(DefaultAliasBucketCaEntity.TimeBucket))!; + + // Act + IReadOnlyList classResult = generator + .GenerateDataAnnotationAttributes(entityType, classAnnotations); + IReadOnlyList propertyResult = generator + .GenerateDataAnnotationAttributes(property, new Dictionary()); + + // Assert + Assert.Single(classResult, a => a.Type == typeof(TimeBucketAttribute)); + Assert.DoesNotContain(propertyResult, a => a.Type == typeof(TimeBucketAttribute)); + } + + #endregion + + // ── Hierarchical continuous aggregate with custom bucket aliases ─────────── + + #region GenerateFluentApiCalls_HierarchicalCustomAlias_BothLevelsDesignateBucketProperty + + private class HierRawEntity + { + public DateTime Timestamp { get; set; } + public double PowerKw { get; set; } + } + + private class HierHourlyEntity + { + public DateTime HourStart { get; set; } + public double AvgPowerKw { get; set; } + } + + private class HierDailyEntity + { + public DateTime DayStart { get; set; } + public double AvgPowerKw { get; set; } + } + + private class HierBucketAliasContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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.Timestamp); + e.ToTable("hier_raw"); + e.Property(x => x.Timestamp).HasColumnName("timestamp"); + e.Property(x => x.PowerKw).HasColumnName("power_kw"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("hier_hourly"); + e.Property(x => x.HourStart).HasColumnName("hour_start"); + e.Property(x => x.AvgPowerKw).HasColumnName("avg_power_kw"); + }); + modelBuilder.Entity(e => + { + e.HasNoKey(); + e.ToView("hier_daily"); + e.Property(x => x.DayStart).HasColumnName("day_start"); + e.Property(x => x.AvgPowerKw).HasColumnName("avg_power_kw"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_HierarchicalCustomAlias_BothLevelsDesignateBucketProperty() + { + // Arrange + const string hourlyViewDef = + "SELECT time_bucket('01:00:00'::interval, hier_raw.\"timestamp\") AS hour_start," + + " avg(hier_raw.power_kw) AS avg_power_kw" + + " FROM hier_raw" + + " GROUP BY time_bucket('01:00:00'::interval, hier_raw.\"timestamp\")"; + const string dailyViewDef = + "SELECT time_bucket('1 day'::interval, hier_hourly.hour_start) AS day_start," + + " avg(hier_hourly.avg_power_kw) AS avg_power_kw" + + " FROM hier_hourly" + + " GROUP BY time_bucket('1 day'::interval, hier_hourly.hour_start)"; + + using HierBucketAliasContext context = new(); + IAnnotationCodeGenerator generator = CreateAnnotationCodeGenerator(); + + IEntityType hourly = GetEntityType(context); + Dictionary hourlyAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hier_hourly"), + (ContinuousAggregateAnnotations.ParentName, "hier_raw"), + (ContinuousAggregateAnnotations.ViewDefinition, hourlyViewDef)); + + IEntityType daily = GetEntityType(context); + Dictionary dailyAnnotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "hier_daily"), + (ContinuousAggregateAnnotations.ParentName, "hier_hourly"), + (ContinuousAggregateAnnotations.ViewDefinition, dailyViewDef)); + + // Act + IReadOnlyList hourlyResult = generator.GenerateFluentApiCalls(hourly, hourlyAnnotations); + IReadOnlyList dailyResult = generator.GenerateFluentApiCalls(daily, dailyAnnotations); + + // Assert + MethodCallCodeFragment hourlyRoot = Assert.Single(hourlyResult, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + NameOfCodeFragment hourlyBucket = Assert.IsType( + Assert.Single(EnumerateChain(hourlyRoot).Single(f => f.Method == "WithTimeBucketProperty").Arguments)); + Assert.Equal($"{nameof(HierHourlyEntity)}.{nameof(HierHourlyEntity.HourStart)}", hourlyBucket.PropertyName); + + MethodCallCodeFragment dailyRoot = Assert.Single(dailyResult, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + NameOfCodeFragment dailyBucket = Assert.IsType( + Assert.Single(EnumerateChain(dailyRoot).Single(f => f.Method == "WithTimeBucketProperty").Arguments)); + Assert.Equal($"{nameof(HierDailyEntity)}.{nameof(HierDailyEntity.DayStart)}", dailyBucket.PropertyName); + } + + #endregion + + private static IEnumerable EnumerateChain(MethodCallCodeFragment? fragment) + { + for (MethodCallCodeFragment? current = fragment; current != null; current = current.ChainedCall) + { + yield return current; + } + } } diff --git a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs index adb18b6..7d831f0 100644 --- a/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Features/ContinuousAggregate/ContinuousAggregateCSharpGeneratorTests.cs @@ -239,6 +239,42 @@ public void AlterContinuousAggregate_EmitsOldArgsOnlyWhenNonDefault() #endregion + #region CreateContinuousAggregate_TimeBucketColumnName_OnlyEmittedWhenCustom + + [Fact] + public void CreateContinuousAggregate_TimeBucketColumnName_OnlyEmittedWhenCustom() + { + // Arrange + CreateContinuousAggregateOperation defaultName = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + TimeBucketColumnName = "time_bucket", + }; + + // Act + string defaultResult = Generate(defaultName); + + // Assert + Assert.DoesNotContain("timeBucketColumnName:", defaultResult); + + // Arrange + CreateContinuousAggregateOperation customName = new() + { + MaterializedViewName = "hourly", + ParentName = "sensor_data", + TimeBucketColumnName = "hour_start", + }; + + // Act + string customResult = Generate(customName); + + // Assert + Assert.Contains("timeBucketColumnName: \"hour_start\"", customResult); + } + + #endregion + #region CreateContinuousAggregate_NullAggregateFunctions_Omits_AggregateFunctionsArg [Fact] diff --git a/tests/Eftdb.Tests/Design/Scaffolding/ViewDefinitionParserTests.cs b/tests/Eftdb.Tests/Design/Scaffolding/ViewDefinitionParserTests.cs index 4c3260d..da2b65e 100644 --- a/tests/Eftdb.Tests/Design/Scaffolding/ViewDefinitionParserTests.cs +++ b/tests/Eftdb.Tests/Design/Scaffolding/ViewDefinitionParserTests.cs @@ -1159,4 +1159,168 @@ public void ParseAggregates_LegacyFinalizeAgg_AlreadyParsedAlias_IsSkipped() } #endregion + + // ── ParseTimeBucketAlias ──────────────────────────────────────────────── + + #region ParseTimeBucketAlias_Extracts_PlainAlias + + [Fact] + public void ParseTimeBucketAlias_Extracts_PlainAlias() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\") AS hour_start," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Equal("hour_start", result); + } + + #endregion + + #region ParseTimeBucketAlias_Extracts_QuotedAlias_StripsQuotes + + [Fact] + public void ParseTimeBucketAlias_Extracts_QuotedAlias_StripsQuotes() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\") AS \"hour_start\"," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Equal("hour_start", result); + } + + #endregion + + #region ParseTimeBucketAlias_ReturnsDefaultAlias_Verbatim + + [Fact] + public void ParseTimeBucketAlias_ReturnsDefaultAlias_Verbatim() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\") AS time_bucket," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Equal("time_bucket", result); + } + + #endregion + + #region ParseTimeBucketAlias_ReturnsNull_WhenNoAlias + + [Fact] + public void ParseTimeBucketAlias_ReturnsNull_WhenNoAlias() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\")," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Null(result); + } + + #endregion + + #region ParseTimeBucketAlias_IsCaseInsensitive_ForAsKeyword + + [Fact] + public void ParseTimeBucketAlias_IsCaseInsensitive_ForAsKeyword() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\") as hour_start," + + " avg(t.value) as avg_value" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Equal("hour_start", result); + } + + #endregion + + #region ParseTimeBucketAlias_IgnoresAliasesOnOtherFunctions + + [Fact] + public void ParseTimeBucketAlias_IgnoresAliasesOnOtherFunctions() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\")," + + " avg(t.value) AS foo" + + " FROM t GROUP BY 1"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Null(result); + } + + #endregion + + #region ParseTimeBucketAlias_RealisticViewDefinition_QualifiedQuotedColumns + + [Fact] + public void ParseTimeBucketAlias_RealisticViewDefinition_QualifiedQuotedColumns() + { + // Arrange + const string sql = + "SELECT time_bucket('01:00:00'::interval, \"custom_schema\".\"power_meter_readings\".\"timestamp\") AS hour_start," + + " avg(\"custom_schema\".\"power_meter_readings\".\"power_kw\") AS avg_power_kw," + + " max(\"custom_schema\".\"power_meter_readings\".\"power_kw\") AS max_power_kw" + + " FROM \"custom_schema\".\"power_meter_readings\"" + + " GROUP BY time_bucket('01:00:00'::interval, \"custom_schema\".\"power_meter_readings\".\"timestamp\")"; + + // Act + string? result = ViewDefinitionParser.ParseTimeBucketAlias(sql); + + // Assert + Assert.Equal("hour_start", result); + } + + #endregion + + #region ParseTimeBucketAlias_Parse_Populates_TimeBucketAlias + + [Fact] + public void ParseTimeBucketAlias_Parse_Populates_TimeBucketAlias() + { + // Arrange + const string sql = + "SELECT time_bucket('1 hour'::interval, t.\"timestamp\") AS hour_start," + + " avg(t.value) AS avg_value" + + " FROM t GROUP BY 1"; + + // Act + ViewDefinitionParser.ParsedViewDefinition parsed = ViewDefinitionParser.Parse(sql); + + // Assert + Assert.Equal("hour_start", parsed.TimeBucketAlias); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs index c0bfaf8..02d022a 100644 --- a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs @@ -3344,4 +3344,774 @@ public void Should_Drop_And_Recreate_When_GroupByColumn_Count_Differs() } #endregion + + // ── Hierarchical continuous aggregates ── + + #region Should_Add_Child_Aggregate_To_Unchanged_Parent + + private class HierAddRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HierAddHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierAddDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierAddParentOnlyContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + + protected 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("hier_add_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_add_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class HierAddParentAndChildContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("hier_add_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_add_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_add_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Add_Child_Aggregate_To_Unchanged_Parent() + { + using HierAddParentOnlyContext sourceContext = new(); + using HierAddParentAndChildContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + CreateContinuousAggregateOperation createOp = Assert.Single(operations.OfType()); + Assert.Equal("hier_add_daily", createOp.MaterializedViewName); + Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation); + } + + #endregion + + #region Should_Drop_Child_Before_Parent_When_Both_Removed + + private class HierRemoveRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HierRemoveHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierRemoveDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierRemoveParentAndChildContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("hier_remove_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_remove_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_remove_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + private class HierRemoveHypertableOnlyContext : DbContext + { + public DbSet Raw => Set(); + + protected 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("hier_remove_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Drop_Child_Before_Parent_When_Both_Removed() + { + using HierRemoveParentAndChildContext sourceContext = new(); + using HierRemoveHypertableOnlyContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List drops = [.. operations.OfType()]; + Assert.Equal(2, drops.Count); + Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation); + + int childIndex = drops.FindIndex(op => op.MaterializedViewName == "hier_remove_daily"); + int parentIndex = drops.FindIndex(op => op.MaterializedViewName == "hier_remove_hourly"); + Assert.True(childIndex < parentIndex); + } + + #endregion + + #region Should_Cascade_Drop_And_Recreate_When_Parent_Structurally_Changes + + private class HierCascadeRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HierCascadeHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierCascadeDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierCascadeInitialContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("hier_cascade_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_cascade_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_cascade_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + private class HierCascadeChangedParentContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("hier_cascade_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_cascade_hourly", + "2 hours", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_cascade_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Cascade_Drop_And_Recreate_When_Parent_Structurally_Changes() + { + using HierCascadeInitialContext sourceContext = new(); + using HierCascadeChangedParentContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.DoesNotContain(operations, op => op is AlterContinuousAggregateOperation); + + List drops = [.. operations.OfType()]; + List creates = [.. operations.OfType()]; + Assert.Equal(2, drops.Count); + Assert.Equal(2, creates.Count); + + int dropChildIndex = drops.FindIndex(op => op.MaterializedViewName == "hier_cascade_daily"); + int dropParentIndex = drops.FindIndex(op => op.MaterializedViewName == "hier_cascade_hourly"); + Assert.True(dropChildIndex < dropParentIndex); + + int createParentIndex = creates.FindIndex(op => op.MaterializedViewName == "hier_cascade_hourly"); + int createChildIndex = creates.FindIndex(op => op.MaterializedViewName == "hier_cascade_daily"); + Assert.True(createParentIndex < createChildIndex); + } + + #endregion + + #region Should_Create_Both_Hierarchical_Aggregates_ParentFirst_From_Empty + + private class HierCreateRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ChildAggregateFirstAlphabetically + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class ParentAggregateSecondAlphabetically + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierCreateHypertableOnlyContext : DbContext + { + public DbSet Raw => Set(); + + protected 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("hier_create_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class HierCreateFullContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Daily => Set(); + public DbSet Hourly => Set(); + + protected 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("hier_create_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_create_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_create_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Create_Both_Hierarchical_Aggregates_ParentFirst_From_Empty() + { + using HierCreateHypertableOnlyContext sourceContext = new(); + using HierCreateFullContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List creates = [.. operations.OfType()]; + Assert.Equal(2, creates.Count); + + int parentIndex = creates.FindIndex(op => op.MaterializedViewName == "hier_create_hourly"); + int childIndex = creates.FindIndex(op => op.MaterializedViewName == "hier_create_daily"); + Assert.True(parentIndex < childIndex); + } + + #endregion + + // ── Time-bucket column name ── + + #region Should_Drop_And_Recreate_When_TimeBucketColumnName_Changes + + private class BucketRenameRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BucketRenameHourly + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketRenameDefaultContext : 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("bucket_rename_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "bucket_rename_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class BucketRenameCustomContext : 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("bucket_rename_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "bucket_rename_hourly", + "1 hour", + x => x.Timestamp) + .WithTimeBucketProperty(x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Drop_And_Recreate_When_TimeBucketColumnName_Changes() + { + using BucketRenameDefaultContext sourceContext = new(); + using BucketRenameCustomContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault(); + CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault(); + + Assert.NotNull(dropOp); + Assert.NotNull(createOp); + Assert.Equal("bucket_rename_hourly", dropOp.MaterializedViewName); + Assert.Equal("bucket_rename_hourly", createOp.MaterializedViewName); + Assert.Equal("hour_start", createOp.TimeBucketColumnName); + } + + #endregion + + #region Should_Not_Generate_Operations_When_TimeBucketColumnName_Identical + + private class BucketStableRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BucketStableHourly + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketStableContext : 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("bucket_stable_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "bucket_stable_hourly", + "1 hour", + x => x.Timestamp) + .WithTimeBucketProperty(x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_TimeBucketColumnName_Identical() + { + using BucketStableContext sourceContext = new(); + using BucketStableContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Cascade_Drop_And_Recreate_When_Parent_BucketColumnName_Changes + + private class BucketCascadeRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BucketCascadeHourly + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketCascadeDaily + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class BucketCascadeInitialContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("bucket_cascade_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "bucket_cascade_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "bucket_cascade_daily", + "1 day", + x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + private class BucketCascadeRenamedParentContext : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected 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("bucket_cascade_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "bucket_cascade_hourly", + "1 hour", + x => x.Timestamp) + .WithTimeBucketProperty(x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "bucket_cascade_daily", + "1 day", + x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Cascade_Drop_And_Recreate_When_Parent_BucketColumnName_Changes() + { + using BucketCascadeInitialContext sourceContext = new(); + using BucketCascadeRenamedParentContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + ContinuousAggregateDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List drops = [.. operations.OfType()]; + List creates = [.. operations.OfType()]; + Assert.Equal(2, drops.Count); + Assert.Equal(2, creates.Count); + + int dropChildIndex = drops.FindIndex(op => op.MaterializedViewName == "bucket_cascade_daily"); + int dropParentIndex = drops.FindIndex(op => op.MaterializedViewName == "bucket_cascade_hourly"); + Assert.True(dropChildIndex < dropParentIndex); + + int createParentIndex = creates.FindIndex(op => op.MaterializedViewName == "bucket_cascade_hourly"); + int createChildIndex = creates.FindIndex(op => op.MaterializedViewName == "bucket_cascade_daily"); + Assert.True(createParentIndex < createChildIndex); + + CreateContinuousAggregateOperation parentCreate = creates[createParentIndex]; + Assert.Equal("hour_start", parentCreate.TimeBucketColumnName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 6693163..7803042 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -1832,22 +1832,22 @@ public void Should_Skip_When_ParentEntity_Not_Found() #endregion - #region Should_Skip_When_ParentTableName_Is_Null + #region Should_Skip_When_Parent_Has_No_Relational_Name - private class NoTableNameSourceMetric + private class NoRelationalNameSourceMetric { public DateTime Timestamp { get; set; } } - private class NoTableNameHourlyMetric + private class NoRelationalNameHourlyMetric { public DateTime Bucket { get; set; } } - private class NoTableNameContext : DbContext + private class NoRelationalNameContext : DbContext { - public DbSet Metrics => Set(); - public DbSet HourlyMetrics => Set(); + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") @@ -1855,17 +1855,16 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.ToView("metrics_view"); - entity.IsHypertable(x => x.Timestamp); + entity.ToTable((string?)null); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.IsContinuousAggregate( + entity.IsContinuousAggregate( "hourly_metrics", "1 hour", x => x.Timestamp @@ -1875,10 +1874,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } [Fact] - public void Should_Skip_When_ParentTableName_Is_Null() + public void Should_Skip_When_Parent_Has_No_Relational_Name() { // Arrange - using NoTableNameContext context = new(); + using NoRelationalNameContext context = new(); IRelationalModel relationalModel = GetRelationalModel(context); // Act @@ -2909,4 +2908,461 @@ public void Should_Resolve_TimeBucket_Source_Inside_ComplexType() } #endregion + + // ── Hierarchical continuous aggregates ── + + #region Should_Extract_Hierarchical_ContinuousAggregate + + private class HierarchicalProbeRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HierarchicalProbeHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierarchicalProbeDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierarchicalContext : DbContext + { + public DbSet ProbeRaw => Set(); + public DbSet ProbeHourly => Set(); + public DbSet ProbeDaily => Set(); + + protected 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("probe_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "probe_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "probe_daily", + "1 day", + x => x.TimeBucket + ).AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Extract_Hierarchical_ContinuousAggregate() + { + using HierarchicalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal(2, operations.Count); + + CreateContinuousAggregateOperation daily = Assert.Single(operations, op => op.MaterializedViewName == "probe_daily"); + Assert.Equal("probe_hourly", daily.ParentName); + Assert.Equal("time_bucket", daily.TimeBucketSourceColumn); + Assert.Equal("1 day", daily.TimeBucketWidth); + Assert.Single(daily.AggregateFunctions); + Assert.Equal("AvgValue:Avg:AvgValue", daily.AggregateFunctions[0]); + } + + #endregion + + #region Should_Order_Hierarchical_ContinuousAggregates_ParentFirst + + private class AlphaChildDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BetaMiddleHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class GammaRoot + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderingContext : DbContext + { + public DbSet Daily => Set(); + public DbSet Hourly => Set(); + public DbSet Raw => Set(); + + protected 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("gamma_raw"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "beta_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "alpha_daily", + "1 day", + x => x.TimeBucket + ).AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Order_Hierarchical_ContinuousAggregates_ParentFirst() + { + using OrderingContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + int hourlyIndex = operations.FindIndex(op => op.MaterializedViewName == "beta_hourly"); + int dailyIndex = operations.FindIndex(op => op.MaterializedViewName == "alpha_daily"); + + Assert.True(hourlyIndex >= 0); + Assert.True(dailyIndex >= 0); + Assert.True(hourlyIndex < dailyIndex); + } + + #endregion + + // ── Time-bucket target property ── + + #region Should_Default_TimeBucketColumnName_When_Undesignated + + private class UndesignatedBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class UndesignatedBucketAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class UndesignatedBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Default_TimeBucketColumnName_When_Undesignated() + { + using UndesignatedBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal("time_bucket", Assert.Single(operations).TimeBucketColumnName); + } + + #endregion + + #region Should_Resolve_Designated_BucketProperty_To_Explicit_ColumnName + + private class ExplicitBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ExplicitBucketAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ExplicitBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).WithTimeBucketProperty(x => x.Bucket) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Resolve_Designated_BucketProperty_To_Explicit_ColumnName() + { + using ExplicitBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal("hour_start", Assert.Single(operations).TimeBucketColumnName); + } + + #endregion + + #region Should_Resolve_Designated_BucketProperty_With_Naming_Convention + + private class ConventionBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ConventionBucketAggregate + { + public DateTime HourStart { get; set; } + public double AvgValue { get; set; } + } + + private class ConventionBucketContext : 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") + .UseSnakeCaseNamingConvention() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "hourly_metrics", + "1 hour", + x => x.Timestamp + ).WithTimeBucketProperty(x => x.HourStart) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Resolve_Designated_BucketProperty_With_Naming_Convention() + { + using ConventionBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal("hour_start", Assert.Single(operations).TimeBucketColumnName); + } + + #endregion + + #region Should_Designate_BucketProperty_Via_Property_Attribute + + private class AttributeBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_attr_metrics", ParentName = nameof(AttributeBucketSource))] + private class AttributeBucketAggregate + { + [TimeBucket("1 hour", nameof(AttributeBucketSource.Timestamp))] + [Column("hour_start")] + public DateTime Bucket { get; set; } + + [Aggregate(EAggregateFunction.Avg, nameof(AttributeBucketSource.Value))] + public double AvgValue { get; set; } + } + + private class AttributeBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("attr_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => entity.HasNoKey()); + } + } + + [Fact] + public void Should_Designate_BucketProperty_Via_Property_Attribute() + { + using AttributeBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal("hour_start", Assert.Single(operations).TimeBucketColumnName); + } + + #endregion + + #region Should_Designate_BucketProperty_Via_StringBuilder + + private class StringBuilderBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class StringBuilderBucketAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class StringBuilderBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.Bucket).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "hourly_metrics", + "Metrics", + "1 hour", + "Timestamp" + ).WithTimeBucketProperty("Bucket") + .AddAggregateFunction("avg_value", "Value", EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Designate_BucketProperty_Via_StringBuilder() + { + using StringBuilderBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + Assert.Equal("hour_start", Assert.Single(operations).TimeBucketColumnName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs index 2fdd2e2..d48951a 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs @@ -45,9 +45,9 @@ public void DesignTime_Create_MinimalAggregate_GeneratesCorrectCSharpCode() string expected = @" CREATE MATERIALIZED VIEW ""public"".""hourly_metrics"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""value"") AS ""avg_value"" + SELECT time_bucket('1 hour', ""timestamp"") AS ""time_bucket"", AVG(""value"") AS ""avg_value"" FROM ""public"".""metrics"" - GROUP BY time_bucket; + GROUP BY 1; "; // Act @@ -86,9 +86,9 @@ public void DesignTime_Create_WithAllStandardAggregates_GeneratesCorrectCode() string expected = @" CREATE MATERIALIZED VIEW ""analytics"".""daily_stats"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = true, timescaledb.materialized_only = true) AS - SELECT time_bucket('1 day', ""time"") AS time_bucket, AVG(""temperature"") AS ""avg_temp"", MAX(""temperature"") AS ""max_temp"", MIN(""temperature"") AS ""min_temp"", COUNT(""id"") AS ""total_readings"", SUM(""voltage"") AS ""sum_voltage"" + SELECT time_bucket('1 day', ""time"") AS ""time_bucket"", AVG(""temperature"") AS ""avg_temp"", MAX(""temperature"") AS ""max_temp"", MIN(""temperature"") AS ""min_temp"", COUNT(""id"") AS ""total_readings"", SUM(""voltage"") AS ""sum_voltage"" FROM ""analytics"".""sensor_data"" - GROUP BY time_bucket + GROUP BY 1 WITH NO DATA; "; @@ -121,9 +121,9 @@ public void DesignTime_Create_WithCountStarAggregate_GeneratesUnquotedWildcard() 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"" + SELECT time_bucket('1 hour', ""event_time"") AS ""time_bucket"", COUNT(*) AS ""record_count"" FROM ""public"".""events"" - GROUP BY time_bucket; + GROUP BY 1; "; // Act @@ -159,9 +159,9 @@ public void DesignTime_Create_WithTimescaleDBFirstLastFunctions_GeneratesCorrect string expected = @" CREATE MATERIALIZED VIEW ""public"".""price_aggregates"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('5 minutes', ""timestamp"") AS time_bucket, first(""price"", ""timestamp"") AS ""first_price"", last(""price"", ""timestamp"") AS ""last_price"" + SELECT time_bucket('5 minutes', ""timestamp"") AS ""time_bucket"", first(""price"", ""timestamp"") AS ""first_price"", last(""price"", ""timestamp"") AS ""last_price"" FROM ""public"".""trades"" - GROUP BY time_bucket; + GROUP BY 1; "; // Act @@ -193,9 +193,9 @@ public void DesignTime_Create_WithGroupByColumns_GeneratesCorrectGrouping() string expected = @" CREATE MATERIALIZED VIEW ""public"".""sales_by_region"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', ""sale_time"") AS time_bucket, ""region"", ""store_id"", SUM(""amount"") AS ""total_amount"" + SELECT time_bucket('1 hour', ""sale_time"") AS ""time_bucket"", ""region"", ""store_id"", SUM(""amount"") AS ""total_amount"" FROM ""public"".""sales"" - GROUP BY time_bucket, ""region"", ""store_id""; + GROUP BY 1, ""region"", ""store_id""; "; // Act @@ -228,10 +228,10 @@ public void DesignTime_Create_WithWhereClause_GeneratesCorrectFiltering() string expected = @" CREATE MATERIALIZED VIEW ""public"".""high_value_trades"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false) AS - SELECT time_bucket('1 hour', ""timestamp"") AS time_bucket, AVG(""price"") AS ""avg_price"" + SELECT time_bucket('1 hour', ""timestamp"") AS ""time_bucket"", AVG(""price"") AS ""avg_price"" FROM ""public"".""trades"" WHERE ""price"" > 100 AND ""volume"" > 1000 - GROUP BY time_bucket; + GROUP BY 1; "; // Act @@ -264,9 +264,9 @@ public void DesignTime_Create_WithChunkInterval_GeneratesCorrectOption() string expected = @" CREATE MATERIALIZED VIEW ""public"".""monthly_summary"" WITH (timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = false, timescaledb.chunk_interval = '7 days') AS - SELECT time_bucket('1 month', ""event_time"") AS time_bucket, COUNT(""id"") AS ""event_count"" + SELECT time_bucket('1 month', ""event_time"") AS ""time_bucket"", COUNT(""id"") AS ""event_count"" FROM ""public"".""events"" - GROUP BY time_bucket; + GROUP BY 1; "; // Act @@ -308,7 +308,7 @@ public void Runtime_Create_MinimalAggregate_GeneratesCorrectSQL() Assert.Contains("time_bucket('1 hour', \"timestamp\")", result); Assert.Contains("AVG(\"value\") AS \"avg_value\"", result); Assert.Contains("FROM \"public\".\"metrics\"", result); - Assert.Contains("GROUP BY time_bucket", result); + Assert.Contains("GROUP BY 1", result); Assert.DoesNotContain("WITH NO DATA", result); } @@ -381,7 +381,7 @@ public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() Assert.Contains("timescaledb.create_group_indexes = true", result); Assert.Contains("timescaledb.materialized_only = true", result); Assert.Contains("timescaledb.chunk_interval = '1 day'", result); - Assert.Contains("time_bucket('30 minutes', \"recorded_at\") AS time_bucket", result); + Assert.Contains("time_bucket('30 minutes', \"recorded_at\") AS \"time_bucket\"", result); Assert.Contains("\"sensor_id\"", result); Assert.Contains("\"location\"", result); Assert.Contains("AVG(\"temperature\") AS \"avg_temp\"", result); @@ -391,7 +391,7 @@ public void Runtime_Create_WithAllOptions_GeneratesCompleteSQL() Assert.Contains("first(\"temperature\", \"recorded_at\") AS \"first_reading\"", result); Assert.Contains("last(\"temperature\", \"recorded_at\") AS \"last_reading\"", result); Assert.Contains("WHERE \"temperature\" IS NOT NULL", result); - Assert.Contains("GROUP BY time_bucket, \"sensor_id\", \"location\"", result); + Assert.Contains("GROUP BY 1, \"sensor_id\", \"location\"", result); Assert.Contains("WITH NO DATA", result); } @@ -705,9 +705,9 @@ public void Create_WithoutTimeBucketInGroupBy_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("time_bucket('1 hour', \"time\") AS time_bucket", result); + Assert.Contains("time_bucket('1 hour', \"time\") AS \"time_bucket\"", result); Assert.Contains("GROUP BY \"region\"", result); - Assert.DoesNotContain("GROUP BY time_bucket", result); + Assert.DoesNotContain("GROUP BY 1", result); } [Fact] @@ -786,8 +786,8 @@ public void Create_WithEmptyGroupByColumns_OnlyIncludesTimeBucket() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("GROUP BY time_bucket", result); - Assert.DoesNotContain("GROUP BY time_bucket,", result); + Assert.Contains("GROUP BY 1", result); + Assert.DoesNotContain("GROUP BY 1,", result); } [Fact] @@ -1119,7 +1119,7 @@ public void Create_RequiresTimeBucket_InSelectClause() // Assert Assert.Contains("time_bucket(", result); - Assert.Contains("AS time_bucket", result); + Assert.Contains("AS \"time_bucket\"", result); } [Fact] @@ -1145,7 +1145,7 @@ public void Create_RequiresTimeBucket_InGroupByClause() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("GROUP BY time_bucket", result); + Assert.Contains("GROUP BY 1", result); } [Fact] @@ -1329,13 +1329,69 @@ public void Create_WithTimeBucketGroupByFalse_And_EmptyGroupByColumns_OmitsGroup string result = GetRuntimeSql(operation); // Assert - Assert.Contains("time_bucket('1 hour', \"ts\") AS time_bucket", result); + 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 Create_CustomTimeBucketColumnName_EmitsQuotedAlias + + [Fact] + public void Create_CustomTimeBucketColumnName_EmitsQuotedAlias() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_usage", + Schema = "public", + ParentName = "readings", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "recorded_at", + TimeBucketColumnName = "hour_start", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("time_bucket('1 hour', \"recorded_at\") AS \"hour_start\"", result); + Assert.DoesNotContain("AS \"time_bucket\"", result); + } + + #endregion + + #region Create_DefaultTimeBucketColumnName_EmitsQuotedTimeBucketAlias + + [Fact] + public void Create_DefaultTimeBucketColumnName_EmitsQuotedTimeBucketAlias() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_usage", + Schema = "public", + ParentName = "readings", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "recorded_at", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_value:Avg:value"], + GroupByColumns = [] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("time_bucket('1 hour', \"recorded_at\") AS \"time_bucket\"", result); + } + + #endregion + #region Alter_NullChunkInterval_And_EmptyOldChunkInterval_GeneratesNoStatement [Fact] diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs index 2cf6089..f0f7566 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; @@ -1208,5 +1209,533 @@ await context.Database.ExecuteSqlRawAsync( } #endregion + + #region Should_Create_Hierarchical_ContinuousAggregate + + private class HierProbeRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HierProbeHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierProbeDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class HierarchicalContext(string connectionString) : DbContext + { + public DbSet ProbeRaw => Set(); + public DbSet ProbeHourly => Set(); + public DbSet ProbeDaily => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("hier_probe_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_probe_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_probe_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + } + } + + [Fact] + public async Task Should_Create_Hierarchical_ContinuousAggregate() + { + await using HierarchicalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List viewNames = await context.Database + .SqlQuery($@" + SELECT view_name AS ""Value"" + FROM timescaledb_information.continuous_aggregates + WHERE view_name IN ('hier_probe_hourly', 'hier_probe_daily') + ORDER BY view_name") + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Contains("hier_probe_hourly", viewNames); + Assert.Contains("hier_probe_daily", viewNames); + + List dailyParents = await context.Database + .SqlQuery($@" + SELECT parent.user_view_name AS ""Value"" + FROM _timescaledb_catalog.continuous_agg child + JOIN _timescaledb_catalog.continuous_agg parent + ON child.parent_mat_hypertable_id = parent.mat_hypertable_id + WHERE child.user_view_name = 'hier_probe_daily'") + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Equal("hier_probe_hourly", Assert.Single(dailyParents)); + } + + #endregion + + #region Should_Create_Five_Level_Hierarchical_ContinuousAggregate_Chain + + private class AChainDaily + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class BChainFourHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CChainHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DChainQuarterHourly + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class EChainFiveMinutely + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class FChainRawMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FiveLevelChainContext(string connectionString) : DbContext + { + public DbSet RawMetrics => Set(); + public DbSet DailyAggregates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("hier_chain_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_chain_5m", + "5 minutes", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_chain_15m", + "15 minutes", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_chain_1h", + "1 hour", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_chain_4h", + "4 hours", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_chain_1d", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + } + } + + [Fact] + public async Task Should_Create_Five_Level_Hierarchical_ContinuousAggregate_Chain() + { + // Arrange + await using FiveLevelChainContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + // Act + List viewNames = await context.Database + .SqlQuery($@" + SELECT view_name AS ""Value"" + FROM timescaledb_information.continuous_aggregates + WHERE view_name LIKE 'hier_chain_%'") + .ToListAsync(TestContext.Current.CancellationToken); + + List parentLinks = await context.Database + .SqlQuery($@" + SELECT child.user_view_name || '<-' || parent.user_view_name AS ""Value"" + FROM _timescaledb_catalog.continuous_agg child + JOIN _timescaledb_catalog.continuous_agg parent + ON child.parent_mat_hypertable_id = parent.mat_hypertable_id + WHERE child.user_view_name LIKE 'hier_chain_%'") + .ToListAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(5, viewNames.Count); + Assert.Contains("hier_chain_5m", viewNames); + Assert.Contains("hier_chain_15m", viewNames); + Assert.Contains("hier_chain_1h", viewNames); + Assert.Contains("hier_chain_4h", viewNames); + Assert.Contains("hier_chain_1d", viewNames); + + Assert.Equal(4, parentLinks.Count); + Assert.Contains("hier_chain_15m<-hier_chain_5m", parentLinks); + Assert.Contains("hier_chain_1h<-hier_chain_15m", parentLinks); + Assert.Contains("hier_chain_4h<-hier_chain_1h", parentLinks); + Assert.Contains("hier_chain_1d<-hier_chain_4h", parentLinks); + + // Act + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO hier_chain_raw (""Timestamp"", ""Value"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {10.0}), + ({new DateTime(2025, 1, 6, 10, 7, 0, DateTimeKind.Utc)}, {20.0}), + ({new DateTime(2025, 1, 6, 10, 20, 0, DateTimeKind.Utc)}, {30.0})", + TestContext.Current.CancellationToken); + + foreach (string refreshSql in (string[]) + [ + "CALL refresh_continuous_aggregate('public.hier_chain_5m', NULL, NULL);", + "CALL refresh_continuous_aggregate('public.hier_chain_15m', NULL, NULL);", + "CALL refresh_continuous_aggregate('public.hier_chain_1h', NULL, NULL);", + "CALL refresh_continuous_aggregate('public.hier_chain_4h', NULL, NULL);", + "CALL refresh_continuous_aggregate('public.hier_chain_1d', NULL, NULL);", + ]) + { + await context.Database.ExecuteSqlRawAsync(refreshSql, [], TestContext.Current.CancellationToken); + } + + List dailyAggregates = await context.DailyAggregates + .ToListAsync(TestContext.Current.CancellationToken); + + // Assert + AChainDaily dailyAggregate = Assert.Single(dailyAggregates); + Assert.Equal(22.5, dailyAggregate.AvgValue); + } + + #endregion + + #region Should_Create_Hierarchical_ContinuousAggregate_With_GroupBy_And_RefreshPolicies + + private class ComboMeterReading + { + public DateTime Timestamp { get; set; } + public string MeterId { get; set; } = string.Empty; + public double PowerKw { get; set; } + } + + private class ComboHourlyUsage + { + public DateTime TimeBucket { get; set; } + public string MeterId { get; set; } = string.Empty; + public double MinPowerKw { get; set; } + public double MaxPowerKw { get; set; } + public double TotalPowerKw { get; set; } + public long ReadingCount { get; set; } + } + + private class ComboDailyUsage + { + public DateTime TimeBucket { get; set; } + public string MeterId { get; set; } = string.Empty; + public double MinPowerKw { get; set; } + public double MaxPowerKw { get; set; } + public double TotalPowerKw { get; set; } + public long ReadingCount { get; set; } + } + + private class ComboHierarchicalContext(string connectionString) : DbContext + { + public DbSet Readings => Set(); + public DbSet DailyUsages => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("hier_combo_readings"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_combo_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.MinPowerKw, x => x.PowerKw, EAggregateFunction.Min) + .AddAggregateFunction(x => x.MaxPowerKw, x => x.PowerKw, EAggregateFunction.Max) + .AddAggregateFunction(x => x.TotalPowerKw, x => x.PowerKw, EAggregateFunction.Sum) + .AddAggregateFunction(x => x.ReadingCount, x => x.Timestamp, EAggregateFunction.Count) + .AddGroupByColumn(x => x.MeterId) + .WithRefreshPolicy(startOffset: "3 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.IsContinuousAggregate( + "hier_combo_daily", + "1 day", + x => x.TimeBucket) + .AddAggregateFunction(x => x.MinPowerKw, x => x.MinPowerKw, EAggregateFunction.Min) + .AddAggregateFunction(x => x.MaxPowerKw, x => x.MaxPowerKw, EAggregateFunction.Max) + .AddAggregateFunction(x => x.TotalPowerKw, x => x.TotalPowerKw, EAggregateFunction.Sum) + .AddAggregateFunction(x => x.ReadingCount, x => x.ReadingCount, EAggregateFunction.Sum) + .AddGroupByColumn(x => x.MeterId) + .WithRefreshPolicy(startOffset: "30 days", endOffset: "1 day", scheduleInterval: "1 hour"); + }); + } + } + + [Fact] + public async Task Should_Create_Hierarchical_ContinuousAggregate_With_GroupBy_And_RefreshPolicies() + { + // Arrange + await using ComboHierarchicalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + // Act + List policyTargets = await context.Database + .SqlQuery($@" + SELECT j.hypertable_name AS ""Value"" + FROM timescaledb_information.jobs j + WHERE j.proc_name = 'policy_refresh_continuous_aggregate' + AND j.hypertable_name LIKE 'hier_combo_%'") + .ToListAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, policyTargets.Count); + Assert.Contains("hier_combo_hourly", policyTargets); + Assert.Contains("hier_combo_daily", policyTargets); + + // Act + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO hier_combo_readings (""Timestamp"", ""MeterId"", ""PowerKw"") + VALUES + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"meter-a"}, {1.0}), + ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"meter-a"}, {3.0}), + ({new DateTime(2025, 1, 6, 11, 15, 0, DateTimeKind.Utc)}, {"meter-a"}, {5.0}), + ({new DateTime(2025, 1, 6, 10, 15, 0, DateTimeKind.Utc)}, {"meter-b"}, {10.0})", + TestContext.Current.CancellationToken); + + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.hier_combo_hourly', NULL, NULL);", + [], TestContext.Current.CancellationToken); + await context.Database.ExecuteSqlRawAsync( + "CALL refresh_continuous_aggregate('public.hier_combo_daily', NULL, NULL);", + [], TestContext.Current.CancellationToken); + + List dailyUsages = await context.DailyUsages + .OrderBy(x => x.MeterId) + .ToListAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, dailyUsages.Count); + + Assert.Equal("meter-a", dailyUsages[0].MeterId); + Assert.Equal(1.0, dailyUsages[0].MinPowerKw); + Assert.Equal(5.0, dailyUsages[0].MaxPowerKw); + Assert.Equal(9.0, dailyUsages[0].TotalPowerKw); + Assert.Equal(3L, dailyUsages[0].ReadingCount); + + Assert.Equal("meter-b", dailyUsages[1].MeterId); + Assert.Equal(10.0, dailyUsages[1].MinPowerKw); + Assert.Equal(10.0, dailyUsages[1].MaxPowerKw); + Assert.Equal(10.0, dailyUsages[1].TotalPowerKw); + Assert.Equal(1L, dailyUsages[1].ReadingCount); + } + + #endregion + + #region Should_Create_Hierarchical_ContinuousAggregate_With_Designated_BucketColumn + + private class DesignatedBucketRaw + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DesignatedBucketHourly + { + public DateTime HourStart { get; set; } + public double AvgValue { get; set; } + } + + private class DesignatedBucketDaily + { + public DateTime DayStart { get; set; } + public double AvgValue { get; set; } + } + + private class DesignatedBucketContext(string connectionString) : DbContext + { + public DbSet Raw => Set(); + public DbSet Hourly => Set(); + public DbSet Daily => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("designated_bucket_raw"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.HourStart).HasColumnName("hour_start"); + entity.IsContinuousAggregate( + "designated_bucket_hourly", + "1 hour", + x => x.Timestamp) + .WithTimeBucketProperty(x => x.HourStart) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(true); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.Property(x => x.DayStart).HasColumnName("day_start"); + entity.IsContinuousAggregate( + "designated_bucket_daily", + "1 day", + x => x.HourStart) + .WithTimeBucketProperty(x => x.DayStart) + .AddAggregateFunction(x => x.AvgValue, x => x.AvgValue, EAggregateFunction.Avg) + .WithNoData(true); + }); + } + } + + [Fact] + public async Task Should_Create_Hierarchical_ContinuousAggregate_With_Designated_BucketColumn() + { + await using DesignatedBucketContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List hourlyColumns = await context.Database + .SqlQuery($@" + SELECT column_name AS ""Value"" + FROM information_schema.columns + WHERE table_name = 'designated_bucket_hourly' + ORDER BY column_name") + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Contains("hour_start", hourlyColumns); + Assert.DoesNotContain("time_bucket", hourlyColumns); + + List dailyColumns = await context.Database + .SqlQuery($@" + SELECT column_name AS ""Value"" + FROM information_schema.columns + WHERE table_name = 'designated_bucket_daily' + ORDER BY column_name") + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Contains("day_start", dailyColumns); + + List dailyParents = await context.Database + .SqlQuery($@" + SELECT parent.user_view_name AS ""Value"" + FROM _timescaledb_catalog.continuous_agg child + JOIN _timescaledb_catalog.continuous_agg parent + ON child.parent_mat_hypertable_id = parent.mat_hypertable_id + WHERE child.user_view_name = 'designated_bucket_daily'") + .ToListAsync(TestContext.Current.CancellationToken); + + Assert.Equal("designated_bucket_hourly", Assert.Single(dailyParents)); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs index 995e916..c7267da 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -715,4 +715,57 @@ public async Task Should_Extract_ContinuousAggregate_When_Connection_Already_Ope } #endregion + + #region Should_Rewrite_Hierarchical_Source_To_Parent_View + + [Fact] + public async Task Should_Rewrite_Hierarchical_Source_To_Parent_View() + { + string testConnectionString = await GetTestConnectionStringAsync(); + + await using NpgsqlConnection setupConnection = new(testConnectionString); + await setupConnection.OpenAsync(TestContext.Current.CancellationToken); + + await ExecuteAsync(setupConnection, @" + CREATE TABLE hier_scaffold_raw ( + ""Timestamp"" timestamptz NOT NULL, + ""Value"" double precision NOT NULL + );"); + await ExecuteAsync(setupConnection, "SELECT create_hypertable('hier_scaffold_raw', 'Timestamp');"); + + await ExecuteAsync(setupConnection, @" + CREATE MATERIALIZED VIEW hier_scaffold_hourly + WITH (timescaledb.continuous) AS + SELECT time_bucket('1 hour', ""Timestamp"") AS time_bucket, avg(""Value"") AS avg_value + FROM hier_scaffold_raw + GROUP BY time_bucket + WITH NO DATA;"); + + await ExecuteAsync(setupConnection, @" + CREATE MATERIALIZED VIEW hier_scaffold_daily + WITH (timescaledb.continuous) AS + SELECT time_bucket('1 day', time_bucket) AS time_bucket, avg(avg_value) AS avg_value + FROM hier_scaffold_hourly + GROUP BY 1 + WITH NO DATA;"); + + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(testConnectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.True(result.ContainsKey(("public", "hier_scaffold_daily"))); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo dailyInfo = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "hier_scaffold_daily")]; + + Assert.Equal("hier_scaffold_hourly", dailyInfo.SourceHypertableName); + Assert.Equal("public", dailyInfo.SourceSchema); + } + + private static async Task ExecuteAsync(NpgsqlConnection connection, string sql) + { + await using NpgsqlCommand command = new(sql, connection); + await command.ExecuteNonQueryAsync(); + } + + #endregion } diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateStringBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateStringBuilderTests.cs new file mode 100644 index 0000000..bd9f301 --- /dev/null +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateStringBuilderTests.cs @@ -0,0 +1,137 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests for the scaffold-targeting ContinuousAggregateStringBuilder overloads that reference +/// columns by name string rather than by lambda selector. +/// +public class ContinuousAggregateStringBuilderTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + // ── WithTimeBucketProperty(string) guard ───────────────────────────────── + + #region WithTimeBucketProperty_Throws_When_PropertyName_Null + + private class NullBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullBucketAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class NullBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "null_bucket_hourly", + "Metrics", + "1 hour", + "Timestamp" + ).WithTimeBucketProperty(null!); + }); + } + } + + [Fact] + public void WithTimeBucketProperty_Throws_When_PropertyName_Null() + { + ArgumentException exception = Assert.Throws(() => + { + using NullBucketContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Equal("propertyName", exception.ParamName); + } + + #endregion + + #region WithTimeBucketProperty_Throws_When_PropertyName_Whitespace + + private class WhitespaceBucketSource + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class WhitespaceBucketAggregate + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class WhitespaceBucketContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "whitespace_bucket_hourly", + "Metrics", + "1 hour", + "Timestamp" + ).WithTimeBucketProperty(" "); + }); + } + } + + [Fact] + public void WithTimeBucketProperty_Throws_When_PropertyName_Whitespace() + { + ArgumentException exception = Assert.Throws(() => + { + using WhitespaceBucketContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Equal("propertyName", exception.ParamName); + } + + #endregion +}