Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, TProperty>(Expression)` consolidates lambda-to-property-name extraction across the fluent API
- `ExpressionHelper.cs` - Shared static helper: `GetPropertyName<T, TProperty>(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:**
Expand Down
3 changes: 2 additions & 1 deletion .claude/reference/file-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T,TProperty>(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 |
Expand Down
60 changes: 60 additions & 0 deletions docs/04-complex-types.md
Original file line number Diff line number Diff line change
@@ -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<TAgg, TSource>(..., 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<HourlyAggregate, Reading>(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.
Original file line number Diff line number Diff line change
@@ -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<ChannelizedSensorReading>
{
public void Configure(EntityTypeBuilder<ChannelizedSensorReading> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<HourlySensorAggregate>
{
public void Configure(EntityTypeBuilder<HourlySensorAggregate> builder)
{
builder.HasNoKey();

builder.IsContinuousAggregate<HourlySensorAggregate, ChannelizedSensorReading>(
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Fluent API configuration for the <see cref="HourlyStationAggregate"/> continuous aggregate.
/// Demonstrates two-hop nested complex-type column resolution.
/// </summary>
public class HourlyStationAggregateConfiguration : IEntityTypeConfiguration<HourlyStationAggregate>
{
public void Configure(EntityTypeBuilder<HourlyStationAggregate> builder)
{
builder.HasNoKey();

builder.IsContinuousAggregate<HourlyStationAggregate, StationReading>(
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Fluent API configuration for <see cref="StationReading"/>.
/// Explicitly registers the two-level complex-type hierarchy.
/// </summary>
public class StationReadingConfiguration : IEntityTypeConfiguration<StationReading>
{
public void Configure(EntityTypeBuilder<StationReading> 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();
}
}
}
30 changes: 30 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/ChannelizedSensorReading.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models
{
/// <summary>
/// An IoT sensor reading that exposes two measurement channels as EF Core complex-type
/// properties.
/// </summary>
[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;

/// <summary>
/// Primary measurement channel (e.g. temperature in °C).
/// Maps to columns <c>Primary_Name</c> and <c>Primary_Value</c> by default;
/// snake_case convention yields <c>primary_name</c> / <c>primary_value</c>.
/// </summary>
public SensorChannel Primary { get; set; } = new();

/// <summary>
/// Secondary measurement channel (e.g. humidity in %).
/// Maps to columns <c>Secondary_Name</c> and <c>Secondary_Value</c> by default;
/// snake_case convention yields <c>secondary_name</c> / <c>secondary_value</c>.
/// </summary>
public SensorChannel Secondary { get; set; } = new();
}
}
8 changes: 8 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/Coordinates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models
{
public class Coordinates
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}
}
11 changes: 11 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/HourlySensorAggregate.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
9 changes: 9 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/HourlyStationAggregate.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
11 changes: 11 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/Location.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 20 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/SensorChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models
{
/// <summary>
/// Represents a single measurement channel owned by a sensor reading.
/// Declared with <see cref="ComplexTypeAttribute"/> 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:
/// <c>{PropertyName}_{MemberName}</c> (e.g. <c>Primary_Name</c>, <c>Primary_Value</c>).
/// Under a snake_case naming convention the columns become
/// <c>primary_name</c> / <c>primary_value</c> etc.
/// </summary>
[ComplexType]
public class SensorChannel
{
public string Name { get; set; } = string.Empty;
public double Value { get; set; }
}
}
20 changes: 20 additions & 0 deletions samples/Eftdb.Samples.Shared/Models/StationReading.cs
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>
/// Geographic location of the monitoring station.
/// Contains a nested <see cref="Coordinates"/> complex type, producing columns
/// such as <c>Location_Site</c>, <c>Location_Coordinates_Latitude</c>, and
/// <c>Location_Coordinates_Longitude</c> on the <c>station_readings</c> table.
/// </summary>
public Location Location { get; set; } = new();
}
}
4 changes: 4 additions & 0 deletions samples/Eftdb.Samples.Shared/TimescaleContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public class TimescaleContext(DbContextOptions<TimescaleContext> options) : DbCo
public DbSet<ApiRequestLog> ApiRequestLogs { get; set; }
public DbSet<ApiRequestAggregate> ApiRequestAggregates { get; set; }
public DbSet<MetricSnapshot> MetricSnapshots { get; set; }
public DbSet<ChannelizedSensorReading> ChannelizedSensorReadings { get; set; }
public DbSet<HourlySensorAggregate> HourlySensorAggregates { get; set; }
public DbSet<StationReading> StationReadings { get; set; }
public DbSet<HourlyStationAggregate> HourlyStationAggregates { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Loading
Loading