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 -![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) @@ -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 +}