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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ samples/Eftdb.Samples.Shared/Migrations/

# Ignore all scaffolded models and the DbContext from the DbFirst project
samples/Eftdb.Samples.DatabaseFirst/**/*.cs
!samples/Eftdb.Samples.DatabaseFirst/Program.cs

# AI - personal overrides only
.claude/settings.local.json
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# CmdScale.EntityFrameworkCore.TimescaleDB

![CmdScale Project](https://github.com/cmdscale/.github/raw/main/profile/assets/CmdShield.svg)
[![Test Workflow](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/actions/workflows/run-tests.yml/badge.svg)](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/actions/workflows/run-tests.yml)
[![NuGet downloads](https://img.shields.io/nuget/dt/CmdScale.EntityFrameworkCore.TimescaleDB?logo=nuget&label=Downloads)](https://www.nuget.org/packages/CmdScale.EntityFrameworkCore.TimescaleDB)
[![codecov](https://codecov.io/gh/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/graph/badge.svg?token=YP3YCJLQ41)](https://codecov.io/gh/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB)
Expand Down Expand Up @@ -42,6 +41,10 @@ Seamlessly define and manage **TimescaleDB hypertables** using standard EF Core

Take full control over how your hypertable data is organized on disk with **TimescaleDB's** reorder policies. By defining a reorder policy, you can automatically re-sort chunks of data by a specified index, significantly improving the performance of queries that scan large time ranges or specific index values.

### Retention Policies

Automatically drop old chunks from hypertables and continuous aggregates so storage stays bounded as your time-series data grows.

### Continuous Aggregates

Create and manage **TimescaleDB continuous aggregates** — automatically refreshed materialized views that pre-compute aggregate data for faster queries. Define time-bucketed aggregations using a type-safe Fluent API or Data Annotations.
Expand All @@ -52,6 +55,16 @@ Create and manage **TimescaleDB continuous aggregates** — automatically refres
- **Filtering**: Apply WHERE clauses to filter source data.
- **Refresh Policies**: Configure automatic refresh with customizable time windows, schedule intervals, and batching options.

### Query Functions

Call TimescaleDB SQL functions directly from LINQ via `EF.Functions.*` extensions. Each entry below translates to its TimescaleDB equivalent at query time:

| `EF.Functions.*` | TimescaleDB | Purpose |
| ---------------- | ----------- | ------------------------------------------------- |
| `TimeBucket` | `time_bucket()` | Group rows into fixed-width time intervals. |

> More TimescaleDB function support coming soon.

---

## 📦 NuGet Packages
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="10.105.3" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="10.105.4" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst</AssemblyName>
Expand Down
1 change: 1 addition & 0 deletions samples/Eftdb.Samples.DatabaseFirst/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
return 0;
2 changes: 1 addition & 1 deletion src/Eftdb.Design/Eftdb.Design.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<PackageTags>timescaledb;timescale;efcore;ef-core;entityframeworkcore;postgresql;postgres;time-series;timeseries;data;database;efcore-provider;provider;design;migrations;scaffolding;codegen;cli;tools</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\assets\cmd-nuget-logo.jpg">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo)
table[ContinuousAggregateAnnotations.ChunkInterval] = info.ChunkInterval;
}

// Store the view definition for reference (custom annotation)
// This will help users understand the structure when scaffolding
table["TimescaleDB:ViewDefinition"] = info.ViewDefinition;
// Capture the catalog's view body so the runtime extractor + generator can
// round-trip CREATE MATERIALIZED VIEW without needing structured time_bucket /
// aggregate / group_by annotations (which the scaffolder cannot reliably
// recover from the catalog).
table[ContinuousAggregateAnnotations.ViewDefinition] = info.ViewDefinition;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private static void GetHypertableSettings(
column_name,
dimension_number,
num_partitions,
EXTRACT(EPOCH FROM time_interval) * 1000 AS time_interval_microseconds,
EXTRACT(EPOCH FROM time_interval) * 1000000 AS time_interval_microseconds,
integer_interval
FROM timescaledb_information.dimensions
ORDER BY hypertable_schema, hypertable_name, dimension_number;";
Expand Down
3 changes: 2 additions & 1 deletion src/Eftdb.Design/TimescaleDBDesignTimeServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public void ConfigureDesignTimeServices(IServiceCollection services)
new NpgsqlDesignTimeServices().ConfigureDesignTimeServices(services);

services.AddSingleton<ICSharpMigrationOperationGenerator, TimescaleCSharpMigrationOperationGenerator>()
.AddSingleton<IDatabaseModelFactory, TimescaleDatabaseModelFactory>();
.AddSingleton<IDatabaseModelFactory, TimescaleDatabaseModelFactory>()
.AddSingleton<IProviderConfigurationCodeGenerator, TimescaleDbCodeGenerator>();
}
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/Eftdb.Design/TimescaleDbCodeGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Scaffolding;
using Npgsql.EntityFrameworkCore.PostgreSQL.Scaffolding.Internal;
using System.Reflection;

#pragma warning disable EF1001 // NpgsqlCodeGenerator lives in *.Internal but is public and the documented extension point.
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design
{
/// <summary>
/// Extends Npgsql's scaffold code generator so the generated <c>OnConfiguring</c> chains
/// <c>.UseTimescaleDb()</c> after <c>.UseNpgsql(...)</c>.
/// </summary>
public class TimescaleDbCodeGenerator(ProviderCodeGeneratorDependencies dependencies)
: NpgsqlCodeGenerator(dependencies)
{
private static readonly MethodInfo UseTimescaleDbMethod =
typeof(TimescaleDbContextOptionsBuilderExtensions)
.GetMethod(
nameof(TimescaleDbContextOptionsBuilderExtensions.UseTimescaleDb),
[typeof(DbContextOptionsBuilder)])
?? throw new InvalidOperationException(
"Could not locate UseTimescaleDb(DbContextOptionsBuilder) via reflection.");

public override MethodCallCodeFragment GenerateUseProvider(
string connectionString,
MethodCallCodeFragment? providerOptions)
=> base.GenerateUseProvider(connectionString, providerOptions)
.Chain(new MethodCallCodeFragment(UseTimescaleDbMethod));
}
}
#pragma warning restore EF1001
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,14 @@ public static class ContinuousAggregateAnnotations
public const string AggregateFunctions = "TimescaleDB:AggregateFunctions";
public const string WhereClause = "TimescaleDB:WhereClause";
public const string GroupByColumns = "TimescaleDB:GroupByColumns";

/// <summary>
/// Raw SQL view body captured by the design-time scaffolder. When present, the
/// runtime extractor and generator use it verbatim as the SELECT clause of
/// CREATE MATERIALIZED VIEW instead of synthesising from structured fields. The
/// scaffolder cannot reliably reverse-engineer time_bucket / aggregate / group_by
/// structure from the catalog, so this raw form is the round-trip fallback.
/// </summary>
public const string ViewDefinition = "TimescaleDB:ViewDefinition";
}
}
19 changes: 19 additions & 0 deletions src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ public List<string> Generate(CreateContinuousAggregateOperation operation)
withOptions.Add($"timescaledb.chunk_interval = '{operation.ChunkInterval}'");
}

// Raw-SQL path required for scaffolding round-trips
if (!string.IsNullOrWhiteSpace(operation.ViewDefinition))
{
StringBuilder rawSqlBuilder = new();
rawSqlBuilder.Append($"CREATE MATERIALIZED VIEW {qualifiedIdentifier}");
rawSqlBuilder.AppendLine();
rawSqlBuilder.Append($"WITH ({string.Join(", ", withOptions)}) AS");
rawSqlBuilder.AppendLine();
rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';').Replace("\"", quoteString));
if (operation.WithNoData)
{
rawSqlBuilder.AppendLine();
rawSqlBuilder.Append("WITH NO DATA");
}
rawSqlBuilder.Append(';');
statements.Add(rawSqlBuilder.ToString());
return statements;
}

// Build the SELECT list
List<string> selectList = [];

Expand Down
49 changes: 49 additions & 0 deletions src/Eftdb/Internals/ColumnNameResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals
{
/// <summary>
/// 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).
/// </summary>
internal static class ColumnNameResolver
{
/// <summary>
/// Returns the database column name for <paramref name="nameOrColumn"/> on
/// <paramref name="entityType"/>, or <c>null</c> if no matching property exists.
/// </summary>
/// <remarks>
/// 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 <c>GetColumnName(StoreObjectIdentifier)</c>,
/// which honours all registered conventions.
/// </remarks>
public static string? Resolve(IEntityType entityType, string? nameOrColumn, StoreObjectIdentifier storeIdentifier)
{
if (string.IsNullOrWhiteSpace(nameOrColumn))
{
return null;
}

string? viaClrName = entityType.FindProperty(nameOrColumn)?.GetColumnName(storeIdentifier);
if (!string.IsNullOrWhiteSpace(viaClrName))
{
return viaClrName;
}

foreach (IProperty property in entityType.GetProperties())
{
string? columnName = property.GetColumnName(storeIdentifier);
if (string.Equals(columnName, nameOrColumn, StringComparison.Ordinal))
{
return columnName;
}
}

return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,16 @@ public static IEnumerable<AddContinuousAggregatePolicyOperation> GetContinuousAg
if (!string.IsNullOrWhiteSpace(parentModelName))
{
parentEntityType = relationalModel.Model.GetEntityTypes()
.FirstOrDefault(e => e.ClrType?.Name == parentModelName || e.ShortName() == parentModelName);
.FirstOrDefault(e =>
e.ClrType?.Name == parentModelName
|| e.ShortName() == parentModelName
|| e.GetTableName() == parentModelName);
}

// Use parent table's schema for the continuous aggregate (matching ContinuousAggregateModelExtractor behavior)
string schema = parentEntityType?.GetSchema() ?? entityType.GetSchema() ?? DefaultValues.DefaultSchema;
string schema = entityType.GetViewSchema()
?? entityType.GetSchema()
?? parentEntityType?.GetSchema()
?? DefaultValues.DefaultSchema;

// Extract policy configuration from annotations
string? startOffset = entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset)?.Value as string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ public IReadOnlyList<MigrationOperation> GetDifferences(IRelationalModel? source
x.Target.WithNoData != x.Source.WithNoData ||
!AreAggregateFunctionsEqual(x.Target.AggregateFunctions, x.Source.AggregateFunctions) ||
!AreGroupByColumnsEqual(x.Target.GroupByColumns, x.Source.GroupByColumns) ||
x.Target.WhereClause != x.Source.WhereClause
x.Target.WhereClause != x.Source.WhereClause ||
x.Target.ViewDefinition != x.Source.ViewDefinition
);

foreach (var aggregate in structurallyChangedAggregates)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
continue;
}

// Find the parent entity type to get its table name
// Find the parent entity type
IEntityType? parentEntityType = relationalModel.Model.GetEntityTypes()
.FirstOrDefault(e => e.ClrType?.Name == parentModelName || e.ShortName() == parentModelName);
.FirstOrDefault(e =>
e.ClrType?.Name == parentModelName
|| e.ShortName() == parentModelName
|| e.GetTableName() == parentModelName);
if (parentEntityType == null)
{
continue;
Expand All @@ -46,14 +49,13 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
}

// Get time bucket configuration
string? timeBucketWidth = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value as string;
if (string.IsNullOrWhiteSpace(timeBucketWidth))
{
continue;
}
string? viewDefinition = entityType.FindAnnotation(ContinuousAggregateAnnotations.ViewDefinition)?.Value as string;
bool useRawDefinition = !string.IsNullOrWhiteSpace(viewDefinition);

// Get time bucket configuration
string? timeBucketWidth = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth)?.Value as string;
string? timeBucketSourceColumnModelName = entityType.FindAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn)?.Value as string;
if (string.IsNullOrWhiteSpace(timeBucketSourceColumnModelName))
if (!useRawDefinition && (string.IsNullOrWhiteSpace(timeBucketWidth) || string.IsNullOrWhiteSpace(timeBucketSourceColumnModelName)))
{
continue;
}
Expand All @@ -62,11 +64,14 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
StoreObjectIdentifier parentStoreIdentifier = StoreObjectIdentifier.Table(parentTableName, parentEntityType.GetSchema());

string? viewName = entityType.GetViewName() ?? materializedViewName;
StoreObjectIdentifier aggregateStoreIdentifier = StoreObjectIdentifier.View(viewName, entityType.GetSchema());

// Resolve time bucket source column to database column name
string? timeBucketSourceColumn = parentEntityType.FindProperty(timeBucketSourceColumnModelName)?.GetColumnName(parentStoreIdentifier);
if (string.IsNullOrWhiteSpace(timeBucketSourceColumn))
StoreObjectIdentifier aggregateStoreIdentifier = StoreObjectIdentifier.View(viewName, entityType.GetViewSchema() ?? entityType.GetSchema());

// Resolve time bucket source column to database column name. Skipped on
// the raw-definition path because the structured field is unused.
string? timeBucketSourceColumn = useRawDefinition
? null
: ColumnNameResolver.Resolve(parentEntityType, timeBucketSourceColumnModelName!, parentStoreIdentifier);
if (!useRawDefinition && string.IsNullOrWhiteSpace(timeBucketSourceColumn))
{
continue;
}
Expand Down Expand Up @@ -98,7 +103,7 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
string sourceColumnModelName = parts[2];

// Resolve source column name from parent entity
string? sourceColumnDbName = parentEntityType.FindProperty(sourceColumnModelName)?.GetColumnName(parentStoreIdentifier);
string? sourceColumnDbName = ColumnNameResolver.Resolve(parentEntityType, sourceColumnModelName, parentStoreIdentifier);
if (string.IsNullOrWhiteSpace(sourceColumnDbName))
{
// Skip if source column not found
Expand Down Expand Up @@ -141,8 +146,12 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
}
}

// Use parent table's schema for the continuous aggregate
string schema = parentEntityType.GetSchema() ?? entityType.GetSchema() ?? DefaultValues.DefaultSchema;
// Schema resolution: prefer the CA's own view schema (set by .ToView(...)
// or by the scaffolder), fall back to the parent's schema, finally default.
string schema = entityType.GetViewSchema()
?? entityType.GetSchema()
?? parentEntityType.GetSchema()
?? DefaultValues.DefaultSchema;

yield return new CreateContinuousAggregateOperation
{
Expand All @@ -153,12 +162,13 @@ public static IEnumerable<CreateContinuousAggregateOperation> GetContinuousAggre
WithNoData = withNoData,
CreateGroupIndexes = createGroupIndexes,
MaterializedOnly = materializedOnly,
TimeBucketWidth = timeBucketWidth,
TimeBucketSourceColumn = timeBucketSourceColumn,
TimeBucketWidth = timeBucketWidth ?? string.Empty,
TimeBucketSourceColumn = timeBucketSourceColumn ?? string.Empty,
TimeBucketGroupBy = timeBucketGroupBy,
AggregateFunctions = aggregateFunctions,
GroupByColumns = groupByColumns,
WhereClause = whereClause
WhereClause = whereClause,
ViewDefinition = useRawDefinition ? viewDefinition : null
};
}
}
Expand Down
Loading
Loading