From d9079ac2390ff30cc97b3da9a7e407976735cea8 Mon Sep 17 00:00:00 2001 From: sebastian-ederer Date: Tue, 18 Aug 2026 21:51:44 +0200 Subject: [PATCH] feat: add EF Core [ComplexType] support for all column-referencing APIs --- .claude/reference/architecture.md | 3 +- .claude/reference/file-organization.md | 3 +- docs/04-complex-types.md | 60 +++ .../ChannelizedSensorReadingConfiguration.cs | 21 + .../HourlySensorAggregateConfiguration.cs | 57 ++ .../HourlyStationAggregateConfiguration.cs | 38 ++ .../StationReadingConfiguration.cs | 28 + .../Models/ChannelizedSensorReading.cs | 30 ++ .../Models/Coordinates.cs | 8 + .../Models/HourlySensorAggregate.cs | 11 + .../Models/HourlyStationAggregate.cs | 9 + .../Eftdb.Samples.Shared/Models/Location.cs | 11 + .../Models/SensorChannel.cs | 20 + .../Models/StationReading.cs | 20 + .../Eftdb.Samples.Shared/TimescaleContext.cs | 4 + .../TimescaleDbAnnotationCodeGenerator.cs | 9 +- ...TimeColumnStoreTypeValidationConvention.cs | 22 +- src/Eftdb/Internals/ColumnNameResolver.cs | 120 ++++- .../CompressionAnnotationExtractor.cs | 28 +- src/Eftdb/Internals/ExpressionHelper.cs | 27 +- .../ContinuousAggregateModelExtractor.cs | 4 +- ...olumnStoreTypeValidationConventionTests.cs | 98 ++++ .../ContinuousAggregateModelExtractorTests.cs | 283 ++++++++++ .../HypertableModelExtractorTests.cs | 321 +++++++++++ .../ComplexTypeIntegrationTests.cs | 277 ++++++++++ .../Internals/ColumnNameResolverTests.cs | 499 ++++++++++++++++++ .../Internals/ExpressionHelperTests.cs | 182 +++++++ 27 files changed, 2114 insertions(+), 79 deletions(-) create mode 100644 docs/04-complex-types.md create mode 100644 samples/Eftdb.Samples.Shared/Configurations/ChannelizedSensorReadingConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Configurations/HourlySensorAggregateConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Configurations/HourlyStationAggregateConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Configurations/StationReadingConfiguration.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/ChannelizedSensorReading.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/Coordinates.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/HourlySensorAggregate.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/HourlyStationAggregate.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/Location.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/SensorChannel.cs create mode 100644 samples/Eftdb.Samples.Shared/Models/StationReading.cs create mode 100644 tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs create mode 100644 tests/Eftdb.Tests/Internals/ExpressionHelperTests.cs diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 76cfcbc..6f3fe30 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -174,7 +174,8 @@ Generated migrations call strongly-typed extension methods that construct a `Mig - `Features/FeatureDiffContext.cs` - Cross-cutting diff state passed to every feature differ - `Features/CompressionDiffHelper.cs` - Shared comparison and rewrite helpers for compression differ logic; used by both hypertable and continuous-aggregate differs; provides `AreStringListsEqual`, `AreOrderByListsEqual`, `NormalizeOrderByEntry`, `RewriteColumns`, and `RewriteOrderByColumns` - `CompressionAnnotationExtractor.cs` - Shared helpers for extracting segment-by, order-by, and sparse-index column lists from entity-type annotations with CLR property → database column name resolution; used by both hypertable and continuous-aggregate model extractors -- `ExpressionHelper.cs` - Shared static helper: `GetPropertyName(Expression)` consolidates lambda-to-property-name extraction across the fluent API +- `ExpressionHelper.cs` - Shared static helper: `GetPropertyName(Expression)` extracts CLR property names from selector lambdas; chained member access (e.g. `x => x.Param1.Value`) produces a dot-separated path that `ColumnNameResolver` traverses; rejects static-member and non-parameter-rooted expressions +- `ColumnNameResolver.cs` - Single resolution authority for all column-name lookups: `Resolve` returns the database column name; `ResolveProperty` returns the `IProperty`; both accept a CLR property name, a dot-separated complex-type path, or the column name itself; forward resolution descends via `FindComplexProperty`; reverse lookup walks complex-type trees recursively; complex collections are skipped; used by `CompressionAnnotationExtractor`, `TimeColumnStoreTypeValidationConvention`, and `ContinuousAggregateModelExtractor` - `ParentEntityTypeResolver.cs` - Resolves a continuous aggregate's parent `IEntityType` by matching CLR class name, EF Core short name, or database table name; handles both code-first and scaffolded models **Feature-specific:** diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 7940e90..99721ce 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -153,7 +153,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/ConventionValidationHelper.cs` | Shared validation helpers for conventions: `ValidateExclusiveFields` (XOR guard) and `ParseInitialStart` (DateTime parse with error context) | | `Configuration/TimeColumnStoreTypeValidationConvention.cs` | Model-finalized validation of hypertable & continuous-aggregate time-column store types | | `Internals/TimeColumnStoreTypeValidator.cs` | Allowed PostgreSQL store types for a TimescaleDB time dimension | -| `Internals/ExpressionHelper.cs` | Shared helper consolidating CLR property-name extraction from lambda expressions | +| `Internals/ExpressionHelper.cs` | Shared helper: `GetPropertyName(Expression)` extracts CLR property names from selector lambdas; chained member access (e.g. `x => x.Param1.Value`) yields dot-separated paths that `ColumnNameResolver` traverses | +| `Internals/ColumnNameResolver.cs` | Single resolution authority: `Resolve` (→ column name) and `ResolveProperty` (→ `IProperty`) accept a CLR property name, a dot-separated complex-type path, or the database column name; recursive complex-type traversal in both directions; complex collections are skipped | | `DefaultValues.cs` | Centralized defaults | | `TimescaleDbOptions.cs` | Provider options: `UseLegacyCompressionSql()` for pre-2.18 compatibility | | `Abstractions/Dimension.cs` | Range/hash partitioning | diff --git a/docs/04-complex-types.md b/docs/04-complex-types.md new file mode 100644 index 0000000..03f1f55 --- /dev/null +++ b/docs/04-complex-types.md @@ -0,0 +1,60 @@ +# Complex Type Support + +This library resolves EF Core [complex type](https://learn.microsoft.com/en-us/ef/core/modeling/complex-types) member references in every column-referencing configuration API. A fluent selector may traverse complex-type properties (`x => x.Param1.Value`), and string-based configuration (data annotations, raw column lists) may use the equivalent dot-separated path (`"Param1.Value"`) or the mapped database column name directly. + +Resolution honours all registered naming conventions: a complex property `Value` on complex member `Param1` maps to `Param1_Value` by default and to `param1_value` under EFCore.NamingConventions snake_case, for example. + +--- + +## Supported APIs + +Complex-type member chains resolve in all of the following: + +| Feature | API | +| --- | --- | +| Hypertable time column | `IsHypertable(x => x.Meta.Timestamp)`, `[Hypertable("Meta.Timestamp")]` | +| Additional dimensions | `HasRangeDimension(x => x.Meta.Region, ...)`, `HasHashDimension(...)` | +| Chunk-skip columns | `WithChunkSkipping(x => x.Meta.DeviceId)` | +| Compression segment-by | `WithCompressionSegmentBy(x => x.Meta.TenantId)` | +| Compression order-by | `s => [s.ByDescending(x => x.Meta.Timestamp)]` | +| Sparse indexes | `s => s.Bloom(x => x.Meta.DeviceId)`, `s => s.MinMax(...)` | +| Continuous aggregate time bucket | `IsContinuousAggregate(..., x => x.Meta.Timestamp, ...)` | +| Aggregate functions | `AddAggregateFunction(a => a.Avg, d => d.Param1.Value, EAggregateFunction.Avg)` | +| Group-by columns | `AddGroupByColumn(x => x.Param1.Name)` | + +Nested complex types (`x => x.Outer.Inner.Value`) resolve recursively. + +```csharp +[ComplexType] +public class SensorChannel +{ + public string Name { get; set; } = string.Empty; + public double Value { get; set; } +} + +public class Reading +{ + public Guid Id { get; set; } + public DateTime RecordedAt { get; set; } + public SensorChannel Primary { get; set; } = new(); + public SensorChannel Secondary { get; set; } = new(); +} +``` + +```csharp +builder.IsContinuousAggregate(x => x.RecordedAt, "1 hour") + .AddAggregateFunction(a => a.AvgPrimary, d => d.Primary.Value, EAggregateFunction.Avg) + .AddAggregateFunction(a => a.AvgSecondary, d => d.Secondary.Value, EAggregateFunction.Avg) + .AddGroupByColumn(d => d.Primary.Name); +``` + +The time column of a hypertable or continuous aggregate may live inside a complex type; the store-type validation at model finalization traverses the path the same way and throws for invalid store types exactly as for top-level properties. + +--- + +## Limitations + +- **JSON-mapped complex types** (`ComplexProperty(...).ToJson()`): properties inside a JSON-mapped complex type do not have individual table columns. References to them do not resolve and the configuration entry is skipped. +- **Complex type collections** (EF Core 10): collections have no per-element columns; paths through a collection complex property do not resolve. +- **Owned entity types** are not traversed. Complex-type support covers `[ComplexType]` / `ComplexProperty(...)` mappings only; a path through an owned navigation does not resolve. +- **Scaffolding** produces flat entities: `dotnet ef dbcontext scaffold` never generates `[ComplexType]` declarations, so a scaffolded model represents complex-type columns as ordinary flat properties. Round-tripping a complex-type model through scaffolding yields an equivalent flat model with no phantom migration diffs, because annotation values store resolved database column names that the resolver recognises in column-name form. diff --git a/samples/Eftdb.Samples.Shared/Configurations/ChannelizedSensorReadingConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/ChannelizedSensorReadingConfiguration.cs new file mode 100644 index 0000000..fb09af2 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/ChannelizedSensorReadingConfiguration.cs @@ -0,0 +1,21 @@ +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 +{ + public class ChannelizedSensorReadingConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("channelized_sensor_readings"); + + builder.IsHypertable(x => x.RecordedAt) + .WithChunkTimeInterval("1 day") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy( + s => s.ByDescending(x => x.RecordedAt)); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/HourlySensorAggregateConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/HourlySensorAggregateConfiguration.cs new file mode 100644 index 0000000..d3f0683 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/HourlySensorAggregateConfiguration.cs @@ -0,0 +1,57 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + public class HourlySensorAggregateConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + materializedViewName: "hourly_sensor_aggregates", + timeBucketWidth: "1 hour", + propertyExpression: source => source.RecordedAt, + timeBucketGroupBy: true) + + // Aggregate functions whose source columns are complex-type members. + // The selector `source => source.Primary.Value` produces the path + // "Primary.Value" which is resolved to the mapped column name at + // migration generation time. + .AddAggregateFunction( + agg => agg.AvgPrimaryValue, + source => source.Primary.Value, + EAggregateFunction.Avg) + .AddAggregateFunction( + agg => agg.MinPrimaryValue, + source => source.Primary.Value, + EAggregateFunction.Min) + .AddAggregateFunction( + agg => agg.MaxPrimaryValue, + source => source.Primary.Value, + EAggregateFunction.Max) + + // Cross-channel aggregate: secondary value average. + .AddAggregateFunction( + agg => agg.AvgSecondaryValue, + source => source.Secondary.Value, + EAggregateFunction.Avg) + + .AddAggregateFunction( + agg => agg.ReadingCount, + source => source.RecordedAt, + EAggregateFunction.Count) + + // Group by a complex-type member: the channel name on the primary channel. + // Resolves to the mapped column for Primary.Name (e.g. "primary_name"). + .AddGroupByColumn(source => source.Primary.Name) + + // Also group by device so each bucket is per-device, per-channel-name. + .AddGroupByColumn(source => source.DeviceId); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/HourlyStationAggregateConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/HourlyStationAggregateConfiguration.cs new file mode 100644 index 0000000..b4a7cac --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/HourlyStationAggregateConfiguration.cs @@ -0,0 +1,38 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +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 continuous aggregate. + /// Demonstrates two-hop nested complex-type column resolution. + /// + public class HourlyStationAggregateConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + materializedViewName: "hourly_station_aggregates", + timeBucketWidth: "1 hour", + propertyExpression: source => source.RecordedAt, + timeBucketGroupBy: true) + + .AddAggregateFunction( + agg => agg.AvgLatitude, + source => source.Location.Coordinates.Latitude, + EAggregateFunction.Avg) + + .AddAggregateFunction( + agg => agg.AvgTemperature, + source => source.Temperature, + EAggregateFunction.Avg) + + .AddGroupByColumn(source => source.Location.Site); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/StationReadingConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/StationReadingConfiguration.cs new file mode 100644 index 0000000..459d03d --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/StationReadingConfiguration.cs @@ -0,0 +1,28 @@ +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 . + /// Explicitly registers the two-level complex-type hierarchy. + /// + public class StationReadingConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("station_readings"); + builder.HasKey(x => new { x.Id, x.RecordedAt }); + + builder.ComplexProperty(x => x.Location, l => + l.ComplexProperty(c => c.Coordinates)); + + builder.IsHypertable(x => x.RecordedAt) + .WithChunkTimeInterval("1 day") + .WithCompressionSegmentBy(x => x.Location.Site) + .EnableCompression(); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/ChannelizedSensorReading.cs b/samples/Eftdb.Samples.Shared/Models/ChannelizedSensorReading.cs new file mode 100644 index 0000000..f1719f0 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/ChannelizedSensorReading.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + /// + /// An IoT sensor reading that exposes two measurement channels as EF Core complex-type + /// properties. + /// + [PrimaryKey(nameof(Id), nameof(RecordedAt))] + public class ChannelizedSensorReading + { + public Guid Id { get; set; } + public DateTime RecordedAt { get; set; } + public string DeviceId { get; set; } = string.Empty; + + /// + /// Primary measurement channel (e.g. temperature in °C). + /// Maps to columns Primary_Name and Primary_Value by default; + /// snake_case convention yields primary_name / primary_value. + /// + public SensorChannel Primary { get; set; } = new(); + + /// + /// Secondary measurement channel (e.g. humidity in %). + /// Maps to columns Secondary_Name and Secondary_Value by default; + /// snake_case convention yields secondary_name / secondary_value. + /// + public SensorChannel Secondary { get; set; } = new(); + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/Coordinates.cs b/samples/Eftdb.Samples.Shared/Models/Coordinates.cs new file mode 100644 index 0000000..a045645 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/Coordinates.cs @@ -0,0 +1,8 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + public class Coordinates + { + public double Latitude { get; set; } + public double Longitude { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/HourlySensorAggregate.cs b/samples/Eftdb.Samples.Shared/Models/HourlySensorAggregate.cs new file mode 100644 index 0000000..79c92eb --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/HourlySensorAggregate.cs @@ -0,0 +1,11 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + public class HourlySensorAggregate + { + public double AvgPrimaryValue { get; set; } + public double MinPrimaryValue { get; set; } + public double MaxPrimaryValue { get; set; } + public double AvgSecondaryValue { get; set; } + public long ReadingCount { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/HourlyStationAggregate.cs b/samples/Eftdb.Samples.Shared/Models/HourlyStationAggregate.cs new file mode 100644 index 0000000..9d08773 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/HourlyStationAggregate.cs @@ -0,0 +1,9 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + public class HourlyStationAggregate + { + public DateTime Bucket { get; set; } + public double AvgLatitude { get; set; } + public double AvgTemperature { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/Location.cs b/samples/Eftdb.Samples.Shared/Models/Location.cs new file mode 100644 index 0000000..18b0af6 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/Location.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + [ComplexType] + public class Location + { + public string Site { get; set; } = string.Empty; + public Coordinates Coordinates { get; set; } = new(); + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/SensorChannel.cs b/samples/Eftdb.Samples.Shared/Models/SensorChannel.cs new file mode 100644 index 0000000..954ca3d --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/SensorChannel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + /// + /// Represents a single measurement channel owned by a sensor reading. + /// Declared with so EF Core maps its scalar + /// properties as columns directly on the owning table rather than a separate table. + /// Default column names follow EF Core's complex-type convention: + /// {PropertyName}_{MemberName} (e.g. Primary_Name, Primary_Value). + /// Under a snake_case naming convention the columns become + /// primary_name / primary_value etc. + /// + [ComplexType] + public class SensorChannel + { + public string Name { get; set; } = string.Empty; + public double Value { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/StationReading.cs b/samples/Eftdb.Samples.Shared/Models/StationReading.cs new file mode 100644 index 0000000..67a727a --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/StationReading.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + [PrimaryKey(nameof(Id), nameof(RecordedAt))] + public class StationReading + { + public Guid Id { get; set; } + public DateTime RecordedAt { get; set; } + public double Temperature { get; set; } + + /// + /// Geographic location of the monitoring station. + /// Contains a nested complex type, producing columns + /// such as Location_Site, Location_Coordinates_Latitude, and + /// Location_Coordinates_Longitude on the station_readings table. + /// + public Location Location { get; set; } = new(); + } +} diff --git a/samples/Eftdb.Samples.Shared/TimescaleContext.cs b/samples/Eftdb.Samples.Shared/TimescaleContext.cs index bd88c6f..38f0b40 100644 --- a/samples/Eftdb.Samples.Shared/TimescaleContext.cs +++ b/samples/Eftdb.Samples.Shared/TimescaleContext.cs @@ -19,6 +19,10 @@ public class TimescaleContext(DbContextOptions options) : DbCo public DbSet ApiRequestLogs { get; set; } public DbSet ApiRequestAggregates { get; set; } public DbSet MetricSnapshots { get; set; } + public DbSet ChannelizedSensorReadings { get; set; } + public DbSet HourlySensorAggregates { get; set; } + public DbSet StationReadings { get; set; } + public DbSet HourlyStationAggregates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index a0eb8e4..57dd22b 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -218,13 +218,8 @@ private static object ResolveSourceArgByColumnName(string columnName, IEntityTyp { if (columnName == "*") return "*"; if (parentEntityType is null) return columnName; - string parentTableName = parentEntityType.GetTableName() ?? parentEntityType.Name; - string? parentSchema = parentEntityType.GetSchema(); - StoreObjectIdentifier parentStoreId = StoreObjectIdentifier.Table(parentTableName, parentSchema); - IProperty? parentProp = parentEntityType.GetProperties() - .FirstOrDefault(p => (p.GetColumnName(parentStoreId) ?? p.Name) == columnName); - return parentProp is not null - ? new NameOfCodeFragment($"{parentEntityType.ShortName()}.{parentProp.Name}") + return AnnotationRendererHelper.TryResolvePropertyName(parentEntityType, columnName, out string propertyName) + ? new NameOfCodeFragment($"{parentEntityType.ShortName()}.{propertyName}") : (object)columnName; } } diff --git a/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs b/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs index 9d4b32b..218949e 100644 --- a/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs +++ b/src/Eftdb/Configuration/TimeColumnStoreTypeValidationConvention.cs @@ -44,7 +44,7 @@ private static void ValidateHypertableTimeColumn(IEntityType entityType) return; } - IProperty? property = ResolveProperty(entityType, timeColumnName); + IProperty? property = ColumnNameResolver.ResolveProperty(entityType, timeColumnName, GetStoreObjectIdentifier(entityType)); if (property == null) { // Unresolvable column names are left to the model extractor, which skips them; this keeps @@ -92,7 +92,7 @@ private static void ValidateContinuousAggregateTimeColumn(IModel model, IEntityT return; } - IProperty? property = ResolveProperty(parentEntityType, sourceColumnName); + IProperty? property = ColumnNameResolver.ResolveProperty(parentEntityType, sourceColumnName, GetStoreObjectIdentifier(parentEntityType)); if (property == null) { return; @@ -124,24 +124,6 @@ internal static void EnsureValidTimeColumn(string? columnType, string? mappingSt } } - private static IProperty? ResolveProperty(IEntityType entityType, string nameOrColumn) - { - IProperty? direct = entityType.FindProperty(nameOrColumn); - if (direct != null) - { - return direct; - } - - StoreObjectIdentifier? storeIdentifier = GetStoreObjectIdentifier(entityType); - if (storeIdentifier == null) - { - return null; - } - - return entityType.GetProperties() - .FirstOrDefault(p => string.Equals(p.GetColumnName(storeIdentifier.Value), nameOrColumn, StringComparison.Ordinal)); - } - private static StoreObjectIdentifier? GetStoreObjectIdentifier(IEntityType entityType) { string? tableName = entityType.GetTableName(); diff --git a/src/Eftdb/Internals/ColumnNameResolver.cs b/src/Eftdb/Internals/ColumnNameResolver.cs index e3948db..488f5ab 100644 --- a/src/Eftdb/Internals/ColumnNameResolver.cs +++ b/src/Eftdb/Internals/ColumnNameResolver.cs @@ -4,9 +4,10 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals { /// - /// Resolves a name to a database column name on a given entity, accepting either - /// the CLR property name (canonical for code-first usage including EFCore.NamingConventions) - /// or the database column name itself (form emitted by the design-time scaffolder). + /// Resolves a name to a database column on a given entity, accepting the CLR property name + /// (canonical for code-first usage including EFCore.NamingConventions), a dot-separated path + /// through complex-type properties (e.g. "Param1.Value"), or the database column name + /// itself (form emitted by the design-time scaffolder). /// internal static class ColumnNameResolver { @@ -15,11 +16,13 @@ internal static class ColumnNameResolver /// , or null if no matching property exists. /// /// - /// Resolution is two-step: first by CLR property name (so naming-convention plugins - /// translate to the actual store column), then by reverse lookup against each - /// property's resolved column name (so a value already in column-name form is - /// recognised). Both steps consult GetColumnName(StoreObjectIdentifier), - /// which honours all registered conventions. + /// Resolution is two-step: first by CLR property name or complex-type path (so + /// naming-convention plugins translate to the actual store column), then by reverse + /// lookup against each property's resolved column name including complex-type + /// properties (so a value already in column-name form is recognised). Both steps + /// consult GetColumnName(StoreObjectIdentifier), which honours all registered + /// conventions. Properties without a column in the store object (e.g. complex types + /// mapped to JSON) resolve to null. /// public static string? Resolve(IEntityType entityType, string? nameOrColumn, StoreObjectIdentifier storeIdentifier) { @@ -28,18 +31,105 @@ internal static class ColumnNameResolver return null; } - string? viaClrName = entityType.FindProperty(nameOrColumn)?.GetColumnName(storeIdentifier); - if (!string.IsNullOrWhiteSpace(viaClrName)) + string? viaClrPath = FindPropertyByPath(entityType, nameOrColumn, ignoreCase: false)?.GetColumnName(storeIdentifier); + if (!string.IsNullOrWhiteSpace(viaClrPath)) { - return viaClrName; + return viaClrPath; } - foreach (IProperty property in entityType.GetProperties()) + return FindPropertyByColumnName(entityType, nameOrColumn, storeIdentifier, StringComparison.Ordinal)?.GetColumnName(storeIdentifier); + } + + /// + /// Returns the for on + /// , or null if no matching property exists. + /// Reverse column-name lookup requires and is + /// skipped when it is null. + /// + public static IProperty? ResolveProperty(IEntityType entityType, string? nameOrColumn, StoreObjectIdentifier? storeIdentifier, bool ignoreCase = false) + { + if (string.IsNullOrWhiteSpace(nameOrColumn)) + { + return null; + } + + IProperty? viaPath = FindPropertyByPath(entityType, nameOrColumn, ignoreCase); + if (viaPath != null) + { + return viaPath; + } + + if (storeIdentifier == null) { - string? columnName = property.GetColumnName(storeIdentifier); - if (string.Equals(columnName, nameOrColumn, StringComparison.Ordinal)) + return null; + } + + StringComparison comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return FindPropertyByColumnName(entityType, nameOrColumn, storeIdentifier.Value, comparison); + } + + private static IProperty? FindPropertyByPath(ITypeBase typeBase, string path, bool ignoreCase) + { + string[] segments = path.Split('.'); + ITypeBase current = typeBase; + + for (int i = 0; i < segments.Length - 1; i++) + { + IComplexProperty? complexProperty = FindComplexProperty(current, segments[i], ignoreCase); + if (complexProperty == null || complexProperty.IsCollection) + { + return null; + } + + current = complexProperty.ComplexType; + } + + return FindScalarProperty(current, segments[^1], ignoreCase); + } + + private static IProperty? FindScalarProperty(ITypeBase typeBase, string name, bool ignoreCase) + { + IProperty? exact = typeBase.FindProperty(name); + if (exact != null || !ignoreCase) + { + return exact; + } + + return typeBase.GetProperties().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static IComplexProperty? FindComplexProperty(ITypeBase typeBase, string name, bool ignoreCase) + { + IComplexProperty? exact = typeBase.FindComplexProperty(name); + if (exact != null || !ignoreCase) + { + return exact; + } + + return typeBase.GetComplexProperties().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static IProperty? FindPropertyByColumnName(ITypeBase typeBase, string columnName, StoreObjectIdentifier storeIdentifier, StringComparison comparison) + { + foreach (IProperty property in typeBase.GetProperties()) + { + if (string.Equals(property.GetColumnName(storeIdentifier), columnName, comparison)) + { + return property; + } + } + + foreach (IComplexProperty complexProperty in typeBase.GetComplexProperties()) + { + if (complexProperty.IsCollection) + { + continue; + } + + IProperty? nested = FindPropertyByColumnName(complexProperty.ComplexType, columnName, storeIdentifier, comparison); + if (nested != null) { - return columnName; + return nested; } } diff --git a/src/Eftdb/Internals/CompressionAnnotationExtractor.cs b/src/Eftdb/Internals/CompressionAnnotationExtractor.cs index 11be5fb..cfce947 100644 --- a/src/Eftdb/Internals/CompressionAnnotationExtractor.cs +++ b/src/Eftdb/Internals/CompressionAnnotationExtractor.cs @@ -165,31 +165,9 @@ internal static IEnumerable SplitSparseIndexEntries(string value) /// internal static string ResolveColumnName(IEntityType entityType, StoreObjectIdentifier storeIdentifier, string propertyName) { - string? exact = entityType.FindProperty(propertyName)?.GetColumnName(storeIdentifier); - if (!string.IsNullOrEmpty(exact)) - { - return exact; - } - - foreach (IProperty property in entityType.GetProperties()) - { - if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) - { - string? resolved = property.GetColumnName(storeIdentifier); - if (!string.IsNullOrEmpty(resolved)) - { - return resolved; - } - } - - string? columnName = property.GetColumnName(storeIdentifier); - if (string.Equals(columnName, propertyName, StringComparison.OrdinalIgnoreCase)) - { - return columnName!; - } - } - - return propertyName; + IProperty? property = ColumnNameResolver.ResolveProperty(entityType, propertyName, storeIdentifier, ignoreCase: true); + string? resolved = property?.GetColumnName(storeIdentifier); + return string.IsNullOrEmpty(resolved) ? propertyName : resolved; } } } diff --git a/src/Eftdb/Internals/ExpressionHelper.cs b/src/Eftdb/Internals/ExpressionHelper.cs index 2651569..87c5589 100644 --- a/src/Eftdb/Internals/ExpressionHelper.cs +++ b/src/Eftdb/Internals/ExpressionHelper.cs @@ -8,23 +8,34 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals internal static class ExpressionHelper { /// - /// Extracts the property name from a simple property access expression, - /// unwrapping boxing conversions produced by object-typed selectors. + /// Extracts the property name from a property access expression, unwrapping boxing + /// conversions produced by object-typed selectors. A chained access through + /// complex-type members (e.g. x => x.Param1.Value) yields a dot-separated + /// path ("Param1.Value") that traverses. /// - /// Thrown when the expression is not a simple property access. + /// Thrown when the expression is not a property access rooted in the lambda parameter. internal static string GetPropertyName(Expression> propertyExpression) { - if (propertyExpression.Body is MemberExpression memberExpression) + Expression? body = propertyExpression.Body; + if (body is UnaryExpression unaryExpression) { - return memberExpression.Member.Name; + body = unaryExpression.Operand; } - if (propertyExpression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression unaryMemberExpression) + List segments = []; + while (body is MemberExpression memberExpression) { - return unaryMemberExpression.Member.Name; + segments.Add(memberExpression.Member.Name); + body = memberExpression.Expression; } - throw new ArgumentException("Expression must be a simple property access expression.", nameof(propertyExpression)); + if (segments.Count == 0 || body is not ParameterExpression) + { + throw new ArgumentException("Expression must be a simple property access expression.", nameof(propertyExpression)); + } + + segments.Reverse(); + return string.Join('.', segments); } } } diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index 524b3d5..10194f2 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -154,7 +154,7 @@ private static List ResolveAggregateFunctions( } // Resolve alias column name from aggregate entity to respect naming conventions - string? aliasDbName = entityType.FindProperty(aliasModelName)?.GetColumnName(aggregateStoreIdentifier); + string? aliasDbName = ColumnNameResolver.Resolve(entityType, aliasModelName, aggregateStoreIdentifier); if (string.IsNullOrWhiteSpace(aliasDbName)) { aliasDbName = aliasModelName; @@ -181,7 +181,7 @@ private static List ResolveGroupByColumns( foreach (string modelColumn in modelGroupByColumns) { // Try to resolve as a property name from the parent entity - string? dbColumnName = parentEntityType.FindProperty(modelColumn)?.GetColumnName(parentStoreIdentifier); + string? dbColumnName = ColumnNameResolver.Resolve(parentEntityType, modelColumn, parentStoreIdentifier); groupByColumns.Add(!string.IsNullOrWhiteSpace(dbColumnName) ? dbColumnName : modelColumn); } diff --git a/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs b/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs index 0af140e..7395889 100644 --- a/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using NodaTime; +using System.ComponentModel.DataAnnotations.Schema; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; @@ -740,4 +741,101 @@ public void EnsureValidTimeColumn_Throws_When_Resolved_Store_Type_Invalid() } #endregion + + // ── Complex-type support ── + + #region Should_Allow_ComplexType_TimeColumn_With_Valid_DateTime_Store_Type + + [ComplexType] + private class ValidComplexMeta + { + public DateTime Timestamp { get; set; } + } + + private class ValidComplexTimeEntity + { + public double Value { get; set; } + public ValidComplexMeta Meta { get; set; } = new(); + } + + private class ValidComplexTimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("validation_complex_valid_time"); + entity.IsHypertable(x => x.Meta.Timestamp); + }); + } + } + + [Fact] + public void Should_Allow_ComplexType_TimeColumn_With_Valid_DateTime_Store_Type() + { + // Arrange & Act + using ValidComplexTimeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ValidComplexTimeEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + } + + #endregion + + #region Should_Throw_When_ComplexType_TimeColumn_Has_Invalid_Store_Type + + [ComplexType] + private class InvalidComplexMeta + { + public string Tag { get; set; } = string.Empty; + } + + private class InvalidComplexTimeEntity + { + public double Value { get; set; } + public InvalidComplexMeta Meta { get; set; } = new(); + } + + private class InvalidComplexTimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("validation_complex_invalid_time"); + entity.IsHypertable(x => x.Meta.Tag); + }); + } + } + + [Fact] + public void Should_Throw_When_ComplexType_TimeColumn_Has_Invalid_Store_Type() + { + // Arrange & Act & Assert + InvalidOperationException exception = Assert.Throws(() => + { + using InvalidComplexTimeContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("not a valid TimescaleDB time dimension", exception.Message); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 513306b..6693163 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using System.ComponentModel.DataAnnotations.Schema; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; @@ -2626,4 +2627,286 @@ public void Should_Use_View_Schema_When_ToView_Specifies_Custom_Schema() } #endregion + + // ── Complex-type support ── + + #region Should_Resolve_AggregateFunction_Source_Column_Inside_ComplexType + + [ComplexType] + private class ComplexMeasurement1 + { + public double Value { get; set; } + } + + private class ComplexAggSourceMetric + { + public DateTime Timestamp { get; set; } + public ComplexMeasurement1 Param1 { get; set; } = new(); + } + + private class ComplexAggHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ComplexAggFunctionContext : 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("complex_agg_src_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "complex_agg_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Param1.Value, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public void Should_Resolve_AggregateFunction_Source_Column_Inside_ComplexType() + { + // Arrange + using ComplexAggFunctionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + // Assert + CreateContinuousAggregateOperation operation = Assert.Single(operations); + Assert.Single(operation.AggregateFunctions); + Assert.Equal("AvgValue:Avg:Param1_Value", operation.AggregateFunctions[0]); + } + + #endregion + + #region Should_Resolve_AggregateFunction_Source_Column_Inside_ComplexType_Under_SnakeCase + + [ComplexType] + private class ComplexMeasurement2 + { + public double SensorValue { get; set; } + } + + private class ComplexAggSnakeSourceMetric + { + public DateTime Timestamp { get; set; } + public ComplexMeasurement2 Param1 { get; set; } = new(); + } + + private class ComplexAggSnakeHourlyMetric + { + public DateTime Bucket { get; set; } + public double AvgSensorValue { get; set; } + } + + private class ComplexAggSnakeCaseContext : 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("complex_agg_snake_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "complex_agg_snake_hourly", + "1 hour", + x => x.Timestamp + ).AddAggregateFunction( + x => x.AvgSensorValue, + x => x.Param1.SensorValue, + EAggregateFunction.Avg + ); + }); + } + } + + [Fact] + public void Should_Resolve_AggregateFunction_Source_Column_Inside_ComplexType_Under_SnakeCase() + { + // Arrange + using ComplexAggSnakeCaseContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + // Assert + CreateContinuousAggregateOperation operation = Assert.Single(operations); + Assert.Single(operation.AggregateFunctions); + Assert.Equal("avg_sensor_value:Avg:param1_sensor_value", operation.AggregateFunctions[0]); + } + + #endregion + + #region Should_Resolve_GroupBy_On_ComplexType_Member + + [ComplexType] + private class ComplexMeasurement3 + { + public string DeviceId { get; set; } = string.Empty; + } + + private class ComplexGroupBySourceMetric + { + public DateTime Timestamp { get; set; } + public ComplexMeasurement3 Param1 { get; set; } = new(); + public double Value { get; set; } + } + + private class ComplexGroupByHourlyMetric + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class ComplexGroupByContext : 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("complex_grp_by_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "complex_grp_by_hourly", + "1 hour", + x => x.Timestamp + ).AddGroupByColumn(x => x.Param1.DeviceId); + }); + } + } + + [Fact] + public void Should_Resolve_GroupBy_On_ComplexType_Member() + { + // Arrange + using ComplexGroupByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + // Assert + CreateContinuousAggregateOperation operation = Assert.Single(operations); + Assert.Single(operation.GroupByColumns); + Assert.Equal("Param1_DeviceId", operation.GroupByColumns[0]); + } + + #endregion + + #region Should_Resolve_TimeBucket_Source_Inside_ComplexType + + [ComplexType] + private class ComplexMeasurement4 + { + public DateTime Timestamp { get; set; } + } + + private class ComplexTimeBucketSourceMetric + { + public double Value { get; set; } + public ComplexMeasurement4 Meta { get; set; } = new(); + } + + private class ComplexTimeBucketHourlyMetric + { + public DateTime Bucket { get; set; } + } + + private class ComplexTimeBucketContext : 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("complex_tb_src_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "complex_tb_hourly", + "1 hour", + x => x.Meta.Timestamp + ); + }); + } + } + + [Fact] + public void Should_Resolve_TimeBucket_Source_Inside_ComplexType() + { + // Arrange + using ComplexTimeBucketContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; + + // Assert + CreateContinuousAggregateOperation operation = Assert.Single(operations); + Assert.Equal("Meta_Timestamp", operation.TimeBucketSourceColumn); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index 1ee4ce2..36e482c 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; @@ -1554,4 +1555,324 @@ public void Should_Skip_Entity_When_TimeColumn_Cannot_Be_Resolved() } #endregion + + // ── Complex-type support ── + + #region Should_Extract_TimeColumn_Inside_ComplexType + + [ComplexType] + private class MetaA + { + public DateTime Timestamp { get; set; } + } + + private class ComplexTimeMetric + { + public double Value { get; set; } + public MetaA Meta { get; set; } = new(); + } + + private class ComplexTimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_time_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp); + }); + } + } + + [Fact] + public void Should_Extract_TimeColumn_Inside_ComplexType() + { + // Arrange + using ComplexTimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.Equal("Meta_Timestamp", operation.TimeColumnName); + } + + #endregion + + #region Should_Extract_ChunkSkipColumn_On_ComplexType_Member + + [ComplexType] + private class MetaB + { + public string DeviceId { get; set; } = string.Empty; + } + + private class ComplexChunkSkipMetric + { + public DateTime Timestamp { get; set; } + public MetaB Meta { get; set; } = new(); + public double Value { get; set; } + } + + private class ComplexChunkSkipContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_chunk_skip_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkSkipping(x => x.Meta.DeviceId); + }); + } + } + + [Fact] + public void Should_Extract_ChunkSkipColumn_On_ComplexType_Member() + { + // Arrange + using ComplexChunkSkipContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.NotNull(operation.ChunkSkipColumns); + string column = Assert.Single(operation.ChunkSkipColumns!); + Assert.Equal("Meta_DeviceId", column); + } + + #endregion + + #region Should_Extract_CompressionSegmentBy_On_ComplexType_Member + + [ComplexType] + private class MetaC + { + public string TenantId { get; set; } = string.Empty; + } + + private class ComplexSegmentByMetric + { + public DateTime Timestamp { get; set; } + public MetaC Meta { get; set; } = new(); + public double Value { get; set; } + } + + private class ComplexSegmentByContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_seg_by_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.Meta.TenantId); + }); + } + } + + [Fact] + public void Should_Extract_CompressionSegmentBy_On_ComplexType_Member() + { + // Arrange + using ComplexSegmentByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.NotNull(operation.CompressionSegmentBy); + Assert.Equal("Meta_TenantId", Assert.Single(operation.CompressionSegmentBy)); + Assert.True(operation.EnableCompression); + } + + #endregion + + #region Should_Extract_CompressionOrderBy_On_ComplexType_Member + + [ComplexType] + private class MetaD + { + public DateTime Timestamp { get; set; } + } + + private class ComplexOrderByMetric + { + public double Value { get; set; } + public MetaD Meta { get; set; } = new(); + } + + private class ComplexOrderByContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_order_by_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Meta.Timestamp) + ]); + }); + } + } + + [Fact] + public void Should_Extract_CompressionOrderBy_On_ComplexType_Member() + { + // Arrange + using ComplexOrderByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.NotNull(operation.CompressionOrderBy); + Assert.Equal("Meta_Timestamp DESC", Assert.Single(operation.CompressionOrderBy)); + Assert.True(operation.EnableCompression); + } + + #endregion + + #region Should_Extract_SparseIndex_Bloom_On_ComplexType_Member + + [ComplexType] + private class MetaE + { + public string DeviceId { get; set; } = string.Empty; + } + + private class ComplexSparseIndexMetric + { + public DateTime Timestamp { get; set; } + public MetaE Meta { get; set; } = new(); + public double Value { get; set; } + } + + private class ComplexSparseIndexContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_sparse_idx_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]) + .WithSparseIndex(s => s.Bloom(x => x.Meta.DeviceId)); + }); + } + } + + [Fact] + public void Should_Extract_SparseIndex_Bloom_On_ComplexType_Member() + { + // Arrange + using ComplexSparseIndexContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.Contains("bloom(Meta_DeviceId)", operation.CompressionSparseIndex); + Assert.True(operation.EnableCompression); + } + + #endregion + + #region Should_Resolve_TimeColumn_Inside_ComplexType_Under_SnakeCase + + [ComplexType] + private class MetaF + { + public DateTime TimestampUtc { get; set; } + } + + private class ComplexSnakeTimeMetric + { + public double Value { get; set; } + public MetaF Meta { get; set; } = new(); + } + + private class ComplexSnakeTimeContext : DbContext + { + public DbSet Metrics => 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("complex_snake_time_metrics"); + entity.IsHypertable(x => x.Meta.TimestampUtc); + }); + } + } + + [Fact] + public void Should_Resolve_TimeColumn_Inside_ComplexType_Under_SnakeCase() + { + // Arrange + using ComplexSnakeTimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + // Assert + CreateHypertableOperation operation = Assert.Single(operations); + Assert.Equal("meta_timestamp_utc", operation.TimeColumnName); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs new file mode 100644 index 0000000..564419a --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs @@ -0,0 +1,277 @@ +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; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using System.ComponentModel.DataAnnotations.Schema; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ComplexTypeIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + GC.SuppressFinalize(this); + } + + private async Task GetIsolatedConnectionStringAsync() + { + string dbName = $"test_db_{Guid.NewGuid():N}"; + + await using NpgsqlConnection admin = new(_connectionString); + await admin.OpenAsync(); + await using (NpgsqlCommand cmd = new($"CREATE DATABASE {dbName}", admin)) + { + await cmd.ExecuteNonQueryAsync(); + } + + string isolated = _connectionString!.Replace("test_db", dbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection conn = new(isolated); + await conn.OpenAsync(); + await using (NpgsqlCommand ext = new("CREATE EXTENSION IF NOT EXISTS timescaledb", conn)) + { + await ext.ExecuteNonQueryAsync(); + } + + return isolated; + } + + private static async Task ContinuousAggregateExistsAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.continuous_aggregates + WHERE view_name = @viewName;"; + cmd.Parameters.AddWithValue("viewName", viewName); + object? result = await cmd.ExecuteScalarAsync(); + return result is bool b && b; + } + + private static async Task IsHypertableAsync(string connectionString, string tableName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName;"; + cmd.Parameters.AddWithValue("tableName", tableName); + object? result = await cmd.ExecuteScalarAsync(); + return result is bool b && b; + } + + private static IReadOnlyList GetOperations(DbContext? source, DbContext target) + { + IMigrationsModelDiffer differ = target.GetService(); + IRelationalModel? sourceModel = source?.GetService().Model.GetRelationalModel(); + IRelationalModel targetModel = target.GetService().Model.GetRelationalModel(); + return differ.GetDifferences(sourceModel, targetModel); + } + + // ── Tests ────────────────────────────────────────────────────────────────── + + #region Should_Create_Hypertable_With_ComplexType_TimeColumn + + [ComplexType] + private class HtMeta1 + { + public DateTime Timestamp { get; set; } + } + + private class HtComplexEntity1 + { + public double Value { get; set; } + public HtMeta1 Meta { get; set; } = new(); + } + + private class HtComplexContext1(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ct_ht1_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_ComplexType_TimeColumn() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + + // Act + await using HtComplexContext1 ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Assert + bool isHypertable = await IsHypertableAsync(conn, "ct_ht1_metrics"); + Assert.True(isHypertable); + } + + #endregion + + #region Should_Create_ContinuousAggregate_With_ComplexType_AggregateFunction_And_GroupBy + + [ComplexType] + private class CaMeta2 + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class CaSource2 + { + public double Value { get; set; } + public CaMeta2 Meta { get; set; } = new(); + } + + private class CaAggregate2 + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + private class CaComplexContext2(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ct_ca2_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("ct_ca2_hourly"); + entity.IsContinuousAggregate( + "ct_ca2_hourly", + "1 hour", + x => x.Meta.Timestamp + ).AddAggregateFunction( + x => x.AvgValue, + x => x.Value, + EAggregateFunction.Avg + ).AddGroupByColumn(x => x.Meta.DeviceId); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.Property(x => x.DeviceId).HasColumnName("DeviceId"); + }); + } + } + + [Fact] + public async Task Should_Create_ContinuousAggregate_With_ComplexType_AggregateFunction_And_GroupBy() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + + // Act + await using CaComplexContext2 ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Assert + bool caExists = await ContinuousAggregateExistsAsync(conn, "ct_ca2_hourly"); + Assert.True(caExists); + } + + #endregion + + #region Should_Produce_Zero_Operations_On_Round_Trip_Diff_With_ComplexType + + [ComplexType] + private class RtMeta3 + { + public DateTime Timestamp { get; set; } + public string TenantId { get; set; } = string.Empty; + } + + private class RtSource3 + { + public double Value { get; set; } + public RtMeta3 Meta { get; set; } = new(); + } + + private class RtContext3(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ct_rt3_metrics"); + entity.IsHypertable(x => x.Meta.Timestamp) + .WithCompressionSegmentBy(x => x.Meta.TenantId); + }); + } + } + + [Fact] + public async Task Should_Produce_Zero_Operations_On_Round_Trip_Diff_With_ComplexType() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + await using RtContext3 initial = new(conn); + await CreateDatabaseViaMigrationAsync(initial); + + // Act + await using RtContext3 same = new(conn); + IReadOnlyList ops = GetOperations(initial, same); + + // Assert + Assert.Empty(ops); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs index 538d7f0..006297b 100644 --- a/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs +++ b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using System.ComponentModel.DataAnnotations.Schema; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; @@ -236,4 +237,502 @@ public void Should_Return_Null_For_Null_Or_Whitespace_Input(string? input) } #endregion + + // ── Complex-type support ── + + #region Should_Resolve_Dotted_Path_On_ComplexType_Property_To_Default_Column + + [ComplexType] + private class MeasurementParams1 + { + public double Value { get; set; } + } + + private class ComplexFwdMetric + { + public DateTime Timestamp { get; set; } + public MeasurementParams1 Param1 { get; set; } = new(); + } + + private class ComplexFwdContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_fwd_metrics"); + }); + } + } + + [Fact] + public void Should_Resolve_Dotted_Path_On_ComplexType_Property_To_Default_Column() + { + // Arrange + using ComplexFwdContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "complex_fwd_metrics"); + + // Act + string? resolved = ColumnNameResolver.Resolve(entityType, "Param1.Value", storeIdentifier); + + // Assert + Assert.Equal("Param1_Value", resolved); + } + + #endregion + + #region Should_Resolve_Dotted_Path_On_ComplexType_Property_Under_SnakeCase + + [ComplexType] + private class MeasurementParams2 + { + public double Value { get; set; } + } + + private class ComplexSnakeCaseMetric + { + public DateTime Timestamp { get; set; } + public MeasurementParams2 Param1 { get; set; } = new(); + } + + private class ComplexSnakeCaseContext : DbContext + { + public DbSet Metrics => 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("complex_snake_metrics"); + }); + } + } + + [Fact] + public void Should_Resolve_Dotted_Path_On_ComplexType_Property_Under_SnakeCase() + { + // Arrange + using ComplexSnakeCaseContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "complex_snake_metrics"); + + // Act + string? resolved = ColumnNameResolver.Resolve(entityType, "Param1.Value", storeIdentifier); + + // Assert + Assert.Equal("param1_value", resolved); + } + + #endregion + + #region Should_Resolve_Column_Form_Of_ComplexType_Property_Via_Reverse_Lookup + + [ComplexType] + private class MeasurementParams3 + { + public double Value { get; set; } + } + + private class ComplexReverseMetric + { + public DateTime Timestamp { get; set; } + public MeasurementParams3 Param1 { get; set; } = new(); + } + + private class ComplexReverseContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("complex_rev_metrics"); + }); + } + } + + [Fact] + public void Should_Resolve_Column_Form_Of_ComplexType_Property_Via_Reverse_Lookup() + { + // Arrange + using ComplexReverseContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "complex_rev_metrics"); + + // Act + string? resolved = ColumnNameResolver.Resolve(entityType, "Param1_Value", storeIdentifier); + + // Assert + Assert.Equal("Param1_Value", resolved); + } + + #endregion + + #region Should_Resolve_Forward_And_Reverse_For_Nested_Complex_Within_Complex + + [ComplexType] + private class DeepInnerComplex + { + public double Value { get; set; } + } + + [ComplexType] + private class DeepOuterComplex + { + public DeepInnerComplex Inner { get; set; } = new(); + } + + private class NestedComplexMetric + { + public DateTime Timestamp { get; set; } + public DeepOuterComplex Outer { get; set; } = new(); + } + + private class NestedComplexContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("nested_complex_metrics"); + }); + } + } + + [Fact] + public void Should_Resolve_Forward_And_Reverse_For_Nested_Complex_Within_Complex() + { + // Arrange + using NestedComplexContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "nested_complex_metrics"); + + // Act + string? forwardResolved = ColumnNameResolver.Resolve(entityType, "Outer.Inner.Value", storeIdentifier); + string? reverseResolved = ColumnNameResolver.Resolve(entityType, "Outer_Inner_Value", storeIdentifier); + + // Assert + Assert.Equal("Outer_Inner_Value", forwardResolved); + Assert.Equal("Outer_Inner_Value", reverseResolved); + } + + #endregion + + #region Should_Return_Null_For_Unresolvable_Dotted_Path + + [ComplexType] + private class GhostParams + { + public double Value { get; set; } + } + + private class UnresolvablePathMetric + { + public DateTime Timestamp { get; set; } + public GhostParams Param1 { get; set; } = new(); + } + + private class UnresolvablePathContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("unresolvable_path_metrics"); + }); + } + } + + [Fact] + public void Should_Return_Null_For_Unresolvable_Dotted_Path() + { + // Arrange + using UnresolvablePathContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "unresolvable_path_metrics"); + + // Act + string? resolved = ColumnNameResolver.Resolve(entityType, "Param1.Ghost", storeIdentifier); + + // Assert + Assert.Null(resolved); + } + + #endregion + + #region Should_ResolveProperty_With_IgnoreCase_Mixed_Case_Name + + [ComplexType] + private class IgnoreCaseParams + { + public double Value { get; set; } + } + + private class IgnoreCaseMetric + { + public DateTime Timestamp { get; set; } + public IgnoreCaseParams Param1 { get; set; } = new(); + } + + private class IgnoreCaseContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ignore_case_metrics"); + }); + } + } + + [Fact] + public void Should_ResolveProperty_With_IgnoreCase_Mixed_Case_Name() + { + // Arrange + using IgnoreCaseContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "ignore_case_metrics"); + + // Act + IProperty? propertyViaMixedCase = ColumnNameResolver.ResolveProperty(entityType, "PARAM1.VALUE", storeIdentifier, ignoreCase: true); + IProperty? propertyViaColumnMixedCase = ColumnNameResolver.ResolveProperty(entityType, "PARAM1_VALUE", storeIdentifier, ignoreCase: true); + + // Assert + Assert.NotNull(propertyViaMixedCase); + Assert.Equal("Value", propertyViaMixedCase.Name); + Assert.NotNull(propertyViaColumnMixedCase); + Assert.Equal("Value", propertyViaColumnMixedCase.Name); + } + + #endregion + + #region Should_ResolveProperty_Return_Null_For_Null_Or_Whitespace_Input + + private class NullInputMetric + { + public DateTime Timestamp { get; set; } + } + + private class NullInputContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("null_input_metrics"); + }); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Should_ResolveProperty_Return_Null_For_Null_Or_Whitespace_Input(string? input) + { + // Arrange + using NullInputContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "null_input_metrics"); + + // Act + IProperty? resolved = ColumnNameResolver.ResolveProperty(entityType, input, storeIdentifier); + + // Assert + Assert.Null(resolved); + } + + #endregion + + #region Should_Return_Null_When_Intermediate_Segment_Is_Not_A_Complex_Property + + [ComplexType] + private class BrokenSegmentParams + { + public double Value { get; set; } + } + + private class BrokenSegmentMetric + { + public DateTime Timestamp { get; set; } + public BrokenSegmentParams Param1 { get; set; } = new(); + } + + private class BrokenSegmentContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("broken_segment_metrics"); + }); + } + } + + [Fact] + public void Should_Return_Null_When_Intermediate_Segment_Is_Not_A_Complex_Property() + { + // Arrange + using BrokenSegmentContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "broken_segment_metrics"); + + // Act & Assert + Assert.Null(ColumnNameResolver.Resolve(entityType, "Missing.Value", storeIdentifier)); + Assert.Null(ColumnNameResolver.Resolve(entityType, "Timestamp.Value", storeIdentifier)); + } + + #endregion + + #region Should_Return_Null_For_Path_Through_Complex_Collection + + private class CollectionChannel + { + public double Value { get; set; } + } + + private class CollectionPathMetric + { + public DateTime Timestamp { get; set; } + public List Channels { get; set; } = []; + } + + private class CollectionPathContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("collection_path_metrics"); + entity.ComplexCollection(x => x.Channels).ToJson(); + }); + } + } + + [Fact] + public void Should_Return_Null_For_Path_Through_Complex_Collection() + { + // Arrange + using CollectionPathContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "collection_path_metrics"); + + // Act + string? resolved = ColumnNameResolver.Resolve(entityType, "Channels.Value", storeIdentifier); + + // Assert + Assert.Null(resolved); + } + + #endregion + + #region Should_Skip_Complex_Collection_During_Reverse_Lookup + + private class ReverseSkipChannel + { + public double Value { get; set; } + } + + [ComplexType] + private class ReverseSkipParams + { + public double Value { get; set; } + } + + private class ReverseSkipMetric + { + public DateTime Timestamp { get; set; } + public List Channels { get; set; } = []; + public ReverseSkipParams Params { get; set; } = new(); + } + + private class ReverseSkipContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reverse_skip_metrics"); + entity.ComplexCollection(x => x.Channels).ToJson(); + }); + } + } + + [Fact] + public void Should_Skip_Complex_Collection_During_Reverse_Lookup() + { + // Arrange + using ReverseSkipContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "reverse_skip_metrics"); + + // Act + string? resolvedPastCollection = ColumnNameResolver.Resolve(entityType, "Params_Value", storeIdentifier); + string? unresolvable = ColumnNameResolver.Resolve(entityType, "ghost_column", storeIdentifier); + + // Assert + Assert.Equal("Params_Value", resolvedPastCollection); + Assert.Null(unresolvable); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Internals/ExpressionHelperTests.cs b/tests/Eftdb.Tests/Internals/ExpressionHelperTests.cs new file mode 100644 index 0000000..6ffd75b --- /dev/null +++ b/tests/Eftdb.Tests/Internals/ExpressionHelperTests.cs @@ -0,0 +1,182 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using System.Linq.Expressions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +/// +/// Tests that verify ExpressionHelper.GetPropertyName extracts the correct dot-separated path +/// for simple properties, complex-type chains, and boxed expressions, and rejects non-parameter-rooted +/// expressions with an ArgumentException. +/// +public class ExpressionHelperTests +{ + // ── Single-level ── + + #region Should_Return_Property_Name_For_Single_Level_Access + + private class SingleLevelEntity + { + public DateTime Value { get; set; } + } + + [Fact] + public void Should_Return_Property_Name_For_Single_Level_Access() + { + // Arrange + Expression> expr = x => x.Value; + + // Act + string result = ExpressionHelper.GetPropertyName(expr); + + // Assert + Assert.Equal("Value", result); + } + + #endregion + + // ── Two-level chain ── + + #region Should_Return_Dot_Separated_Path_For_Two_Level_Chain + + private class Param1Complex + { + public double Value { get; set; } + } + + private class TwoLevelEntity + { + public Param1Complex Param1 { get; set; } = new(); + } + + [Fact] + public void Should_Return_Dot_Separated_Path_For_Two_Level_Chain() + { + // Arrange + Expression> expr = x => x.Param1.Value; + + // Act + string result = ExpressionHelper.GetPropertyName(expr); + + // Assert + Assert.Equal("Param1.Value", result); + } + + #endregion + + // ── Three-level chain (complex within complex) ── + + #region Should_Return_Dot_Separated_Path_For_Three_Level_Chain + + private class InnerComplex + { + public double Value { get; set; } + } + + private class OuterComplex + { + public InnerComplex Inner { get; set; } = new(); + } + + private class ThreeLevelEntity + { + public OuterComplex Outer { get; set; } = new(); + } + + [Fact] + public void Should_Return_Dot_Separated_Path_For_Three_Level_Chain() + { + // Arrange + Expression> expr = x => x.Outer.Inner.Value; + + // Act + string result = ExpressionHelper.GetPropertyName(expr); + + // Assert + Assert.Equal("Outer.Inner.Value", result); + } + + #endregion + + // ── Boxed two-level chain (object-typed selector) ── + + #region Should_Return_Dot_Separated_Path_For_Boxed_Two_Level_Chain + + private class BoxedParam1Complex + { + public double Value { get; set; } + } + + private class BoxedTwoLevelEntity + { + public BoxedParam1Complex Meta { get; set; } = new(); + } + + [Fact] + public void Should_Return_Dot_Separated_Path_For_Boxed_Two_Level_Chain() + { + // Arrange + Expression> expr = x => x.Meta.Value; + + // Act + string result = ExpressionHelper.GetPropertyName(expr); + + // Assert + Assert.Equal("Meta.Value", result); + } + + #endregion + + // ── Closure/variable-rooted expression throws ── + + #region Should_Throw_ArgumentException_For_Variable_Rooted_Expression + + private class ClosureEntity + { + public double Value { get; set; } + } + + [Fact] + public void Should_Throw_ArgumentException_For_Variable_Rooted_Expression() + { + // Arrange + ClosureEntity captured = new(); + Expression> expr = _ => captured.Value; + + // Act & Assert + Assert.Throws(() => ExpressionHelper.GetPropertyName(expr)); + } + + #endregion + + // ── Non-member expression throws ── + + #region Should_Throw_ArgumentException_For_Constant_Expression + + [Fact] + public void Should_Throw_ArgumentException_For_Constant_Expression() + { + // Arrange + Expression> expr = _ => 42; + + // Act & Assert + Assert.Throws(() => ExpressionHelper.GetPropertyName(expr)); + } + + #endregion + + // ── Static member access throws ── + + #region Should_Throw_ArgumentException_For_Static_Member_Access + + [Fact] + public void Should_Throw_ArgumentException_For_Static_Member_Access() + { + // Arrange + Expression> expr = _ => DateTime.Now; + + // Act & Assert + Assert.Throws(() => ExpressionHelper.GetPropertyName(expr)); + } + + #endregion +}