diff --git a/.gitignore b/.gitignore
index 1fb6197..8ae0429 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 84b45a6..1d319bc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
ο»Ώ# CmdScale.EntityFrameworkCore.TimescaleDB
-
[](https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB/actions/workflows/run-tests.yml)
[](https://www.nuget.org/packages/CmdScale.EntityFrameworkCore.TimescaleDB)
[](https://codecov.io/gh/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB)
@@ -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.
@@ -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
diff --git a/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj b/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj
index 789cb30..b40d40d 100644
--- a/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj
+++ b/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj b/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj
index a3808d7..0466b9f 100644
--- a/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj
+++ b/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj b/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj
index 6b1b2bf..150d30d 100644
--- a/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj
+++ b/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj
@@ -6,6 +6,7 @@
net10.0
+ Exe
enable
enable
CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst
diff --git a/samples/Eftdb.Samples.DatabaseFirst/Program.cs b/samples/Eftdb.Samples.DatabaseFirst/Program.cs
new file mode 100644
index 0000000..6b54a11
--- /dev/null
+++ b/samples/Eftdb.Samples.DatabaseFirst/Program.cs
@@ -0,0 +1 @@
+ο»Ώreturn 0;
\ No newline at end of file
diff --git a/src/Eftdb.Design/Eftdb.Design.csproj b/src/Eftdb.Design/Eftdb.Design.csproj
index 7f88566..8aad300 100644
--- a/src/Eftdb.Design/Eftdb.Design.csproj
+++ b/src/Eftdb.Design/Eftdb.Design.csproj
@@ -24,7 +24,7 @@
timescaledb;timescale;efcore;ef-core;entityframeworkcore;postgresql;postgres;time-series;timeseries;data;database;efcore-provider;provider;design;migrations;scaffolding;codegen;cli;tools
-
+
diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs
index de7be14..15c3b8f 100644
--- a/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs
+++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs
@@ -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;
}
}
}
diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs
index 6a32b6b..88358e5 100644
--- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs
+++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs
@@ -77,7 +77,7 @@ private static void GetHypertableSettings(
column_name,
dimension_number,
num_partitions,
- EXTRACT(EPOCH FROM time_interval) * 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;";
diff --git a/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs b/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs
index 0062130..e20fb54 100644
--- a/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs
+++ b/src/Eftdb.Design/TimescaleDBDesignTimeServices.cs
@@ -15,7 +15,8 @@ public void ConfigureDesignTimeServices(IServiceCollection services)
new NpgsqlDesignTimeServices().ConfigureDesignTimeServices(services);
services.AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton();
}
}
}
diff --git a/src/Eftdb.Design/TimescaleDbCodeGenerator.cs b/src/Eftdb.Design/TimescaleDbCodeGenerator.cs
new file mode 100644
index 0000000..e5b630e
--- /dev/null
+++ b/src/Eftdb.Design/TimescaleDbCodeGenerator.cs
@@ -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
+{
+ ///
+ /// Extends Npgsql's scaffold code generator so the generated OnConfiguring chains
+ /// .UseTimescaleDb() after .UseNpgsql(...).
+ ///
+ 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
diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs
index 824c472..30a32c0 100644
--- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs
+++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAnnotations.cs
@@ -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";
+
+ ///
+ /// 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.
+ ///
+ public const string ViewDefinition = "TimescaleDB:ViewDefinition";
}
}
diff --git a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
index cd0eb19..9871555 100644
--- a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
+++ b/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
@@ -40,6 +40,25 @@ public List 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 selectList = [];
diff --git a/src/Eftdb/Internals/ColumnNameResolver.cs b/src/Eftdb/Internals/ColumnNameResolver.cs
new file mode 100644
index 0000000..e3948db
--- /dev/null
+++ b/src/Eftdb/Internals/ColumnNameResolver.cs
@@ -0,0 +1,49 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+
+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).
+ ///
+ internal static class ColumnNameResolver
+ {
+ ///
+ /// Returns the database column name for on
+ /// , 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.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs
index 5662505..81926e3 100644
--- a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs
+++ b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs
@@ -45,11 +45,16 @@ public static IEnumerable 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;
diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
index 70352bc..aa4657d 100644
--- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
+++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
@@ -65,7 +65,8 @@ public IReadOnlyList 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)
diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs
index a3ecaf8..2d02975 100644
--- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs
+++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs
@@ -31,9 +31,12 @@ public static IEnumerable 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;
@@ -46,14 +49,13 @@ public static IEnumerable 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;
}
@@ -62,11 +64,14 @@ public static IEnumerable 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;
}
@@ -98,7 +103,7 @@ public static IEnumerable 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
@@ -141,8 +146,12 @@ public static IEnumerable 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
{
@@ -153,12 +162,13 @@ public static IEnumerable 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
};
}
}
diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs
index 5847bc4..882f036 100644
--- a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs
+++ b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs
@@ -35,7 +35,7 @@ public static IEnumerable GetHypertables(IRelationalM
continue;
}
- string? timeColumnName = entityType.FindProperty(timeColumnModelName)?.GetColumnName(storeIdentifier);
+ string? timeColumnName = ColumnNameResolver.Resolve(entityType, timeColumnModelName, storeIdentifier);
if (string.IsNullOrWhiteSpace(timeColumnName))
{
continue;
@@ -47,7 +47,7 @@ public static IEnumerable GetHypertables(IRelationalM
if (!string.IsNullOrWhiteSpace(chunkSkipColumnsString))
{
chunkSkipColumns = chunkSkipColumnsString.Split(',', StringSplitOptions.TrimEntries)
- .Select(modelPropName => entityType.FindProperty(modelPropName)?.GetColumnName(storeIdentifier))
+ .Select(name => ColumnNameResolver.Resolve(entityType, name, storeIdentifier))
.Where(name => name != null)
.ToList()!;
}
@@ -97,7 +97,7 @@ public static IEnumerable GetHypertables(IRelationalM
additionalDimensions = [];
foreach (Dimension dim in modelDimensions)
{
- string? conventionalColumnName = entityType.FindProperty(dim.ColumnName)?.GetColumnName(storeIdentifier);
+ string? conventionalColumnName = ColumnNameResolver.Resolve(entityType, dim.ColumnName, storeIdentifier);
if (conventionalColumnName != null)
{
Dimension newDimension = JsonSerializer.Deserialize(JsonSerializer.Serialize(dim))!;
diff --git a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs
index 037b9b4..2fd50d6 100644
--- a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs
+++ b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs
@@ -20,5 +20,14 @@ public class CreateContinuousAggregateOperation : MigrationOperation
public List AggregateFunctions { get; set; } = [];
public List GroupByColumns { get; set; } = [];
public string? WhereClause { get; set; }
+
+ ///
+ /// Raw SQL body for the materialized view. When non-null the generator uses this
+ /// verbatim (CREATE MATERIALIZED VIEW ... AS {ViewDefinition}) and ignores the
+ /// structured time-bucket/aggregate/group-by/where fields. Populated by the
+ /// design-time scaffolder, which cannot reverse-engineer those structured fields
+ /// from the TimescaleDB catalog.
+ ///
+ public string? ViewDefinition { get; set; }
}
}
diff --git a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj
index 8426c4f..237577c 100644
--- a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj
+++ b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj
@@ -12,13 +12,13 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
diff --git a/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs b/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs
new file mode 100644
index 0000000..b3f84a1
--- /dev/null
+++ b/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs
@@ -0,0 +1,90 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Design;
+using Microsoft.EntityFrameworkCore.Design;
+using Microsoft.EntityFrameworkCore.Scaffolding;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Design.Internal;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design;
+
+///
+/// Regression tests for gap #1: the scaffolder must emit
+/// .UseNpgsql(...).UseTimescaleDb() in the generated OnConfiguring
+/// so the scaffolded context registers TimescaleMigrationsModelDiffer
+/// and emits TimescaleDB operations on subsequent migrations.
+///
+public class TimescaleDbCodeGeneratorTests
+{
+ private const string ConnectionString = "Host=localhost;Database=test;Username=test;Password=test";
+
+ #region Should_Chain_UseTimescaleDb_After_UseNpgsql
+
+ [Fact]
+ public void Should_Chain_UseTimescaleDb_After_UseNpgsql()
+ {
+ // Arrange β mimic the EF tooling DI container so the test exercises the same
+ // resolution path as `dotnet ef dbcontext scaffold`. This also confirms that
+ // TimescaleDBDesignTimeServices wins over the Npgsql default registration.
+ ServiceCollection services = new();
+ new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services);
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ IProviderConfigurationCodeGenerator generator = provider.GetRequiredService();
+
+ // Act
+ MethodCallCodeFragment fragment = generator.GenerateUseProvider(ConnectionString, providerOptions: null);
+
+ // Assert β head call must remain UseNpgsql; chained call must be UseTimescaleDb.
+ Assert.Equal("UseNpgsql", fragment.Method);
+ Assert.NotNull(fragment.ChainedCall);
+ Assert.Equal("UseTimescaleDb", fragment.ChainedCall!.Method);
+ }
+
+ #endregion
+
+ #region Should_Resolve_TimescaleDbCodeGenerator_From_Service_Provider
+
+ [Fact]
+ public void Should_Resolve_TimescaleDbCodeGenerator_From_Service_Provider()
+ {
+ // Arrange
+ ServiceCollection services = new();
+ new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services);
+
+ // Act
+ using ServiceProvider provider = services.BuildServiceProvider();
+ IProviderConfigurationCodeGenerator generator = provider.GetRequiredService();
+
+ // Assert β the Eftdb.Design registration must replace Npgsql's default.
+ Assert.IsType(generator);
+ }
+
+ #endregion
+
+ #region Should_Chain_UseTimescaleDb_When_Npgsql_Defaults_Are_Pre_Registered
+
+ [Fact]
+ public void Should_Chain_UseTimescaleDb_When_Npgsql_Defaults_Are_Pre_Registered()
+ {
+ // Arrange β register Npgsql defaults first so the test confirms registration
+ // order does not matter: TimescaleDBDesignTimeServices must still take over.
+ ServiceCollection services = new();
+#pragma warning disable EF1001
+ new NpgsqlDesignTimeServices().ConfigureDesignTimeServices(services);
+#pragma warning restore EF1001
+ new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services);
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ IProviderConfigurationCodeGenerator generator = provider.GetRequiredService();
+
+ // Act
+ MethodCallCodeFragment fragment = generator.GenerateUseProvider(ConnectionString, providerOptions: null);
+
+ // Assert
+ Assert.IsType(generator);
+ Assert.Equal("UseNpgsql", fragment.Method);
+ Assert.NotNull(fragment.ChainedCall);
+ Assert.Equal("UseTimescaleDb", fragment.ChainedCall!.Method);
+ }
+
+ #endregion
+}
diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs
index 4c2aa24..805cf9e 100644
--- a/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs
+++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateDifferTests.cs
@@ -2088,4 +2088,207 @@ public void Should_Drop_And_Recreate_When_GroupByColumns_Count_Differs()
}
#endregion
+
+ #region Should_Drop_And_Recreate_When_ViewDefinition_Changes
+
+ private class MetricEntity24
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class MetricAggregate24
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class RawDefinitionContextA24 : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Metrics");
+ entity.HasNoKey();
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MetricEntity24));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ private class RawDefinitionContextB24 : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Metrics");
+ entity.HasNoKey();
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MetricEntity24));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 day', \"Timestamp\") AS bucket FROM \"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Drop_And_Recreate_When_ViewDefinition_Changes()
+ {
+ // Arrange - regression for bug #5: differ now compares ViewDefinition as a structural change
+ using RawDefinitionContextA24 sourceContext = new();
+ using RawDefinitionContextB24 targetContext = new();
+
+ IRelationalModel sourceModel = GetModel(sourceContext);
+ IRelationalModel targetModel = GetModel(targetContext);
+
+ ContinuousAggregateDiffer differ = new();
+
+ // Act
+ IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel);
+
+ // Assert - drop must come before create
+ DropContinuousAggregateOperation? dropOp = operations.OfType().FirstOrDefault();
+ CreateContinuousAggregateOperation? createOp = operations.OfType().FirstOrDefault();
+ Assert.NotNull(dropOp);
+ Assert.NotNull(createOp);
+ Assert.Equal("hourly_metrics", dropOp.MaterializedViewName);
+ Assert.Equal("hourly_metrics", createOp.MaterializedViewName);
+ Assert.Contains("1 day", createOp.ViewDefinition);
+
+ int dropIndex = operations.ToList().FindIndex(o => o is DropContinuousAggregateOperation);
+ int createIndex = operations.ToList().FindIndex(o => o is CreateContinuousAggregateOperation);
+ Assert.True(dropIndex < createIndex, "Drop operation must precede the create operation.");
+ }
+
+ #endregion
+
+ #region Should_Emit_No_Operation_When_ViewDefinition_Identical
+
+ private class MetricEntity25
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class MetricAggregate25
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class IdenticalRawDefinitionContextA25 : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Metrics");
+ entity.HasNoKey();
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MetricEntity25));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ private class IdenticalRawDefinitionContextB25 : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Metrics");
+ entity.HasNoKey();
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MetricEntity25));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Emit_No_Operation_When_ViewDefinition_Identical()
+ {
+ // Arrange - counter-test for bug #5: identical ViewDefinition must not trigger drop+recreate
+ using IdenticalRawDefinitionContextA25 sourceContext = new();
+ using IdenticalRawDefinitionContextB25 targetContext = new();
+
+ IRelationalModel sourceModel = GetModel(sourceContext);
+ IRelationalModel targetModel = GetModel(targetContext);
+
+ ContinuousAggregateDiffer differ = new();
+
+ // Act
+ IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel);
+
+ // Assert
+ Assert.DoesNotContain(operations, op => op is DropContinuousAggregateOperation);
+ Assert.DoesNotContain(operations, op => op is CreateContinuousAggregateOperation);
+ }
+
+ #endregion
}
diff --git a/tests/Eftdb.Tests/Eftdb.Tests.csproj b/tests/Eftdb.Tests/Eftdb.Tests.csproj
index 176ef06..48eba8b 100644
--- a/tests/Eftdb.Tests/Eftdb.Tests.csproj
+++ b/tests/Eftdb.Tests/Eftdb.Tests.csproj
@@ -12,13 +12,13 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs
index 1e2ee94..9509f75 100644
--- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs
+++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs
@@ -2222,4 +2222,389 @@ public void Should_Use_Model_Name_As_Alias_When_Property_Not_Found_In_Aggregate_
}
#endregion
+
+ #region Should_Extract_ContinuousAggregate_When_TimeBucketSourceColumn_Annotation_Holds_Column_Name_Under_SnakeCase
+
+ private class ScaffoldedTimeBucketSourceMetric
+ {
+ public DateTime Time { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class ScaffoldedTimeBucketHourlyMetric
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class ScaffoldedTimeBucketContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseSnakeCaseNamingConvention()
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("metrics");
+ entity.IsHypertable(x => x.Time);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+
+ // Scaffolder emits the resolved database column name (snake_case "time"),
+ // not the CLR property name. Bug #44 dropped the aggregate when the
+ // extractor only matched CLR property names.
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(ScaffoldedTimeBucketSourceMetric));
+ entity.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, "time");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_ContinuousAggregate_When_TimeBucketSourceColumn_Annotation_Holds_Column_Name_Under_SnakeCase()
+ {
+ // Arrange
+ using ScaffoldedTimeBucketContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert
+ Assert.Single(operations);
+ Assert.Equal("time", operations[0].TimeBucketSourceColumn);
+ }
+
+ #endregion
+
+ #region Should_Extract_AggregateFunction_When_SourceColumnName_Annotation_Holds_Column_Name_Under_SnakeCase
+
+ private class ScaffoldedAggregateFunctionSourceMetric
+ {
+ public DateTime Time { get; set; }
+ public double SensorValue { get; set; }
+ }
+
+ private class ScaffoldedAggregateFunctionHourlyMetric
+ {
+ public DateTime Bucket { get; set; }
+ public double AvgValue { get; set; }
+ }
+
+ private class ScaffoldedAggregateFunctionContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseSnakeCaseNamingConvention()
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("metrics");
+ entity.IsHypertable(x => x.Time);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+
+ // Scaffolder writes the source-column part of "alias:fn:source" in
+ // resolved-column-name form (snake_case "sensor_value").
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(ScaffoldedAggregateFunctionSourceMetric));
+ entity.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, "time");
+
+ List aggregateFunctions = ["AvgValue:Avg:sensor_value"];
+ entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions);
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_AggregateFunction_When_SourceColumnName_Annotation_Holds_Column_Name_Under_SnakeCase()
+ {
+ // Arrange
+ using ScaffoldedAggregateFunctionContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert
+ Assert.Single(operations);
+ Assert.Single(operations[0].AggregateFunctions);
+ // Alias resolves via the aggregate entity's snake_case convention; source resolves via reverse lookup.
+ Assert.Equal("avg_value:Avg:sensor_value", operations[0].AggregateFunctions[0]);
+ }
+
+ #endregion
+
+ #region Should_Extract_ContinuousAggregate_When_ViewDefinition_Is_Set_Without_StructuredFields
+
+ private class RawDefinitionSourceMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class RawDefinitionAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class RawDefinitionContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(RawDefinitionSourceMetric));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_ContinuousAggregate_When_ViewDefinition_Is_Set_Without_StructuredFields()
+ {
+ // Arrange
+ using RawDefinitionContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert - exactly one operation emitted with raw ViewDefinition populated, structured fields empty
+ CreateContinuousAggregateOperation operation = Assert.Single(operations);
+ Assert.NotNull(operation.ViewDefinition);
+ Assert.Contains("time_bucket('1 hour'", operation.ViewDefinition);
+ Assert.Equal(string.Empty, operation.TimeBucketWidth);
+ Assert.Equal(string.Empty, operation.TimeBucketSourceColumn);
+ Assert.Equal("hourly_metrics", operation.MaterializedViewName);
+ }
+
+ #endregion
+
+ #region Should_Skip_ContinuousAggregate_When_Neither_ViewDefinition_Nor_TimeBucketWidth_Is_Set
+
+ private class NoStructuredOrRawSourceMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class NoStructuredOrRawAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class NoStructuredOrRawContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet HourlyMetrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(NoStructuredOrRawSourceMetric));
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Skip_ContinuousAggregate_When_Neither_ViewDefinition_Nor_TimeBucketWidth_Is_Set()
+ {
+ // Arrange
+ using NoStructuredOrRawContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert
+ Assert.Empty(operations);
+ }
+
+ #endregion
+
+ #region Should_Resolve_Parent_When_ParentName_Annotation_Holds_TableName_Not_ClrName
+
+ private class ApiRequestLog
+ {
+ public DateTime Timestamp { get; set; }
+ public int StatusCode { get; set; }
+ }
+
+ private class ApiRequestLogHourlyAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class TableNameParentLookupContext : DbContext
+ {
+ // Note: CLR class is "ApiRequestLog" but the table is "ApiRequestLogs".
+ public DbSet Logs => Set();
+ public DbSet HourlyLogs => Set();
+
+ protected 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();
+ // Distinct table name so ParentName == "ApiRequestLogs" matches table only, not CLR / short name
+ entity.ToTable("ApiRequestLogs");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_api_logs");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_api_logs");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "ApiRequestLogs");
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"ApiRequestLogs\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Resolve_Parent_When_ParentName_Annotation_Holds_TableName_Not_ClrName()
+ {
+ // Arrange
+ using TableNameParentLookupContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert
+ CreateContinuousAggregateOperation operation = Assert.Single(operations);
+ Assert.Equal("ApiRequestLogs", operation.ParentName);
+ Assert.Equal("hourly_api_logs", operation.MaterializedViewName);
+ }
+
+ #endregion
+
+ #region Should_Use_View_Schema_When_ToView_Specifies_Custom_Schema
+
+ private class ViewSchemaSourceMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class ViewSchemaAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class ViewSchemaContext : 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();
+ // Parent in different schema than the CA's view schema
+ entity.ToTable("Metrics", "telemetry");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ // CA mapped via .ToView with explicit custom schema
+ entity.ToView("agg_view", "custom_schema");
+
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(ViewSchemaSourceMetric));
+ entity.HasAnnotation(
+ ContinuousAggregateAnnotations.ViewDefinition,
+ "SELECT time_bucket('1 hour', \"Timestamp\") AS bucket FROM \"telemetry\".\"Metrics\" GROUP BY bucket;");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Use_View_Schema_When_ToView_Specifies_Custom_Schema()
+ {
+ // Arrange
+ using ViewSchemaContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)];
+
+ // Assert - schema resolution prefers GetViewSchema() over the parent's schema and the default
+ CreateContinuousAggregateOperation operation = Assert.Single(operations);
+ Assert.Equal("custom_schema", operation.Schema);
+ }
+
+ #endregion
}
diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs
index 4c6727c..bb1b276 100644
--- a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs
+++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs
@@ -531,4 +531,401 @@ public void Should_Extract_Policy_From_Attribute()
}
#endregion
+
+ #region Should_Use_View_Schema_For_Policy_When_ToView_Specifies_Custom_Schema
+
+ private class ViewSchemaPolicySourceMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class ViewSchemaPolicyAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class ViewSchemaPolicyContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet Aggregates => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ // Parent in a different schema than the CA's view schema
+ entity.ToTable("Metrics", "telemetry");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ // CA mapped via .ToView with explicit custom schema
+ entity.ToView("agg_view", "custom_schema");
+
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(ViewSchemaPolicySourceMetric));
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "1 month");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "1 hour");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Use_View_Schema_For_Policy_When_ToView_Specifies_Custom_Schema()
+ {
+ // Arrange
+ using ViewSchemaPolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Equal("custom_schema", op.Schema);
+ Assert.Equal("agg_view", op.MaterializedViewName);
+ }
+
+ #endregion
+
+ #region Should_Resolve_Parent_For_Policy_When_ParentName_Annotation_Holds_TableName_Not_ClrName
+
+ private class TableNameParentLookupPolicyMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public int StatusCode { get; set; }
+ }
+
+ private class TableNameParentLookupPolicyAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class TableNameParentLookupPolicyContext : DbContext
+ {
+ // Note: CLR name "TableNameParentLookupPolicyMetric" but the table is "ApiRequestLogs".
+ public DbSet Logs => Set();
+ public DbSet HourlyLogs => Set();
+
+ protected 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("ApiRequestLogs", "telemetry");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("hourly_api_logs", "telemetry");
+
+ // Scaffolder writes the table name into ParentName, not the CLR / short name.
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_api_logs");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "ApiRequestLogs");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "1 month");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "1 hour");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Resolve_Parent_For_Policy_When_ParentName_Annotation_Holds_TableName_Not_ClrName()
+ {
+ // Arrange
+ using TableNameParentLookupPolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Equal("hourly_api_logs", op.MaterializedViewName);
+ Assert.Equal("telemetry", op.Schema);
+ }
+
+ #endregion
+
+ #region Should_Emit_Policy_When_ParentName_Annotation_Is_Missing
+
+ private class NoParentNameMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class NoParentNameAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class NoParentNamePolicyContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet Aggregates => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("agg_view", "custom_schema");
+
+ // HasRefreshPolicy set, but ParentName annotation deliberately omitted
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "1 month");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "1 hour");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Emit_Policy_When_ParentName_Annotation_Is_Missing()
+ {
+ // Arrange
+ using NoParentNamePolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert: policy is still produced; schema falls through to the view's own schema
+ // because the parent-lookup branch is short-circuited by the IsNullOrWhiteSpace guard.
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Equal("agg_view", op.MaterializedViewName);
+ Assert.Equal("custom_schema", op.Schema);
+ }
+
+ #endregion
+
+ #region Should_Emit_Policy_When_ParentName_Does_Not_Match_Any_Entity
+
+ private class UnmatchedParentMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class UnmatchedParentAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class UnmatchedParentPolicyContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet Aggregates => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("agg_view", "custom_schema");
+
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ // ParentName matches nothing in the model β covers the path where all three
+ // disjuncts in the FirstOrDefault predicate (CLR name / ShortName / table name)
+ // return false for every entity.
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, "DoesNotExist");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "1 month");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "1 hour");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Emit_Policy_When_ParentName_Does_Not_Match_Any_Entity()
+ {
+ // Arrange
+ using UnmatchedParentPolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert: parent lookup yields null, schema falls through to the view's own schema.
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Equal("custom_schema", op.Schema);
+ }
+
+ #endregion
+
+ #region Should_Emit_Policy_With_Null_Offsets_And_ScheduleInterval
+
+ private class OptionalOffsetsMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class OptionalOffsetsAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class OptionalOffsetsPolicyContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet Aggregates => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToView("agg_view", "custom_schema");
+
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(OptionalOffsetsMetric));
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Emit_Policy_With_Null_Offsets_And_ScheduleInterval()
+ {
+ // Arrange
+ using OptionalOffsetsPolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert: optional fields stay null when their annotations are absent.
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Null(op.StartOffset);
+ Assert.Null(op.EndOffset);
+ Assert.Null(op.ScheduleInterval);
+ }
+
+ #endregion
+
+ #region Should_Use_DefaultSchema_When_No_Schema_Sources_Available
+
+ private class NoSchemaMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class NoSchemaAggregate
+ {
+ public DateTime Bucket { get; set; }
+ }
+
+ private class NoSchemaPolicyContext : DbContext
+ {
+ public DbSet Metrics => Set();
+ public DbSet Aggregates => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // Parent has no explicit schema.
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ entity.IsHypertable(x => x.Timestamp);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ // No .ToView / .ToTable β view schema and entity schema both null.
+ // Combined with parent having no schema, the resolution chain falls
+ // all the way through to DefaultValues.DefaultSchema.
+ entity.HasAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "agg_view");
+ entity.HasAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(NoSchemaMetric));
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true);
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.StartOffset, "1 month");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.EndOffset, "1 hour");
+ entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, "1 hour");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Use_DefaultSchema_When_No_Schema_Sources_Available()
+ {
+ // Arrange
+ using NoSchemaPolicyContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations =
+ [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(relationalModel)];
+
+ // Assert: with no view schema, no entity schema, and no parent schema,
+ // resolution falls through to DefaultValues.DefaultSchema ("public").
+ AddContinuousAggregatePolicyOperation op = Assert.Single(operations);
+ Assert.Equal("public", op.Schema);
+ }
+
+ #endregion
}
diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs
index e2503fe..9dbf883 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.Text.Json;
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors;
@@ -1329,4 +1330,171 @@ public void Should_Extract_Dimension_With_Explicit_Column_Name()
}
#endregion
+
+ #region Should_Extract_Hypertable_When_TimeColumnName_Annotation_Holds_Column_Name_Under_SnakeCase
+
+ private class ScaffoldedTimeColumnMetric
+ {
+ public DateTime Time { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class ScaffoldedTimeColumnContext : 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("metrics");
+ entity.HasAnnotation(HypertableAnnotations.IsHypertable, true);
+ entity.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_Hypertable_When_TimeColumnName_Annotation_Holds_Column_Name_Under_SnakeCase()
+ {
+ // Arrange
+ using ScaffoldedTimeColumnContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)];
+
+ // Assert
+ Assert.Single(operations);
+ Assert.Equal("time", operations[0].TimeColumnName);
+ }
+
+ #endregion
+
+ #region Should_Extract_ChunkSkipColumns_When_Annotation_Holds_Column_Names_Under_SnakeCase
+
+ private class ScaffoldedChunkSkipMetric
+ {
+ public DateTime Time { get; set; }
+ public string DeviceId { get; set; } = string.Empty;
+ public string Location { get; set; } = string.Empty;
+ public double Value { get; set; }
+ }
+
+ private class ScaffoldedChunkSkipContext : 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("metrics");
+
+ // Annotations carry resolved column names (snake_case), mimicking scaffolder output.
+ entity.HasAnnotation(HypertableAnnotations.IsHypertable, true);
+ entity.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time");
+ entity.HasAnnotation(HypertableAnnotations.ChunkSkipColumns, "device_id,location");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_ChunkSkipColumns_When_Annotation_Holds_Column_Names_Under_SnakeCase()
+ {
+ // Arrange
+ using ScaffoldedChunkSkipContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)];
+
+ // Assert
+ Assert.Single(operations);
+ Assert.NotNull(operations[0].ChunkSkipColumns);
+ Assert.Equal(2, operations[0].ChunkSkipColumns!.Count);
+ Assert.Contains("device_id", operations[0].ChunkSkipColumns!);
+ Assert.Contains("location", operations[0].ChunkSkipColumns!);
+ }
+
+ #endregion
+
+ #region Should_Extract_AdditionalDimensions_When_Json_Holds_Column_Names_Under_SnakeCase
+
+ private class ScaffoldedDimensionMetric
+ {
+ public DateTime Time { get; set; }
+ public string DeviceId { get; set; } = string.Empty;
+ public string Location { get; set; } = string.Empty;
+ public double Value { get; set; }
+ }
+
+ private class ScaffoldedDimensionContext : 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("metrics");
+
+ // Dimensions JSON carries resolved column names (snake_case), as the scaffolder produces.
+ List dimensions = [
+ Dimension.CreateHash("device_id", 4),
+ Dimension.CreateRange("location", "1000")
+ ];
+
+ entity.HasAnnotation(HypertableAnnotations.IsHypertable, true);
+ entity.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, "time");
+ entity.HasAnnotation(HypertableAnnotations.AdditionalDimensions, JsonSerializer.Serialize(dimensions));
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Extract_AdditionalDimensions_When_Json_Holds_Column_Names_Under_SnakeCase()
+ {
+ // Arrange
+ using ScaffoldedDimensionContext context = new();
+ IRelationalModel relationalModel = GetRelationalModel(context);
+
+ // Act
+ List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)];
+
+ // Assert
+ Assert.Single(operations);
+ Assert.NotNull(operations[0].AdditionalDimensions);
+ Assert.Equal(2, operations[0].AdditionalDimensions!.Count);
+
+ Dimension hashDim = operations[0].AdditionalDimensions![0];
+ Assert.Equal("device_id", hashDim.ColumnName);
+ Assert.Equal(EDimensionType.Hash, hashDim.Type);
+ Assert.Equal(4, hashDim.NumberOfPartitions);
+
+ Dimension rangeDim = operations[0].AdditionalDimensions![1];
+ Assert.Equal("location", rangeDim.ColumnName);
+ Assert.Equal(EDimensionType.Range, rangeDim.Type);
+ Assert.Equal("1000", rangeDim.Interval);
+ }
+
+ #endregion
}
diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs
index 439f1f9..440a24c 100644
--- a/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs
+++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateOperationGeneratorTests.cs
@@ -1263,5 +1263,125 @@ public void Create_StandardAggregates_DoNotRequireTimeParameter()
}
#endregion
+
+ #region CreateContinuousAggregateOperation Tests - Raw ViewDefinition
+
+ [Fact]
+ public void Runtime_Create_WithViewDefinition_GeneratesRawCreateMaterializedView()
+ {
+ // Arrange - scaffolded round-trip path: raw view body, no structured fields needed
+ CreateContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = "hourly_metrics",
+ Schema = "public",
+ ParentName = "metrics",
+ ViewDefinition = "SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\"\n GROUP BY bucket;",
+ MaterializedOnly = true,
+ CreateGroupIndexes = false,
+ WithNoData = false
+ };
+
+ // Act
+ ContinuousAggregateOperationGenerator generator = new(isDesignTime: false);
+ List statements = generator.Generate(operation);
+
+ // Assert
+ string sql = Assert.Single(statements);
+ Assert.Contains("CREATE MATERIALIZED VIEW \"public\".\"hourly_metrics\"", sql);
+ Assert.Contains("timescaledb.continuous, timescaledb.create_group_indexes = false, timescaledb.materialized_only = true", sql);
+ // Raw body present (with leading semicolon stripped) and a final terminating semicolon appended
+ Assert.Contains("SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\"", sql);
+ Assert.Contains("GROUP BY bucket", sql);
+ Assert.EndsWith(";", sql);
+ // Raw body's own trailing ';' is stripped, so only one ';' at the end
+ Assert.False(sql.EndsWith(";;"));
+ // No structured SELECT synthesis appears
+ Assert.DoesNotContain("AS time_bucket,", sql);
+ }
+
+ [Fact]
+ public void Runtime_Create_WithViewDefinition_AndWithNoData_AppendsWithNoDataBeforeSemicolon()
+ {
+ // Arrange
+ CreateContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = "hourly_metrics",
+ Schema = "public",
+ ParentName = "metrics",
+ ViewDefinition = "SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\" GROUP BY bucket",
+ WithNoData = true
+ };
+
+ // Act
+ ContinuousAggregateOperationGenerator generator = new(isDesignTime: false);
+ List statements = generator.Generate(operation);
+
+ // Assert
+ string sql = Assert.Single(statements);
+ Assert.Contains("WITH NO DATA", sql);
+ // WITH NO DATA must come immediately before the trailing ';'
+ Assert.EndsWith("WITH NO DATA;", sql);
+ }
+
+ [Fact]
+ public void DesignTime_Create_WithViewDefinition_DoublesEmbeddedQuotes()
+ {
+ // Arrange
+ CreateContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = "hourly_metrics",
+ Schema = "public",
+ ParentName = "metrics",
+ ViewDefinition = "SELECT time_bucket('1 hour', \"time\") AS bucket FROM \"src\" GROUP BY bucket;"
+ };
+
+ // Act - design-time mode escapes embedded `"` to `""` for the C# verbatim string literal
+ ContinuousAggregateOperationGenerator generator = new(isDesignTime: true);
+ List statements = generator.Generate(operation);
+
+ // Assert
+ string sql = Assert.Single(statements);
+ Assert.Contains("CREATE MATERIALIZED VIEW \"\"public\"\".\"\"hourly_metrics\"\"", sql);
+ Assert.Contains("\"\"time\"\"", sql);
+ Assert.Contains("\"\"src\"\"", sql);
+ // No raw single `"` should appear in the body β all should be doubled
+ Assert.DoesNotContain("\"time\"", sql.Replace("\"\"time\"\"", ""));
+ }
+
+ [Fact]
+ public void Runtime_Create_WithViewDefinition_IgnoresStructuredFields()
+ {
+ // Arrange - even when structured fields are populated with nonsense values,
+ // the raw ViewDefinition must take precedence and they must not appear in output.
+ CreateContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = "hourly_metrics",
+ Schema = "public",
+ ParentName = "metrics",
+ ViewDefinition = "SELECT raw_only AS bucket FROM \"src\" GROUP BY bucket;",
+ TimeBucketWidth = "BOGUS_WIDTH",
+ TimeBucketSourceColumn = "bogus_column",
+ TimeBucketGroupBy = true,
+ AggregateFunctions = ["bogus_alias:Avg:bogus_source"],
+ GroupByColumns = ["bogus_groupby"],
+ WhereClause = "bogus_where = 1"
+ };
+
+ // Act
+ ContinuousAggregateOperationGenerator generator = new(isDesignTime: false);
+ List statements = generator.Generate(operation);
+
+ // Assert - raw body is present, structured fields are not
+ string sql = Assert.Single(statements);
+ Assert.Contains("SELECT raw_only AS bucket FROM \"src\" GROUP BY bucket", sql);
+ Assert.DoesNotContain("BOGUS_WIDTH", sql);
+ Assert.DoesNotContain("bogus_column", sql);
+ Assert.DoesNotContain("bogus_alias", sql);
+ Assert.DoesNotContain("bogus_source", sql);
+ Assert.DoesNotContain("bogus_groupby", sql);
+ Assert.DoesNotContain("bogus_where", sql);
+ }
+
+ #endregion
}
}
diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs
index b2ffa10..a4fd02a 100644
--- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs
+++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs
@@ -774,6 +774,152 @@ public async Task Should_Extract_Range_Dimension_With_Integer_Interval()
#endregion
+ #region Should_Extract_ChunkTimeInterval_In_Microseconds_For_OneDay
+
+ private class OneDayChunkIntervalMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class OneDayChunkIntervalContext(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("one_day_chunk_metrics");
+ entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("86400000000");
+ });
+ }
+ }
+
+ [Fact]
+ public async Task Should_Extract_ChunkTimeInterval_In_Microseconds_For_OneDay()
+ {
+ // Arrange
+ await using OneDayChunkIntervalContext context = new(_connectionString!);
+ await CreateDatabaseViaMigrationAsync(context);
+
+ // Act
+ HypertableScaffoldingExtractor extractor = new();
+ await using NpgsqlConnection connection = new(_connectionString);
+ Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection);
+
+ // Assert
+ HypertableScaffoldingExtractor.HypertableInfo info =
+ (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "one_day_chunk_metrics")];
+ Assert.Equal("86400000000", info.ChunkTimeInterval);
+ }
+
+ #endregion
+
+ #region Should_Extract_ChunkTimeInterval_In_Microseconds_For_SevenDays
+
+ private class SevenDaysChunkIntervalMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class SevenDaysChunkIntervalContext(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("seven_days_chunk_metrics");
+ // 7 days = 604,800 seconds * 1,000,000 us/s = 604,800,000,000 us
+ entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("604800000000");
+ });
+ }
+ }
+
+ [Fact]
+ public async Task Should_Extract_ChunkTimeInterval_In_Microseconds_For_SevenDays()
+ {
+ // Arrange
+ await using SevenDaysChunkIntervalContext context = new(_connectionString!);
+ await CreateDatabaseViaMigrationAsync(context);
+
+ // Act
+ HypertableScaffoldingExtractor extractor = new();
+ await using NpgsqlConnection connection = new(_connectionString);
+ Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection);
+
+ // Assert
+ HypertableScaffoldingExtractor.HypertableInfo info =
+ (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "seven_days_chunk_metrics")];
+ Assert.Equal("604800000000", info.ChunkTimeInterval);
+ }
+
+ #endregion
+
+ #region Should_Extract_AdditionalDimension_TimeRange_In_Microseconds
+
+ private class TimeRangeDimensionMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public DateTime SecondaryTimestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class TimeRangeDimensionContext(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("time_range_dim_metrics");
+ // 30 days expressed in microseconds (30 * 86,400 * 1,000,000 = 2,592,000,000,000)
+ entity.IsHypertable(x => x.Timestamp)
+ .HasDimension(Dimension.CreateRange("SecondaryTimestamp", "2592000000000"));
+ });
+ }
+ }
+
+ [Fact]
+ public async Task Should_Extract_AdditionalDimension_TimeRange_In_Microseconds()
+ {
+ // Arrange
+ await using TimeRangeDimensionContext context = new(_connectionString!);
+ await CreateDatabaseViaMigrationAsync(context);
+
+ // Act
+ HypertableScaffoldingExtractor extractor = new();
+ await using NpgsqlConnection connection = new(_connectionString);
+ Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection);
+
+ // Assert
+ HypertableScaffoldingExtractor.HypertableInfo info =
+ (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "time_range_dim_metrics")];
+ Dimension dimension = Assert.Single(info.AdditionalDimensions);
+ Assert.Equal("SecondaryTimestamp", dimension.ColumnName);
+ Assert.Equal(EDimensionType.Range, dimension.Type);
+ // 30 days in microseconds
+ Assert.Equal("2592000000000", dimension.Interval);
+ }
+
+ #endregion
+
#region Should_Extract_Multiple_Hypertables
private class MultipleHypertablesMetric
diff --git a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs
index 6405a4c..1ef11af 100644
--- a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs
+++ b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs
@@ -14,12 +14,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration;
///
///
///
-/// Issue #15 Background: The original bug was that TimescaleMigrationsModelDiffer used List.Sort()
-/// which is an unstable sort and destroyed the correct dependency order from base.GetDifferences().
-/// The fix was to use OrderBy() which is a stable sort that preserves the relative order
-/// of elements with equal priority values.
-///
-///
/// Why Unstable Sorts Don't Reliably Fail Tests:
/// List.Sort() is an unstable sort, meaning it does not guarantee preservation of relative order
/// for elements with equal sort keys. However, it doesn't randomly shuffle elements - it uses an
@@ -28,19 +22,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration;
/// 2. The number of elements
/// 3. The distribution of priorities
///
-///
-/// In practice, for small lists (like in these tests), List.Sort() often appears stable even though
-/// it's not guaranteed to be. The bug was intermittent in production because:
-/// - EF Core orders CreateTable before CreateIndex (stable order from base differ)
-/// - All standard EF Core operations have priority 0
-/// - List.Sort() might preserve their order... or might not
-/// - The issue manifested unpredictably, especially with larger/more complex models
-///
-///
-/// These tests verify correct behavior but cannot reliably detect the unstable sort bug.
-/// They serve as regression tests to ensure OrderBy() is used and the correct ordering is maintained.
-/// Manual code review is needed to verify the stable sort is in place.
-///
///
public class MigrationOperationOrderingTests : MigrationTestBase
{
diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs
index ec9a468..ae63769 100644
--- a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs
+++ b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs
@@ -838,8 +838,6 @@ public async Task Should_Alter_RetentionPolicy_DropAfter_To_DropCreatedBefore_Pr
await using ModifiedPreserveJobSettingsContext modifiedContext = new(_connectionString!);
await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext);
- // The recreation path must reapply non-default job settings on the new policy
- // (this is exactly the flow the pre-2.26.3 alter_job bug used to break).
int jobId = await GetRetentionPolicyJobIdAsync(modifiedContext, "retention_preserve_job_settings");
Assert.True(jobId > 0);
diff --git a/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs
new file mode 100644
index 0000000..43c66f7
--- /dev/null
+++ b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs
@@ -0,0 +1,240 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Internals;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals;
+
+///
+/// Tests that verify resolves either a CLR property
+/// name or a raw column name back to the database column name on a given entity.
+/// Reverse lookup is the path used by the design-time scaffolder, which emits
+/// already-translated column names into TimescaleDB annotations.
+///
+public class ColumnNameResolverTests
+{
+ private static (IEntityType entityType, StoreObjectIdentifier storeIdentifier) GetEntityAndStoreIdentifier(TContext context, string tableName)
+ where TContext : DbContext
+ {
+ IModel model = context.GetService().Model;
+ IEntityType entityType = model.GetEntityTypes().Single(e => e.GetTableName() == tableName);
+ StoreObjectIdentifier storeIdentifier = StoreObjectIdentifier.Table(tableName, entityType.GetSchema());
+ return (entityType, storeIdentifier);
+ }
+
+ #region Should_Resolve_Clr_Property_Name_To_Column_Name_With_Default_Convention
+
+ private class DefaultConventionMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class DefaultConventionContext : DbContext
+ {
+ public DbSet Metrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Resolve_Clr_Property_Name_To_Column_Name_With_Default_Convention()
+ {
+ // Arrange
+ using DefaultConventionContext context = new();
+ (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics");
+
+ // Act
+ string? resolved = ColumnNameResolver.Resolve(entityType, "Timestamp", storeIdentifier);
+
+ // Assert
+ Assert.Equal("Timestamp", resolved);
+ }
+
+ #endregion
+
+ #region Should_Resolve_Clr_Property_Name_Under_Snake_Case_Convention
+
+ private class SnakeCaseClrMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class SnakeCaseClrContext : 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("Metrics");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Resolve_Clr_Property_Name_Under_Snake_Case_Convention()
+ {
+ // Arrange β passing the CLR property name should yield the snake_case column.
+ using SnakeCaseClrContext context = new();
+ (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics");
+
+ // Act
+ string? resolved = ColumnNameResolver.Resolve(entityType, "Timestamp", storeIdentifier);
+
+ // Assert
+ Assert.Equal("timestamp", resolved);
+ }
+
+ #endregion
+
+ #region Should_Resolve_Value_Already_In_Column_Name_Form_Via_Reverse_Lookup
+
+ private class ReverseLookupMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class ReverseLookupContext : 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("Metrics");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Resolve_Value_Already_In_Column_Name_Form_Via_Reverse_Lookup()
+ {
+ // Arrange β feeding the snake_case column name (as the scaffolder emits) must
+ // still resolve to the matching column via reverse lookup.
+ using ReverseLookupContext context = new();
+ (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics");
+
+ // Act
+ string? resolved = ColumnNameResolver.Resolve(entityType, "timestamp", storeIdentifier);
+
+ // Assert
+ Assert.Equal("timestamp", resolved);
+ }
+
+ #endregion
+
+ #region Should_Return_Null_For_Unknown_Name
+
+ private class UnknownNameMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class UnknownNameContext : DbContext
+ {
+ public DbSet Metrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ });
+ }
+ }
+
+ [Fact]
+ public void Should_Return_Null_For_Unknown_Name()
+ {
+ // Arrange
+ using UnknownNameContext context = new();
+ (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics");
+
+ // Act
+ string? resolved = ColumnNameResolver.Resolve(entityType, "DoesNotExist", storeIdentifier);
+
+ // Assert
+ Assert.Null(resolved);
+ }
+
+ #endregion
+
+ #region Should_Return_Null_For_Null_Or_Whitespace_Input
+
+ private class NullOrWhitespaceInputMetric
+ {
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ }
+
+ private class NullOrWhitespaceInputContext : DbContext
+ {
+ public DbSet Metrics => Set();
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
+ .UseTimescaleDb();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasNoKey();
+ entity.ToTable("Metrics");
+ });
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Should_Return_Null_For_Null_Or_Whitespace_Input(string? input)
+ {
+ // Arrange
+ using NullOrWhitespaceInputContext context = new();
+ (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics");
+
+ // Act
+ string? resolved = ColumnNameResolver.Resolve(entityType, input, storeIdentifier);
+
+ // Assert
+ Assert.Null(resolved);
+ }
+
+ #endregion
+}