diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index 002e509..c9c1405 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -35,19 +35,23 @@ This document provides detailed architectural information for the CmdScale.Entit | File | Purpose | |------|---------| | `TimescaleDbServiceCollectionExtensions.cs` | Registers `IMigrationsModelDiffer`, `IConventionSetPlugin` | -| `TimescaleDbContextOptionsBuilderExtensions.cs` | Service registration via `UseTimescaleDb()` | +| `TimescaleDbContextOptionsBuilderExtensions.cs` | Service registration via `UseTimescaleDb()` and `UseTimescaleDb(o => o.UseLegacyCompressionSql())` | +| `TimescaleDbOptions.cs` | Provider options: `UseLegacyCompressionSql()` opts into pre-2.18 compression API naming | | `TimescaleDbMigrationsSqlGenerator.cs` | Runtime SQL generator for `dotnet ef database update` | ### Configuration/ - Feature Subsystems > When adding new features, follow the same directory structure pattern. -#### Hypertable/ (5 files) -- `HypertableAttribute.cs` - Data annotation: `[Hypertable("TimeColumn", ChunkTimeInterval = "1 day")]` +#### Hypertable/ (8 files) +- `HypertableAttribute.cs` - Data annotation: `[Hypertable("TimeColumn", ChunkTimeInterval = "1 day", CompressChunkTimeInterval = "7 days", DisableAutoSparseIndexes = true)]` - `DimensionAttribute.cs` - Data annotation for additional partitioning dimensions: `[Dimension("Col", EDimensionType.Range, "1 month")]` -- `HypertableConvention.cs` - IEntityTypeAddedConvention implementation; processes both `[Hypertable]` and `[Dimension]` attributes +- `HypertableConvention.cs` - IEntityTypeAddedConvention implementation; processes `[Hypertable]`, `[Dimension]`, and `[SparseIndex]` attributes - `HypertableAnnotations.cs` - Annotation constants -- `HypertableTypeBuilder.cs` - Fluent API: `IsHypertable()`, `WithChunkTimeInterval()`, `HasRangeDimension()`, `HasHashDimension()`, etc. +- `HypertableTypeBuilder.cs` - Fluent API: `IsHypertable()`, `WithChunkTimeInterval()`, `WithSparseIndex()`, `WithoutAutoSparseIndexes()`, `WithCompressChunkTimeInterval()`, `HasRangeDimension()`, `HasHashDimension()`, etc. +- `SparseIndex.cs` - `SparseIndex` value type and `SparseIndexSelector` typed fluent builder for `.Bloom()`/`.MinMax()` entries +- `SparseIndexAttribute.cs` - `[SparseIndex(ESparseIndexType.Bloom, nameof(Col))]` data annotation; AllowMultiple; `DisableAutoSparseIndexes` on `[Hypertable]` for disabling auto-generated indexes +- `SparseIndexValidationConvention.cs` - IModelFinalizedConvention validating sparse index entries against compression segmentby/orderby constraints, arity rules, and duplicate detection #### ReorderPolicy/ (5 files) - `ReorderPolicyAttribute.cs` - Data annotation: `[ReorderPolicy("index_name")]` @@ -57,12 +61,20 @@ This document provides detailed architectural information for the CmdScale.Entit - `ReorderPolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code; exposes `WithInitialStart(DateTime)` as a chained method (DateTime cannot be rendered as a positional literal via `MethodCallCodeFragment`) #### RetentionPolicy/ (5 files) -- `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy(DropAfter = "30 days")]` +- `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy("30 days")]`; exactly one of `DropAfter`/`DropCreatedBefore` is required — constructor args validate fail-fast, property-initializer style (`DropAfter = "..."`) remains supported and is validated by the convention - `RetentionPolicyConvention.cs` - IEntityTypeAddedConvention implementation - `RetentionPolicyAnnotations.cs` - Annotation constants - `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()`; includes a scaffold-targeting overload that takes 6 positional string parameters and returns `RetentionPolicyStringBuilder` - `RetentionPolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code; exposes `WithInitialStart(DateTime)` as a chained method (DateTime cannot be rendered as a positional literal via `MethodCallCodeFragment`) +#### CompressionPolicy/ (6 files) +- `CompressionPolicyAttribute.cs` - Data annotation: `[CompressionPolicy(After = "7 days")]`; exactly one of `After`/`CreatedBefore` is required (XOR validated) +- `CompressionPolicyConvention.cs` - IEntityTypeAddedConvention implementation +- `CompressionPolicyAnnotations.cs` - Annotation constants +- `CompressionPolicyTypeBuilder.cs` - Fluent API: `WithCompressionPolicy(after: "7 days", ...)`; optional `scheduleInterval`, `initialStart`, `timezone`, `ifNotExists`; includes scaffold-targeting overload returning `CompressionPolicyStringBuilder` +- `CompressionPolicyStringBuilder.cs` - String-based builder used by scaffolded `OnModelCreating` code; exposes `WithInitialStart(DateTime)` as a chained method +- `CompressionPolicyPrerequisiteValidationConvention.cs` - IModelFinalizedConvention that validates compression is enabled on any continuous aggregate before a compression policy is applied; runs at finalization so all fluent API is visible + #### ContinuousAggregate/ (11 files) - `ContinuousAggregateAttribute.cs` - Entity-level attribute defining materialized view - `TimeBucketAttribute.cs` - Property-level attribute for time bucketing @@ -88,6 +100,7 @@ This document provides detailed architectural information for the CmdScale.Entit - `PolicyJobBuilderCore.cs` - Shared base class providing annotation helpers for policy-job fields common to all three policy builder cores (ScheduleInterval, MaxRuntime, MaxRetries, RetryPeriod, InitialStart) #### Cross-cutting +- `ConventionValidationHelper.cs` - Internal static helper used by multiple convention and type-builder classes: `ValidateExclusiveFields` enforces XOR constraints (e.g. `After`/`CreatedBefore`, `DropAfter`/`DropCreatedBefore`) with entity-context error messages; `ParseInitialStart` centralizes `DateTime.TryParse` with a consistent error format - `TimeColumnStoreTypeValidationConvention.cs` - IModelFinalizedConvention validating that hypertable and continuous-aggregate time columns resolve to a PostgreSQL time-dimension store type (timestamp/timestamptz/date/integer); backed by `Internals/TimeColumnStoreTypeValidator.cs` ### Abstractions/ - Domain Objects @@ -97,6 +110,7 @@ This document provides detailed architectural information for the CmdScale.Entit | `Dimension.cs` | Represents range/hash partitioning with factory methods | | `EDimensionType.cs` | Enum: `Range`, `Hash` | | `EAggregateFunction.cs` | Enum: `Avg`, `Sum`, `Min`, `Max`, `Count`, `First`, `Last` | +| `ESparseIndexType.cs` | Enum: `Bloom`, `MinMax` — identifies the sparse index function | | `ContinuousAggregateFunction.cs` | Strongly-typed `(Alias, Function, SourceColumn)` for continuous-aggregate columns; `ToAnnotationValue()` serializes to the `alias:Function:sourceColumn` wire format | ### Operations/ - Migration Operations @@ -108,6 +122,7 @@ All inherit `MigrationOperation` and contain feature-specific properties: - `AddRetentionPolicyOperation.cs` / `AlterRetentionPolicyOperation.cs` / `DropRetentionPolicyOperation.cs` - `CreateContinuousAggregateOperation.cs` / `AlterContinuousAggregateOperation.cs` / `DropContinuousAggregateOperation.cs` - `AddContinuousAggregatePolicyOperation.cs` / `RemoveContinuousAggregatePolicyOperation.cs` +- `AddCompressionPolicyOperation.cs` / `AlterCompressionPolicyOperation.cs` / `DropCompressionPolicyOperation.cs` ### Query/ - EF.Functions Extensions and LINQ Translators @@ -134,6 +149,8 @@ Each `*SqlGenerator` exposes `static List Generate(XxxOperation operatio | `RetentionPolicySqlGenerator.cs` | `add_retention_policy()`, `remove_retention_policy()`, `alter_job` tuning | | `ContinuousAggregateSqlGenerator.cs` | `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` plus drop/alter SQL | | `ContinuousAggregatePolicySqlGenerator.cs` | `add_continuous_aggregate_policy()` / `remove_continuous_aggregate_policy()` | +| `CompressionPolicySqlGenerator.cs` | `CALL add_columnstore_policy()` / `CALL remove_columnstore_policy()` (2.18+ default); falls back to `add_compression_policy` / `remove_compression_policy` when `UseLegacyCompressionSql()` is set | +| `CompressionSettingsSqlHelper.cs` | Shared SQL-building helpers for compression settings: builds the `SET (timescaledb.enable_columnstore = ...)` / `SET (timescaledb.compress = ...)` clause depending on legacy mode, computes changed-settings list for alter operations; used by both hypertable and continuous-aggregate SQL generators | | `PolicyJobSqlBuilder.cs` | Shared `alter_job` clause builder (schedule interval, max runtime, retries, retry period) used by reorder/retention/CA refresh policies | | `SqlBuilderHelper.cs` | `Regclass()`, `QualifiedIdentifier()`, `QuoteIdentifier()`, statement grouping, and `SELECT`→`PERFORM` rewriting for idempotent scripts | @@ -148,12 +165,16 @@ Generated migrations call strongly-typed extension methods that construct a `Mig | `RetentionPolicyMigrationExtensions.cs` | `AddRetentionPolicy(...)`, `AlterRetentionPolicy(...)`, `DropRetentionPolicy(...)` | | `ContinuousAggregateMigrationExtensions.cs` | `CreateContinuousAggregate(...)`, `AlterContinuousAggregate(...)`, `DropContinuousAggregate(...)` | | `ContinuousAggregatePolicyMigrationExtensions.cs` | `AddContinuousAggregatePolicy(...)`, `RemoveContinuousAggregatePolicy(...)` | +| `CompressionPolicyMigrationExtensions.cs` | `AddCompressionPolicy(...)`, `AlterCompressionPolicy(...)`, `DropCompressionPolicy(...)` | ### Internals/ - Core Diffing Logic - `TimescaleMigrationsModelDiffer.cs` - Extends EF Core's MigrationsModelDiffer; orchestrates the feature differs, builds the `FeatureDiffContext`, implements `GetOperationPriority()` - `Features/IFeatureDiffer.cs` - Interface: `GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)` - `Features/FeatureDiffContext.cs` - Cross-cutting diff state passed to every feature differ +- `Features/CompressionDiffHelper.cs` - Shared comparison and rewrite helpers for compression differ logic; used by both hypertable and continuous-aggregate differs; provides `AreStringListsEqual`, `AreOrderByListsEqual`, `NormalizeOrderByEntry`, `RewriteColumns`, and `RewriteOrderByColumns` +- `CompressionAnnotationExtractor.cs` - Shared helpers for extracting segment-by, order-by, and sparse-index column lists from entity-type annotations with CLR property → database column name resolution; used by both hypertable and continuous-aggregate model extractors +- `ExpressionHelper.cs` - Shared static helper: `GetPropertyName(Expression)` consolidates lambda-to-property-name extraction across the fluent API - `ParentEntityTypeResolver.cs` - Resolves a continuous aggregate's parent `IEntityType` by matching CLR class name, EF Core short name, or database table name; handles both code-first and scaffolded models **Feature-specific:** @@ -162,6 +183,7 @@ Generated migrations call strongly-typed extension methods that construct a `Mig - `Features/RetentionPolicies/` - `RetentionPolicyDiffer.cs`, `RetentionPolicyModelExtractor.cs` - `Features/ContinuousAggregates/` - `ContinuousAggregateDiffer.cs`, `ContinuousAggregateModelExtractor.cs` - `Features/ContinuousAggregatePolicies/` - `ContinuousAggregatePolicyDiffer.cs`, `ContinuousAggregatePolicyModelExtractor.cs` +- `Features/CompressionPolicies/` - `CompressionPolicyDiffer.cs`, `CompressionPolicyModelExtractor.cs`, `CompressionPolicyDefaultHelper.cs` #### FeatureDiffContext @@ -211,6 +233,7 @@ Each `*CSharpGenerator.Generate(XxxOperation, IndentedStringBuilder)` emits one | `RetentionPolicyCSharpGenerator.cs` | Emits `AddRetentionPolicy(...)` / `AlterRetentionPolicy(...)` / `DropRetentionPolicy(...)` | | `ContinuousAggregateCSharpGenerator.cs` | Emits `CreateContinuousAggregate(...)` / `AlterContinuousAggregate(...)` / `DropContinuousAggregate(...)` | | `ContinuousAggregatePolicyCSharpGenerator.cs` | Emits `AddContinuousAggregatePolicy(...)` / `RemoveContinuousAggregatePolicy(...)` | +| `CompressionPolicyCSharpGenerator.cs` | Emits `AddCompressionPolicy(...)` / `AlterCompressionPolicy(...)` / `DropCompressionPolicy(...)` | | `MigrationCallWriter.cs` | `IDisposable` helper that writes a `.Method(` call and named `arg: value` lines | | `CSharpGeneratorHelper.cs` | `LiteralStringList()` for `["a", "b"]` collection expressions and `StaticCall()` for `Type.Method(args)` literals | @@ -230,6 +253,7 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a | `AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | | `AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; `ShouldRender` guard requires the parent renderer (hypertable or continuous aggregate) to have already consumed its annotation | | `AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs` | Renders reorder policy annotations to `WithReorderPolicy(...)` fluent API or `[ReorderPolicy]` attribute; `ShouldRender` guard requires the hypertable renderer to have already consumed its annotation | +| `AnnotationRenderers/CompressionPolicyAnnotationRenderer.cs` | Renders compression policy annotations to `WithCompressionPolicy(...)` fluent API or `[CompressionPolicy]` attribute; `ShouldRender` guard requires the hypertable renderer to have already consumed its annotation | | `AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared static helpers for rendering optional policy-job fields (InitialStart, ScheduleInterval, MaxRuntime, etc.) shared across all policy renderers | | `AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName`, `ResolveColumns` | | `AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` record: renders as `nameof(Property)` or `$"{nameof(Property)} DESC"` | @@ -245,17 +269,20 @@ Converts `DatabaseModel` annotations to C# fluent API calls or data annotation a - `RetentionPolicyScaffoldingExtractor` + `RetentionPolicyAnnotationApplier` - `ContinuousAggregateScaffoldingExtractor` + `ContinuousAggregateAnnotationApplier` - `ContinuousAggregatePolicyScaffoldingExtractor` + `ContinuousAggregatePolicyAnnotationApplier` +- `CompressionPolicyScaffoldingExtractor` + `CompressionPolicyAnnotationApplier` — reads jobs from `timescaledb_information.jobs` joined with `_timescaledb_config.bgw_job` for timezone; applier suppresses default schedule intervals +- `CompressionSettingsScaffoldingHelper` — shared helper that reads `timescaledb_information.hypertable_columnstore_settings` (2.18+) with fallback to `compression_settings` (pre-2.18); provides segmentby, orderby, sparse index, and compress_chunk_time_interval; used by both `HypertableScaffoldingExtractor` and `ContinuousAggregateScaffoldingExtractor` **Phase 2 — Annotation code generation** (`TimescaleDbAnnotationCodeGenerator` + `AnnotationRenderers/`): EF Core's scaffolding pipeline calls `TimescaleDbAnnotationCodeGenerator` to convert those annotations into C# code. The dispatcher iterates its registered `IFeatureAnnotationRenderer` implementations: - When `UseDataAnnotations = false` → `GenerateFluentApiCalls` → fluent API method chains in `OnModelCreating` - When `UseDataAnnotations = true` → `GenerateDataAnnotationAttributes` → `[Attribute]` declarations on entity classes -Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`. Registration order matters: child renderers (`ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`) must run after their respective parent renderers so the `ShouldRender` guard can verify the parent annotation was consumed. +Registered renderers: `HypertableAnnotationRenderer`, `ContinuousAggregateAnnotationRenderer`, `ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`, `CompressionPolicyAnnotationRenderer`. Registration order matters: child renderers (`ContinuousAggregatePolicyAnnotationRenderer`, `RetentionPolicyAnnotationRenderer`, `ReorderPolicyAnnotationRenderer`, `CompressionPolicyAnnotationRenderer`) must run after their respective parent renderers so the `ShouldRender` guard can verify the parent annotation was consumed. `TimescaleCSharpModelGenerator` wraps EF Core's standard model generator and post-processes the generated files to inject missing `using` directives for TimescaleDB attribute namespaces. `TimescaleModelCodeGeneratorSelector` ensures this custom generator is selected. **Additional Design-Time Utilities:** +- `Scaffolding/ScaffoldingExtractorHelper.cs` - Shared infrastructure for all scaffolding extractors: `UsingConnection` execute-around helper (opens/closes the `DbConnection` only when needed, eliminating 12–15 boilerplate lines per extractor), `ViewExists` (parameterized `information_schema.views` lookup), and `TimescaleInternalSchemaExclusion` constant (the four `_timescaledb_*` schema names embedded in SQL `NOT IN` clauses) - `Scaffolding/ViewDefinitionParser.cs` - Parses a continuous aggregate's stored view definition SQL (best-effort, cached) to extract `TimeBucketWidth`, `TimeBucketSourceColumn`, aggregate functions, GROUP BY columns, and WHERE clause; used by `ContinuousAggregateAnnotationRenderer` **Scaffolding/ Interfaces:** @@ -277,11 +304,13 @@ Custom operations are sorted by `TimescaleMigrationsModelDiffer.GetOperationPrio | -60 | `DropRetentionPolicyOperation` | | -50 | `RemoveContinuousAggregatePolicyOperation` | | -40 | `DropContinuousAggregateOperation` | +| -25 | `DropCompressionPolicyOperation` | | -20 | `DropReorderPolicyOperation` | | 0 | Standard EF operations (CreateTable, AddColumn, DropTable, …) | | 10 | `CreateHypertableOperation` | | 15 | `AlterHypertableOperation` | | 20 | `AddReorderPolicyOperation` / `AlterReorderPolicyOperation` | +| 25 | `AddCompressionPolicyOperation` / `AlterCompressionPolicyOperation` | | 30 | `CreateContinuousAggregateOperation` | | 40 | `AlterContinuousAggregateOperation` | | 50 | `AddContinuousAggregatePolicyOperation` | diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 5a28479..cf356ba 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -23,6 +23,9 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Configuration/Hypertable/HypertableAttribute.cs` | Data annotation | | `Configuration/Hypertable/DimensionAttribute.cs` | Data annotation for additional partitioning dimensions | | `Configuration/Hypertable/HypertableConvention.cs` | Convention processing | +| `Configuration/Hypertable/SparseIndex.cs` | `SparseIndex` value type and `SparseIndexSelector` typed fluent builder | +| `Configuration/Hypertable/SparseIndexAttribute.cs` | `[SparseIndex]` data annotation (AllowMultiple); also `DisableAutoSparseIndexes` on `[Hypertable]` | +| `Configuration/Hypertable/SparseIndexValidationConvention.cs` | IModelFinalizedConvention that validates sparse index entries (bloom/minmax arity, segmentby/orderby prerequisites, duplicates) | | `Internals/Features/Hypertables/HypertableDiffer.cs` | Diffing logic | | `Internals/Features/Hypertables/HypertableModelExtractor.cs` | Model extraction | | `Generators/HypertableSqlGenerator.cs` | Runtime SQL generation | @@ -64,6 +67,28 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Operations/AlterRetentionPolicyOperation.cs` | Migration operation | | `Operations/DropRetentionPolicyOperation.cs` | Migration operation | +### Compression Policy + +| File | Purpose | +|------|---------| +| `Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs` | Fluent API (including scaffold-targeting overload) | +| `Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs` | String-based builder used in scaffolded code | +| `Configuration/CompressionPolicy/CompressionPolicyAnnotations.cs` | Annotation constants | +| `Configuration/CompressionPolicy/CompressionPolicyAttribute.cs` | Data annotation | +| `Configuration/CompressionPolicy/CompressionPolicyConvention.cs` | Convention processing | +| `Configuration/CompressionPolicy/CompressionPolicyPrerequisiteValidationConvention.cs` | IModelFinalizedConvention that validates compression is enabled on any continuous aggregate that has a compression policy | +| `Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs` | Diffing logic | +| `Internals/Features/CompressionPolicies/CompressionPolicyModelExtractor.cs` | Model extraction | +| `Internals/Features/CompressionPolicies/CompressionPolicyDefaultHelper.cs` | Dynamic schedule_interval default (12h when chunk interval >= 1 day, else half the chunk interval) | +| `Internals/CompressionAnnotationExtractor.cs` | Shared helper for extracting segment-by and order-by column lists from entity annotations; used by both hypertable and continuous-aggregate model extractors | +| `Internals/Features/CompressionDiffHelper.cs` | Shared comparison and rewrite helpers for compression differ logic; used by both hypertable and continuous-aggregate differs | +| `Generators/CompressionPolicySqlGenerator.cs` | Runtime SQL generation | +| `Generators/CompressionSettingsSqlHelper.cs` | Shared SQL-building helpers for compression settings (compress SET clause, alter diff, enable-state check); used by hypertable and continuous-aggregate SQL generators | +| `MigrationExtensions/CompressionPolicyMigrationExtensions.cs` | Typed migrationBuilder methods | +| `Operations/AddCompressionPolicyOperation.cs` | Migration operation | +| `Operations/AlterCompressionPolicyOperation.cs` | Migration operation | +| `Operations/DropCompressionPolicyOperation.cs` | Migration operation | + ### Continuous Aggregate | File | Purpose | @@ -123,13 +148,17 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Internals/TimescaleMigrationsModelDiffer.cs` | Differ orchestration, context building, operation prioritization | | `Internals/Features/IFeatureDiffer.cs` | Differ interface | | `Internals/Features/FeatureDiffContext.cs` | Cross-cutting diff state (renames, recreated aggregates) | -| `Generators/SqlBuilderHelper.cs` | Identifier quoting, regclass, command grouping, SELECT→PERFORM | +| `Generators/SqlBuilderHelper.cs` | Identifier quoting, regclass, command grouping, SELECT→PERFORM, `EscapeStringLiteral`, `FormatTimestamp` | | `Generators/PolicyJobSqlBuilder.cs` | Shared `alter_job` clause builder for policies | +| `Configuration/ConventionValidationHelper.cs` | Shared validation helpers for conventions: `ValidateExclusiveFields` (XOR guard) and `ParseInitialStart` (DateTime parse with error context) | | `Configuration/TimeColumnStoreTypeValidationConvention.cs` | Model-finalized validation of hypertable & continuous-aggregate time-column store types | | `Internals/TimeColumnStoreTypeValidator.cs` | Allowed PostgreSQL store types for a TimescaleDB time dimension | +| `Internals/ExpressionHelper.cs` | Shared helper consolidating CLR property-name extraction from lambda expressions | | `DefaultValues.cs` | Centralized defaults | +| `TimescaleDbOptions.cs` | Provider options: `UseLegacyCompressionSql()` for pre-2.18 compatibility | | `Abstractions/Dimension.cs` | Range/hash partitioning | | `Abstractions/EAggregateFunction.cs` | Aggregate function enum | +| `Abstractions/ESparseIndexType.cs` | Sparse index kind enum (`Bloom`, `MinMax`) | | `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value | ## Design Library Key Files @@ -143,6 +172,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/HypertableCSharpGenerator.cs` | Emits `CreateHypertable`/`AlterHypertable` calls | | `Generators/ReorderPolicyCSharpGenerator.cs` | Emits reorder-policy calls | | `Generators/RetentionPolicyCSharpGenerator.cs` | Emits retention-policy calls | +| `Generators/CompressionPolicyCSharpGenerator.cs` | Emits compression-policy calls | | `Generators/ContinuousAggregateCSharpGenerator.cs` | Emits continuous-aggregate calls | | `Generators/ContinuousAggregatePolicyCSharpGenerator.cs` | Emits CA-policy calls | | `Generators/MigrationCallWriter.cs` | Writes a `.Method(arg: value, …)` call | @@ -162,6 +192,7 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRenderer.cs` | Renders continuous aggregate policy annotations to `WithRefreshPolicy(...)` fluent API or `[ContinuousAggregatePolicy]` attribute | | `Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs` | Renders retention policy annotations to `WithRetentionPolicy(...)` fluent API or `[RetentionPolicy]` attribute; registered after parent renderers (hypertable and continuous aggregate) | | `Generators/AnnotationRenderers/ReorderPolicyAnnotationRenderer.cs` | Renders reorder policy annotations to `WithReorderPolicy(...)` fluent API or `[ReorderPolicy]` attribute; registered after the hypertable renderer | +| `Generators/AnnotationRenderers/CompressionPolicyAnnotationRenderer.cs` | Renders compression policy annotations to `WithCompressionPolicy(...)` fluent API or `[CompressionPolicy]` attribute; registered after the hypertable renderer | | `Generators/AnnotationRenderers/PolicyJobRendererHelper.cs` | Shared helpers for emitting policy-job optional arguments (`InitialStart`, `WithScheduleInterval`, etc.) | | `Generators/AnnotationRenderers/AnnotationRendererHelper.cs` | Static helpers: `Find`, `GetString`, `SplitColumns`, `Consume`, `ResolvePropertyName`, `TryResolvePropertyName` | | `Generators/AnnotationRenderers/NameOfCodeFragment.cs` | Custom `CodeFragment` producing `nameof(X)` or `$"{nameof(X)} DESC"` | @@ -183,6 +214,10 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times | `Scaffolding/ContinuousAggregateAnnotationApplier.cs` | Apply continuous aggregate annotations | | `Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs` | Query continuous aggregate refresh policies from database | | `Scaffolding/ContinuousAggregatePolicyAnnotationApplier.cs` | Apply continuous aggregate policy annotations | +| `Scaffolding/CompressionPolicyScaffoldingExtractor.cs` | Query compression policies from `timescaledb_information.jobs` joined with `_timescaledb_config.bgw_job` for timezone | +| `Scaffolding/CompressionPolicyAnnotationApplier.cs` | Apply compression policy annotations; suppresses default schedule intervals to avoid phantom migrations | +| `Scaffolding/CompressionSettingsScaffoldingHelper.cs` | Shared helper that reads `timescaledb_information.hypertable_columnstore_settings` (2.18+) with fallback to `compression_settings` (pre-2.18); used by both the hypertable and continuous-aggregate scaffolding extractors | +| `Scaffolding/ScaffoldingExtractorHelper.cs` | Shared infrastructure for extractors: `UsingConnection` (execute-around connection-state management), `ViewExists` (parameterized view lookup), and `TimescaleInternalSchemaExclusion` constant | | `Scaffolding/IntervalParsingHelper.cs` | Parses and normalizes PostgreSQL interval strings (e.g. `"01:00:00"` → `"1 hour"`) and integer offsets | | `Scaffolding/ViewDefinitionParser.cs` | Parses continuous aggregate view SQL to extract structured configuration for code generation | | `build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets` | MSBuild integration | @@ -209,6 +244,7 @@ src/ ├── Eftdb/ # Core runtime library (CmdScale.EntityFrameworkCore.TimescaleDB) │ ├── Abstractions/ # Domain objects (Dimension, enums) │ ├── Configuration/ # Fluent API, attributes, conventions +│ │ ├── CompressionPolicy/ │ │ ├── ContinuousAggregate/ │ │ ├── ContinuousAggregatePolicy/ │ │ ├── Hypertable/ @@ -218,6 +254,7 @@ src/ │ ├── MigrationExtensions/ # Typed migrationBuilder.* methods │ ├── Internals/ # Core diffing logic │ │ └── Features/ +│ │ ├── CompressionPolicies/ │ │ ├── ContinuousAggregates/ │ │ ├── ContinuousAggregatePolicies/ │ │ ├── Hypertables/ diff --git a/.claude/reference/patterns.md b/.claude/reference/patterns.md index 1e83b68..c7be88c 100644 --- a/.claude/reference/patterns.md +++ b/.claude/reference/patterns.md @@ -141,13 +141,11 @@ new ContinuousAggregateFunction("average_price", EAggregateFunction.Avg, "price" `ToAnnotationValue()` serializes it to the colon-delimited wire format stored on `CreateContinuousAggregateOperation.AggregateFunctions`: -**Format:** -- Basic: `"alias:Function:sourceColumn"` -- First/Last: `"alias:Function:sourceColumn:timeColumn"` +**Format:** `"alias:Function:sourceColumn"` (always three parts — First/Last take no time column in the wire format; the SQL generator supplies the time-bucket column as their second argument: `last("price", "timestamp")`). -**Examples:** `"average_price:Avg:price"`, `"last_price:Last:price:timestamp"` +**Examples:** `"average_price:Avg:price"`, `"last_price:Last:price"` -**Parsing:** Split by `:` and validate array length (3 or 4 elements). +**Parsing:** Split by `:` and validate array length (exactly 3 elements; malformed entries are skipped). **Location:** `Abstractions/ContinuousAggregateFunction.cs`, `ContinuousAggregateModelExtractor.cs`, `Generators/ContinuousAggregateSqlGenerator.cs` @@ -166,12 +164,12 @@ builder.AddAggregateFunction( function: EAggregateFunction.Avg ) -// First/Last with time column +// First/Last — the time argument is always the continuous aggregate's +// time-bucket column; there is no timeColumn parameter builder.AddAggregateFunction( aggregateProperty: x => x.LastPrice, sourceProperty: x => x.Price, - function: EAggregateFunction.Last, - timeColumn: x => x.Timestamp + function: EAggregateFunction.Last ) // Group by columns diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 75e2a01..ea26965 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -34,7 +34,6 @@ public async Task Should_Detect_New_Hypertable() { /* ... */ } - `InitialContext` — represents the "before" model state - `ModifiedContext` — represents the "after" model state with changes -- Comment changed values with `// <-- Changed from ` ## Test Structure (AAA) @@ -42,6 +41,14 @@ public async Task Should_Detect_New_Hypertable() { /* ... */ } 2. **Act:** Diff `InitialContext` vs `ModifiedContext` to produce operations 3. **Assert:** Verify generated operations match expected changes +## Comments + +Comments are for navigation, never explanation. If a test needs an explanatory comment, it is not a good test — make the test plainer instead. + +- Allowed: XML doc `` on test classes, bare AAA markers (`// Arrange`, `// Act`, `// Assert`, bare combinations like `// Act & Assert`), thin `// ── Title ──` dividers, `#region`/`#endregion` +- Forbidden: explanatory text on or under AAA markers, comments in test-data setup (model classes, `OnModelCreating`), trailing explanations after code lines (including `// <-- Changed from ` delta markers), `//`-comment banners above test classes or methods +- Shared test infrastructure (base classes, fixtures) may carry a comment only for a genuine non-obvious constraint the code cannot express + ## Integration Tests - Use `Testcontainers` for real TimescaleDB instances (requires Docker) diff --git a/README.md b/README.md index e3d4e37..2e7ed39 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,17 @@ Seamlessly define and manage **TimescaleDB hypertables** using standard EF Core - **Chunk Skipping**: Enable chunk skipping to improve query performance on specific columns. - **Compression Segment By**: Define columns to group compressed data by, allowing efficient access to specific segments without decompressing entire chunks. - **Compression Order By**: Specify the sort order within compressed segments, with support for ascending/descending direction and NULLS FIRST/LAST positioning. +- **Sparse Indexes**: Configure bloom-filter or min/max sparse indexes on the hypertable's columnstore via a type-safe fluent API (`.WithSparseIndex(s => s.Bloom(x => x.Col), s => s.MinMax(x => x.Col))`) or the `[SparseIndex]` attribute (allowMultiple). Use `.WithoutAutoSparseIndexes()` or `DisableAutoSparseIndexes = true` on `[Hypertable]` to suppress auto-generated indexes. +- **Compress Chunk Time Interval**: Set `compress_chunk_time_interval` via `.WithCompressChunkTimeInterval("7 days")` or the `CompressChunkTimeInterval` property on `[Hypertable]` to control the minimum age of a chunk before the compression policy will compress it. ### Reorder Policies 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. +### Compression Policies + +Automate when TimescaleDB compresses chunks on a hypertable. + ### Retention Policies Automatically drop old chunks from hypertables and continuous aggregates so storage stays bounded as your time-series data grows. @@ -54,6 +60,8 @@ Create and manage **TimescaleDB continuous aggregates** — automatically refres - **Group By Columns**: Add additional grouping dimensions beyond time. - **Filtering**: Apply WHERE clauses to filter source data. - **Refresh Policies**: Configure automatic refresh with customizable time windows, schedule intervals, and batching options. +- **Compression**: Enable compression on the continuous aggregate's materialized view, with segment-by and order-by column control, using the same fluent API as hypertables (`.WithCompression()`, `.WithCompressionSegmentBy()`, `.WithCompressionOrderBy()`). +- **Compression Policies**: Schedule automatic compression of the continuous aggregate's chunks via `.WithCompressionPolicy()` or `[CompressionPolicy]`, independent of the hypertable's own compression policy. ### Query Functions diff --git a/docs/data-annotations/compression-policies.md b/docs/data-annotations/compression-policies.md new file mode 100644 index 0000000..ea0794e --- /dev/null +++ b/docs/data-annotations/compression-policies.md @@ -0,0 +1,176 @@ +# Compression Policies + +A compression policy schedules automatic conversion of uncompressed hypertable or continuous aggregate chunks to the columnstore format. The policy runs as a background job and compresses chunks that fall outside the configured age window. + +Each hypertable or continuous aggregate supports at most one compression policy. Compression must be enabled on the target table before a compression policy can be created. + +[See also: add_columnstore_policy](https://www.tigerdata.com/docs/learn/columnar-storage/compression-methods) + +## Prerequisites + +Compression must be enabled on the hypertable before applying `[CompressionPolicy]`. Set `EnableCompression = true`, `CompressionSegmentBy`, or `CompressionOrderBy` on `[Hypertable]` first: + +```csharp +[Hypertable(nameof(Time), CompressionSegmentBy = new[] { "DeviceId" })] +[CompressionPolicy("7 days")] +public class DeviceReading { ... } +``` + +For continuous aggregates, set `EnableCompression = true` on `[ContinuousAggregate]`. The convention validates this at model finalization and raises an `InvalidOperationException` if compression is not configured. + +## Compress Modes + +Two mutually exclusive compress modes are available: + +- **`After`**: Compresses chunks whose data is older than the specified interval. This is the standard mode. +- **`CreatedBefore`**: Compresses chunks created more than the specified interval ago, regardless of the data they contain. + +Exactly one of `After` or `CreatedBefore` must be set. Providing both or neither raises an exception. + +## Interval Values + +All interval parameters are strings passed verbatim to PostgreSQL, so every format the server accepts in raw SQL works here too: + +- **PostgreSQL interval syntax**: `"7 days"`, `"24:00:00"`, `"1 month"`, `"2 weeks 3 days"` +- **ISO 8601 durations**: `"P7D"`, `"PT12H"` +- **Plain numbers** for hypertables with integer time columns (`bigint`, `int`): an `After` value that parses as an integer is emitted as `{value}::bigint` in the column's own unit instead of an `INTERVAL` literal. `CreatedBefore` is always a wall-clock interval based on chunk creation time and never takes a number. + +## Attribute Constructor Forms + +The `[CompressionPolicy]` attribute supports three usage forms: + +### Positional `after` string + +The most common form. Passes the compress-after interval as the first positional argument: + +```csharp +[CompressionPolicy("7 days")] +``` + +This constructor validates that the argument is not null or whitespace and sets `After`. + +### Named `createdBefore` + +Use named argument syntax to configure by chunk creation time: + +```csharp +[CompressionPolicy(createdBefore: "30 days")] +``` + +The two-argument constructor `(string? after = null, string? createdBefore = null)` validates mutual exclusivity: providing both or neither raises an `InvalidOperationException` at attribute construction time. + +### Property initializer + +All properties can be set via object initializer syntax. Mutual exclusivity of `After` and `CreatedBefore` is validated by the convention at model-build time rather than at attribute construction time, since the parameterless constructor cannot enforce it: + +```csharp +[CompressionPolicy(After = "7 days", ScheduleInterval = "12 hours")] +``` + +## Validation Timing + +| Form | Validation point | +|------|-----------------| +| `[CompressionPolicy("7 days")]` | At attribute construction (argument must be non-empty) | +| `[CompressionPolicy(after: "7 days", createdBefore: "30 days")]` | At attribute construction (mutual exclusivity) | +| `[CompressionPolicy(After = "7 days")]` | At model build via convention | + +## Basic Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), ChunkTimeInterval = "1 day", CompressionSegmentBy = new[] { "DeviceId" })] +[PrimaryKey(nameof(Id), nameof(Time))] +[CompressionPolicy("7 days")] +public class DeviceReading +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } +} +``` + +## Using `CreatedBefore` + +```csharp +[Hypertable(nameof(Time), CompressionSegmentBy = new[] { "DeviceId" })] +[PrimaryKey(nameof(Id), nameof(Time))] +[CompressionPolicy(createdBefore: "30 days")] +public class DeviceReading +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } +} +``` + +## Complete Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), + ChunkTimeInterval = "1 day", + CompressionSegmentBy = new[] { "DeviceId" }, + CompressionOrderBy = new[] { "Time DESC" })] +[PrimaryKey(nameof(Id), nameof(Time))] +[CompressionPolicy("7 days", + ScheduleInterval = "12 hours", + InitialStart = "2025-10-01T02:00:00Z", + Timezone = "Europe/Berlin", + IfNotExists = true)] +public class DeviceReading +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } +} +``` + +## Compression Policy on Continuous Aggregates + +Continuous aggregates support `[CompressionPolicy]` in the same way as hypertables. The `[ContinuousAggregate]` attribute must have `EnableCompression = true` (or `CompressionSegmentBy`/`CompressionOrderBy` set) before the policy can be applied. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; + +[Keyless] +[ContinuousAggregate( + MaterializedViewName = "hourly_trade_stats", + ParentName = nameof(Trade), + EnableCompression = true, + CompressionOrderBy = new[] { "Bucket DESC" })] +[TimeBucket("1 hour", nameof(Trade.Time))] +[CompressionPolicy("30 days")] +public class HourlyTradeStat +{ + public DateTime Bucket { get; set; } + public decimal AveragePrice { get; set; } + public string Ticker { get; set; } = string.Empty; +} +``` + +## SQL Generation + +By default, the library emits the TimescaleDB 2.18+ columnstore API (`add_columnstore_policy`). To use legacy function names, configure via `UseTimescaleDb(o => o.UseLegacyCompressionSql())`. See the [Fluent API Compression Policies](../fluent-api/compression-policies.md) page for details. + +## Supported Properties + +| Property | Description | Type | Database Type | Default | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | --------- | ------------- | ------------------------------------------------- | +| `After` | Interval after which chunks are compressed. Mutually exclusive with `CreatedBefore`. | `string?` | `INTERVAL` | — | +| `CreatedBefore` | Interval relative to chunk creation time. Mutually exclusive with `After`. | `string?` | `INTERVAL` | — | +| `ScheduleInterval` | Interval between policy job executions. | `string?` | `INTERVAL` | 12 hours (or half the chunk interval for sub-day) | +| `InitialStart` | First scheduled run of the policy job, as a UTC date-time string in ISO 8601 format. | `string?` | `TIMESTAMPTZ` | `null` | +| `Timezone` | PostgreSQL time zone used when computing the initial start time (e.g., `"Europe/Berlin"`). | `string?` | — | `null` | +| `IfNotExists` | When `true`, no error is raised if the policy already exists. | `bool` | — | `false` | diff --git a/docs/data-annotations/hypertable.md b/docs/data-annotations/hypertable.md index ee997a5..7eb1f20 100644 --- a/docs/data-annotations/hypertable.md +++ b/docs/data-annotations/hypertable.md @@ -33,6 +33,8 @@ Time-series data can be compressed to reduce the amount of storage required, and [See also: TimescaleDB Compression](https://docs.tigerdata.com/use-timescale/latest/compression/) +> :information_source: Generated migrations use the TimescaleDB 2.18+ columnstore option names (`timescaledb.enable_columnstore`, `timescaledb.columnstore_segmentby`, ...) by default. On servers older than 2.18, opt into the legacy names via `UseTimescaleDb(o => o.UseLegacyCompressionSql())`. + ### Enabling Compression Set the `EnableCompression` property to true to enable compression on a hypertable: @@ -181,6 +183,108 @@ public class DeviceReading } ``` +## Sparse Indexes + +Sparse indexes are columnstore metadata structures that allow TimescaleDB to skip chunks whose data cannot satisfy a query predicate. Two index types are supported: `bloom` (probabilistic, supports composite columns) and `minmax` (range-based, single column only). + +> :warning: **Note:** Sparse indexes require `compress_orderby` to be configured. The convention validates this at model finalization and raises an `InvalidOperationException` if `compress_orderby` is absent. + +[See also: Columnstore indexes](https://docs.tigerdata.com/api/latest/compression/compress_chunk/) + +### Using `[SparseIndex]` + +Apply `[SparseIndex]` one or more times to declare individual sparse index entries. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), + ChunkTimeInterval = "1 day", + CompressionSegmentBy = new[] { "DeviceId" }, + CompressionOrderBy = new[] { "Time DESC" })] +[PrimaryKey(nameof(Id), nameof(Time))] +[SparseIndex(ESparseIndexType.Bloom, nameof(SensorType))] +[SparseIndex(ESparseIndexType.MinMax, nameof(Value))] +public class SensorMeasurement +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string SensorType { get; set; } = string.Empty; + public double Value { get; set; } +} +``` + +Use `nameof()` for each column to maintain refactoring safety. Property names are resolved to their database column names during model finalization. + +### Composite Bloom Index + +`bloom` supports multiple columns in a single attribute: + +```csharp +[SparseIndex(ESparseIndexType.Bloom, nameof(SensorType), nameof(Region))] +``` + +### `minmax` Single-Column Restriction + +`minmax` accepts exactly one column. Specifying multiple columns in `[SparseIndex(ESparseIndexType.MinMax, ...)]` is not caught at attribute construction time — the violation is detected by the `SparseIndexValidationConvention` at model finalization and raises an `InvalidOperationException`. + +### Disabling Auto Sparse Indexes + +TimescaleDB automatically creates sparse indexes on `compress_orderby` columns. To suppress this behaviour, set `DisableAutoSparseIndexes = true` on `[Hypertable]`: + +```csharp +[Hypertable(nameof(Time), + CompressionOrderBy = new[] { "Time DESC" }, + DisableAutoSparseIndexes = true)] +[PrimaryKey(nameof(Id), nameof(Time))] +public class SensorMeasurement { ... } +``` + +> :warning: **Note:** `DisableAutoSparseIndexes = true` and `[SparseIndex]` attributes are mutually exclusive. Having both on the same entity raises an `InvalidOperationException` at model build. + +### Validation Rules + +The `SparseIndexValidationConvention` enforces these rules at model finalization: + +- `compress_orderby` must be configured before any sparse index entry is processed. +- `bloom` and `minmax` entries cannot include `compress_segmentby` columns — segmentby columns are not compressed and cannot have a sparse index. +- A single-column `bloom(col)` entry is rejected when `col` is already a `compress_orderby` column, because `compress_orderby` columns receive an implicit sparse index automatically. +- `minmax` accepts exactly one column. Specifying multiple columns raises an error. +- Two single-column entries targeting the same column are rejected as duplicates. + +## Compress Chunk Time Interval + +The `CompressChunkTimeInterval` property sets the minimum time interval for merging chunks during compression. When set, TimescaleDB can combine adjacent chunks into a single larger compressed chunk during the compression job. + +The value must be a string interval and must be a multiple of the hypertable's `ChunkTimeInterval`. Setting this property implicitly enables compression. + +> :warning: **Warning:** Chunk merges are irreversible. Decreasing `CompressChunkTimeInterval` later cannot un-merge already merged chunks. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), + ChunkTimeInterval = "1 day", + CompressionSegmentBy = new[] { "DeviceId" }, + CompressChunkTimeInterval = "7 days")] +[PrimaryKey(nameof(Id), nameof(Time))] +public class DeviceReading +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } +} +``` + +### Removal Quirk + +PostgreSQL rejects `RESET` for the `timescaledb.compress_chunk_time_interval` storage option. When the value is removed from the model, the migration emits `SET (timescaledb.compress_chunk_time_interval = '0')` to clear it instead. See [Fluent API — Hypertables](../fluent-api/hypertable.md#compress-chunk-time-interval) for details. + ## Migrating Existing Data When converting an existing PostgreSQL table with data into a hypertable, the `MigrateData` property controls whether existing rows should be migrated to the hypertable structure. By default, this property is set to `false`, meaning existing data remains in place without migration. diff --git a/docs/fluent-api/compression-policies.md b/docs/fluent-api/compression-policies.md new file mode 100644 index 0000000..cec2ad9 --- /dev/null +++ b/docs/fluent-api/compression-policies.md @@ -0,0 +1,156 @@ +# Compression Policies + +A compression policy schedules automatic conversion of uncompressed hypertable or continuous aggregate chunks to the columnstore format. The policy runs as a background job and compresses chunks that fall outside the configured age window. + +Each hypertable or continuous aggregate supports at most one compression policy. Compression must be enabled on the target table before a compression policy can be created. + +[See also: add_columnstore_policy](https://www.tigerdata.com/docs/learn/columnar-storage/compression-methods) + +## Prerequisites + +Compression must be enabled on the hypertable before configuring a compression policy. Use `.EnableCompression()`, `.WithCompressionSegmentBy()`, or `.WithCompressionOrderBy()` first: + +```csharp +builder.IsHypertable(x => x.Time) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(OrderByBuilder.For(x => x.Time).Descending()); + +builder.WithCompressionPolicy( + after: "7 days", + scheduleInterval: "12 hours"); +``` + + +## Compress Modes + +Two mutually exclusive compress modes are available: + +- **`after`**: Compresses chunks whose data is older than the specified interval. This is the standard mode. +- **`createdBefore`**: Compresses chunks created more than the specified interval ago, regardless of the data they contain. + +Exactly one of `after` or `createdBefore` must be specified. Providing both or neither raises an exception at configuration time. + +## SQL Generation + +By default, the library emits the TimescaleDB 2.18+ columnstore API: + +```sql +CALL add_columnstore_policy('readings', after => INTERVAL '7 days'); +``` + +To use the legacy function names (`add_compression_policy`, `remove_compression_policy`), opt in via: + +```csharp +services.AddDbContext(options => + options.UseNpgsql(connectionString) + .UseTimescaleDb(o => o.UseLegacyCompressionSql())); +``` + +With the legacy option enabled, the generated SQL uses: + +```sql +SELECT add_compression_policy('readings', compress_after => INTERVAL '7 days'); +``` + +## Interval Values + +All interval parameters are strings passed verbatim to PostgreSQL, so every format the server accepts in raw SQL works here too: + +- **PostgreSQL interval syntax**: `"7 days"`, `"24:00:00"`, `"1 month"`, `"2 weeks 3 days"` +- **ISO 8601 durations**: `"P7D"`, `"PT12H"` — NodaTime users can pass `Period.FromDays(7).ToString()` directly, since `Period`'s default format is ISO 8601 +- **Plain numbers** for hypertables with integer time columns (`bigint`, `int`): an `after` value that parses as an integer is emitted as `{value}::bigint` in the column's own unit instead of an `INTERVAL` literal. `createdBefore` is always a wall-clock interval based on chunk creation time and never takes a number. + +## Using `createdBefore` + +```csharp +builder.WithCompressionPolicy(createdBefore: "30 days"); +``` + +## Complete Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DeviceReadingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy( + OrderByBuilder.For(x => x.Time).Descending()); + + builder.WithCompressionPolicy( + after: "7 days", + scheduleInterval: "12 hours", + initialStart: new DateTime(2025, 10, 1, 2, 0, 0, DateTimeKind.Utc), + timezone: "Europe/Berlin", + ifNotExists: true); + } +} + +public class DeviceReading +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } +} +``` + +## Chaining with Other Policies + +`WithCompressionPolicy` returns `EntityTypeBuilder`, allowing further method chaining. When used after a scaffold-generated string-based builder (e.g., `WithRetentionPolicy` returning a `RetentionPolicyStringBuilder`), a dedicated overload accepts the builder as the receiver: + +```csharp +builder.WithRetentionPolicy(dropAfter: "90 days", scheduleInterval: "1 day", timezone: null, ifNotExists: null) + .WithCompressionPolicy(after: "7 days", createdBefore: null, scheduleInterval: null, timezone: null, ifNotExists: null) + .WithInitialStart(new DateTime(2025, 10, 1, 3, 0, 0, DateTimeKind.Utc)); +``` + +> :warning: **Note:** The chained overload that accepts a `RetentionPolicyStringBuilder` or `ContinuousAggregateStringBuilder` as the receiver returns a `CompressionPolicyStringBuilder` with a `.WithInitialStart(DateTime)` method. This is required because attributes and scaffold generators cannot represent `DateTime` as a literal constructor argument. + +## Compression Policy on Continuous Aggregates + +Continuous aggregates also support compression policies. The continuous aggregate must have compression enabled before adding a compression policy. The convention validates this at model finalization and raises an `InvalidOperationException` if compression is not configured. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class HourlyTradeStatConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + + builder.IsContinuousAggregate( + "hourly_trade_stats", + "1 hour", + x => x.Time) + .WithCompression() + .WithCompressionSegmentBy(x => x.Ticker); + + builder.WithCompressionPolicy(after: "30 days"); + } +} +``` + +## Supported Parameters + +| Parameter | Description | Type | Database Type | Default | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | ----------- | ------------- | ------------------------------------------------- | +| `after` | Interval after which chunks are compressed. Mutually exclusive with `createdBefore`. | `string?` | `INTERVAL` | — | +| `createdBefore` | Interval relative to chunk creation time. Mutually exclusive with `after`. | `string?` | `INTERVAL` | — | +| `scheduleInterval` | Interval between policy job executions. | `string?` | `INTERVAL` | 12 hours (or half the chunk interval for sub-day) | +| `initialStart` | First scheduled run of the policy job, as a UTC `DateTime`. When `null`, derived from `scheduleInterval`. | `DateTime?` | `TIMESTAMPTZ` | `null` | +| `timezone` | PostgreSQL time zone used when computing the initial start time (e.g., `"Europe/Berlin"`). | `string?` | — | `null` | +| `ifNotExists` | When `true`, no error is raised if the policy already exists. | `bool?` | — | `false` | diff --git a/docs/fluent-api/hypertable.md b/docs/fluent-api/hypertable.md index e8c7d02..6498e88 100644 --- a/docs/fluent-api/hypertable.md +++ b/docs/fluent-api/hypertable.md @@ -59,6 +59,8 @@ Time-series data can be compressed to reduce the amount of storage required, and [See also: TimescaleDB Compression](https://docs.tigerdata.com/use-timescale/latest/compression/) +> :information_source: Generated migrations use the TimescaleDB 2.18+ columnstore option names (`timescaledb.enable_columnstore`, `timescaledb.columnstore_segmentby`, ...) by default. On servers older than 2.18, opt into the legacy names via `UseTimescaleDb(o => o.UseLegacyCompressionSql())`. + ### Enabling Compression Use the `.EnableCompression()` method to enable compression on a hypertable: @@ -256,6 +258,130 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration } ``` +## Sparse Indexes + +Sparse indexes are columnstore metadata structures that allow TimescaleDB to skip chunks whose data cannot satisfy a query predicate. Two index types are supported: `bloom` (probabilistic, supports composite columns) and `minmax` (range-based, single column only). + +> :warning: **Note:** Sparse indexes require `compress_orderby` to be configured. The convention validates this at model finalization and raises an `InvalidOperationException` if `compress_orderby` is absent. + +[See also: Columnstore indexes](https://docs.tigerdata.com/api/latest/compression/compress_chunk/) + +### Using Typed Selectors + +The `SparseIndexSelector` provides type-safe lambda expressions for building index entries: + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class SensorMeasurementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy( + OrderByBuilder.For(x => x.Time).Descending()) + .WithSparseIndex( + s => s.Bloom(x => x.SensorType), + s => s.MinMax(x => x.Value)); + } +} +``` + +### Using Explicit SparseIndex Objects + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; + +builder.IsHypertable(x => x.Time) + .WithCompressionOrderBy(OrderByBuilder.For(x => x.Time).Descending()) + .WithSparseIndex( + new SparseIndex(ESparseIndexType.Bloom, ["SensorType", "Region"]), + new SparseIndex(ESparseIndexType.MinMax, ["Value"])); +``` + +### Using a Raw String + +A comma-separated string of `bloom(col)` and `minmax(col)` entries is also accepted: + +```csharp +builder.WithSparseIndex("bloom(sensor_type,region), minmax(value)"); +``` + +> :warning: **Note:** When using the raw string overload, column names must match the database column names, not the property names. + +### Composite Bloom Indexes + +`bloom` supports multiple columns in a single entry. Use this for compound predicate filtering: + +```csharp +builder.WithSparseIndex(s => s.Bloom(x => x.SensorType, x => x.Region)); +``` + +### Disabling Auto Sparse Indexes + +TimescaleDB automatically creates sparse indexes on `compress_orderby` columns. To suppress this behaviour: + +```csharp +builder.WithoutAutoSparseIndexes(); +``` + +This sets `timescaledb.sparse_index = ''` on the table. + +### Validation Rules + +The `SparseIndexValidationConvention` enforces these rules at model finalization: + +- `compress_orderby` must be configured before any sparse index entry is processed. +- `bloom` and `minmax` entries cannot include `compress_segmentby` columns — segmentby columns are not compressed and cannot have a sparse index. +- A single-column `bloom(col)` entry is rejected when `col` is already a `compress_orderby` column, because `compress_orderby` columns receive an implicit sparse index automatically. Use a composite `bloom(col, other)` if additional coverage is needed. +- `minmax` accepts exactly one column. Specifying multiple columns raises an error. +- Two single-column entries targeting the same column are rejected as duplicates. + +## Compress Chunk Time Interval + +The `WithCompressChunkTimeInterval` method sets the minimum time interval for merging chunks during compression. When this is set, TimescaleDB can combine adjacent chunks into a single larger compressed chunk during the compression job. + +The value must be a string interval and must be a multiple of the hypertable's `chunk_time_interval`. + +> :warning: **Warning:** Chunk merges are irreversible. Decreasing `compress_chunk_time_interval` later cannot un-merge already merged chunks. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DeviceReadingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressChunkTimeInterval("7 days"); + } +} +``` + +### Removal Quirk + +PostgreSQL rejects `RESET` for the `timescaledb.compress_chunk_time_interval` storage option (`"only columnstore options segmentby and orderby can be reset"`). When the value is removed from the model, the migration instead emits: + +```sql +ALTER TABLE "device_readings" SET (timescaledb.compress_chunk_time_interval = '0'); +``` + +Setting the interval to `'0'` clears the merge behaviour without using `RESET`. + ## Migrating Existing Data When converting an existing PostgreSQL table with data into a hypertable, the `.WithMigrateData()` method controls whether existing rows should be migrated to the hypertable structure. By default, this option is set to `false`, meaning existing data remains in place without migration. diff --git a/docs/fluent-api/retention-policies.md b/docs/fluent-api/retention-policies.md index 9282a83..ccceb7b 100644 --- a/docs/fluent-api/retention-policies.md +++ b/docs/fluent-api/retention-policies.md @@ -97,6 +97,10 @@ public class ApplicationLog } ``` +## Interval Values + +`dropAfter`, `dropCreatedBefore`, `scheduleInterval`, `maxRuntime`, and `retryPeriod` are strings passed verbatim to PostgreSQL — every format the server accepts in raw SQL works, including ISO 8601 durations (`"P7D"`, so NodaTime `Period.ToString()` can be passed directly) and, for `dropAfter` on integer-time-column hypertables, plain numbers emitted as `{value}::bigint`. See [Compression Policies — Interval Values](compression-policies.md#interval-values) for details and caveats. + ## Supported Parameters | Parameter | Description | Type | Database Type | Default Value | diff --git a/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs index 493c170..14dac33 100644 --- a/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs +++ b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs @@ -1,6 +1,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; using Microsoft.EntityFrameworkCore; @@ -8,6 +9,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations { + /// + /// Fluent API configuration for the continuous aggregate. + /// public class ApiRequestAggregateConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -18,7 +22,14 @@ public void Configure(EntityTypeBuilder builder) .AddAggregateFunction(x => x.MaxDurationMs, x => x.DurationMs, EAggregateFunction.Max) .AddAggregateFunction(x => x.MinDurationMs, x => x.DurationMs, EAggregateFunction.Min) .AddGroupByColumn(x => x.ServiceName) + .WithCompression() + .WithCompressionSegmentBy(x => x.ServiceName) + .WithCompressionOrderBy( + s => s.ByDescending(x => x.TimeBucket)) .WithRefreshPolicy(startOffset: "2 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + + builder.WithCompressionPolicy(after: "30 days", scheduleInterval: "1 day"); + builder.WithRetentionPolicy( dropAfter: "90 days", scheduleInterval: "1 day", diff --git a/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs index e4dd13b..3c4eaf5 100644 --- a/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs +++ b/samples/Eftdb.Samples.Shared/Configurations/SensorMeasurementConfiguration.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,10 @@ public void Configure(EntityTypeBuilder builder) .WithCompressionSegmentBy(x => x.Site, x => x.MetricType) .WithCompressionOrderBy( s => s.By(x => x.SensorId), - s => s.ByDescending(x => x.RecordedAt)); + s => s.ByDescending(x => x.RecordedAt)) + .WithSparseIndex(s => s.Bloom(x => x.Site), s => s.MinMax(x => x.Value)) + .WithCompressChunkTimeInterval("7 days") + .WithCompressionPolicy(after: "7 days", scheduleInterval: "12 hours"); } } } diff --git a/samples/Eftdb.Samples.Shared/Models/EnvironmentReading.cs b/samples/Eftdb.Samples.Shared/Models/EnvironmentReading.cs index 912b5ba..905a54b 100644 --- a/samples/Eftdb.Samples.Shared/Models/EnvironmentReading.cs +++ b/samples/Eftdb.Samples.Shared/Models/EnvironmentReading.cs @@ -1,13 +1,19 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore; using NodaTime; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models { - /// - /// Annotation-configured hypertable whose time column is a NodaTime . - /// - [Hypertable(nameof(ObservedAt), ChunkTimeInterval = "1 day", EnableCompression = true, CompressionSegmentBy = new[] { "Station" }, CompressionOrderBy = new[] { "ObservedAt DESC" })] + [Hypertable( + nameof(ObservedAt), + ChunkTimeInterval = "1 day", + EnableCompression = true, + CompressionSegmentBy = new[] { "Station" }, + CompressionOrderBy = new[] { "ObservedAt DESC" }, + CompressChunkTimeInterval = "7 days")] + [SparseIndex(ESparseIndexType.Bloom, nameof(EnvironmentReading.Station))] + [SparseIndex(ESparseIndexType.MinMax, nameof(EnvironmentReading.Pressure))] [PrimaryKey(nameof(Id), nameof(ObservedAt))] public class EnvironmentReading { diff --git a/samples/Eftdb.Samples.Shared/Models/MetricSnapshot.cs b/samples/Eftdb.Samples.Shared/Models/MetricSnapshot.cs new file mode 100644 index 0000000..63ba5b4 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/MetricSnapshot.cs @@ -0,0 +1,38 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + [Hypertable( + nameof(RecordedAt), + ChunkTimeInterval = "1 day", + EnableCompression = true, + CompressionSegmentBy = [nameof(ServiceName)], + CompressionOrderBy = [$"{nameof(MetricName)} ASC", $"{nameof(RecordedAt)} DESC"])] + [CompressionPolicy( + CreatedBefore = "30 days", + ScheduleInterval = "1 day", + Timezone = "UTC")] + [PrimaryKey(nameof(Id), nameof(RecordedAt))] + public class MetricSnapshot + { + /// Row identifier. + public Guid Id { get; set; } + + /// UTC timestamp when the metric was sampled. + public DateTime RecordedAt { get; set; } + + /// Name of the service emitting the metric (e.g. "checkout-api"). + public string ServiceName { get; set; } = string.Empty; + + /// Name of the metric (e.g. "request_count", "p99_latency_ms"). + public string MetricName { get; set; } = string.Empty; + + /// Sampled numeric value. + public double Value { get; set; } + + /// Optional unit label (e.g. "ms", "req/s"). + public string? Unit { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/WeatherAggregate.cs b/samples/Eftdb.Samples.Shared/Models/WeatherAggregate.cs index abf3192..890c333 100644 --- a/samples/Eftdb.Samples.Shared/Models/WeatherAggregate.cs +++ b/samples/Eftdb.Samples.Shared/Models/WeatherAggregate.cs @@ -1,6 +1,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; using Microsoft.EntityFrameworkCore; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models @@ -17,14 +18,17 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models WithNoData = true, CreateGroupIndexes = true, MaterializedOnly = false, - Where = "\"temperature\" > -50 AND \"humidity\" >= 0")] + Where = "\"temperature\" > -50 AND \"humidity\" >= 0", + EnableCompression = true, + CompressionSegmentBy = ["AverageTemperature"], + CompressionOrderBy = ["RecordCount DESC"])] [TimeBucket("1 day", nameof(WeatherData.Time), GroupBy = true)] [ContinuousAggregatePolicy( StartOffset = "30 days", EndOffset = "1 day", ScheduleInterval = "1 hour", - RefreshNewestFirst = true)] + [CompressionPolicy(After = "90 days", ScheduleInterval = "1 day")] public class WeatherAggregate { // Avg aggregate function diff --git a/samples/Eftdb.Samples.Shared/TimescaleContext.cs b/samples/Eftdb.Samples.Shared/TimescaleContext.cs index ad73c69..bd88c6f 100644 --- a/samples/Eftdb.Samples.Shared/TimescaleContext.cs +++ b/samples/Eftdb.Samples.Shared/TimescaleContext.cs @@ -18,6 +18,7 @@ public class TimescaleContext(DbContextOptions options) : DbCo public DbSet ApplicationLogs { get; set; } public DbSet ApiRequestLogs { get; set; } public DbSet ApiRequestAggregates { get; set; } + public DbSet MetricSnapshots { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRenderer.cs new file mode 100644 index 0000000..f860968 --- /dev/null +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRenderer.cs @@ -0,0 +1,160 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.AnnotationRendererHelper; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers.PolicyJobRendererHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers +{ + /// + /// Renders TimescaleDB:HasCompressionPolicy and related annotations as a + /// .WithCompressionPolicy(...) Fluent API chain or a [CompressionPolicy(...)] + /// attribute. + /// + internal sealed class CompressionPolicyAnnotationRenderer : IFeatureAnnotationRenderer + { + /// + /// Reflects the scaffold-targeting WithCompressionPolicy overload whose receiver is + /// EntityTypeBuilder<> and returns CompressionPolicyStringBuilder<>. + /// + private static readonly MethodInfo WithCompressionPolicyMethod = + typeof(CompressionPolicyTypeBuilder) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(CompressionPolicyTypeBuilder.WithCompressionPolicy) + && m.GetParameters().Length == 6 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<>) + && m.ReturnType.IsGenericType + && m.ReturnType.GetGenericTypeDefinition() == typeof(CompressionPolicyStringBuilder<>)); + + private static readonly MethodInfo WithInitialStartMethod = + typeof(CompressionPolicyStringBuilder<>) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == nameof(CompressionPolicyStringBuilder<>.WithInitialStart)); + + public IReadOnlyList GenerateFluentApiCalls( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? after = GetString(annotations, CompressionPolicyAnnotations.After); + string? createdBefore = GetString(annotations, CompressionPolicyAnnotations.CreatedBefore); + string? scheduleInterval = GetString(annotations, CompressionPolicyAnnotations.ScheduleInterval); + string? timezone = GetString(annotations, CompressionPolicyAnnotations.Timezone); + bool? ifNotExists = Find(annotations, CompressionPolicyAnnotations.IfNotExists)?.Value is bool b ? b : null; + + object?[] entryArgs = BuildCompressionPolicyArgs(after, createdBefore, scheduleInterval, timezone, ifNotExists); + MethodCallCodeFragment call = new(WithCompressionPolicyMethod, entryArgs); + + call = ChainInitialStart(call, annotations, CompressionPolicyAnnotations.InitialStart, WithInitialStartMethod); + + ConsumeAllCompressionPolicyAnnotations(annotations); + return [call]; + } + + public IReadOnlyList GenerateDataAnnotationAttributes( + IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return []; + } + + string? after = GetString(annotations, CompressionPolicyAnnotations.After); + string? createdBefore = GetString(annotations, CompressionPolicyAnnotations.CreatedBefore); + Dictionary namedArgs = []; + + if (!string.IsNullOrWhiteSpace(after)) + { + namedArgs[nameof(CompressionPolicyAttribute.After)] = after; + } + + if (!string.IsNullOrWhiteSpace(createdBefore)) + { + namedArgs[nameof(CompressionPolicyAttribute.CreatedBefore)] = createdBefore; + } + + string? scheduleInterval = GetString(annotations, CompressionPolicyAnnotations.ScheduleInterval); + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + { + namedArgs[nameof(CompressionPolicyAttribute.ScheduleInterval)] = scheduleInterval; + } + + AddInitialStartNamedArg( + annotations, + CompressionPolicyAnnotations.InitialStart, + nameof(CompressionPolicyAttribute.InitialStart), + namedArgs); + + string? timezone = GetString(annotations, CompressionPolicyAnnotations.Timezone); + if (!string.IsNullOrWhiteSpace(timezone)) + { + namedArgs[nameof(CompressionPolicyAttribute.Timezone)] = timezone; + } + + if (Find(annotations, CompressionPolicyAnnotations.IfNotExists)?.Value is bool ifNotExists) + { + namedArgs[nameof(CompressionPolicyAttribute.IfNotExists)] = ifNotExists; + } + + ConsumeAllCompressionPolicyAnnotations(annotations); + return [new AttributeCodeFragment(typeof(CompressionPolicyAttribute), [], namedArgs)]; + } + + public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) + { + if (!ShouldRender(annotations)) + { + return; + } + + ConsumeAllCompressionPolicyAnnotations(annotations); + } + + /// + /// Guards all three rendering paths: a compression policy annotation is only emitted when the + /// parent feature renderer already succeeded. For hypertables, the hypertable renderer consumes + /// IsHypertable on success — if it is still present the hypertable renderer failed. + /// For continuous aggregates, the CA renderer consumes MaterializedViewName on success — + /// if it is still present the CA renderer failed. Both checks must pass: the policy annotation + /// is left for the .HasAnnotation fallback when either parent renderer did not succeed. + /// + private static bool ShouldRender(IDictionary annotations) + => Find(annotations, CompressionPolicyAnnotations.HasCompressionPolicy)?.Value is true + && Find(annotations, HypertableAnnotations.IsHypertable) is null + && Find(annotations, ContinuousAggregateAnnotations.MaterializedViewName) is null; + + /// + /// Builds positional arguments for the scaffold-targeting WithCompressionPolicy overload. + /// All five arguments are always emitted: trimmed forms could resolve against the user-facing + /// overload whose fourth parameter is initialStart (DateTime?), silently binding + /// later arguments to the wrong parameters. + /// + private static object?[] BuildCompressionPolicyArgs( + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) + => [after, createdBefore, scheduleInterval, timezone, ifNotExists]; + + private static void ConsumeAllCompressionPolicyAnnotations(IDictionary annotations) + { + Consume(annotations, + CompressionPolicyAnnotations.HasCompressionPolicy, + CompressionPolicyAnnotations.After, + CompressionPolicyAnnotations.CreatedBefore, + CompressionPolicyAnnotations.InitialStart, + CompressionPolicyAnnotations.ScheduleInterval, + CompressionPolicyAnnotations.Timezone, + CompressionPolicyAnnotations.IfNotExists); + } + } +} diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs index cdbad1d..6434602 100644 --- a/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRenderer.cs @@ -39,6 +39,9 @@ private static MethodInfo BuilderMethod(string name) => private static readonly MethodInfo WithNoDataMethod = BuilderMethod("WithNoData"); private static readonly MethodInfo CreateGroupIndexesMethod = BuilderMethod("CreateGroupIndexes"); private static readonly MethodInfo WithChunkIntervalMethod = BuilderMethod("WithChunkInterval"); + private static readonly MethodInfo WithCompressionMethod = BuilderMethod("WithCompression"); + private static readonly MethodInfo WithCompressionSegmentByMethod = BuilderMethod("WithCompressionSegmentBy"); + private static readonly MethodInfo WithCompressionOrderByMethod = BuilderMethod("WithCompressionOrderBy"); public IReadOnlyList GenerateFluentApiCalls( IEntityType entityType, IDictionary annotations) @@ -124,6 +127,27 @@ public IReadOnlyList GenerateFluentApiCalls( call = call.Chain(WithChunkIntervalMethod, IntervalParsingHelper.NormalizeInterval(chunkInterval)); } + bool compressionConfigured = false; + + string segmentBy = GetString(annotations, HypertableAnnotations.CompressionSegmentBy) ?? ""; + if (!string.IsNullOrWhiteSpace(segmentBy)) + { + call = call.Chain(WithCompressionSegmentByMethod, segmentBy); + compressionConfigured = true; + } + + string orderBy = GetString(annotations, HypertableAnnotations.CompressionOrderBy) ?? ""; + if (!string.IsNullOrWhiteSpace(orderBy)) + { + call = call.Chain(WithCompressionOrderByMethod, orderBy); + compressionConfigured = true; + } + + if (!compressionConfigured && Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) + { + call = call.Chain(WithCompressionMethod); + } + ConsumeAllCaAnnotations(annotations); return [call]; } @@ -153,6 +177,10 @@ public IReadOnlyList GenerateDataAnnotationAttributes( return []; } + bool enableCompression = Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true; + string? compressionSegmentBy = GetString(annotations, HypertableAnnotations.CompressionSegmentBy); + string? compressionOrderBy = GetString(annotations, HypertableAnnotations.CompressionOrderBy); + ConsumeAllCaAnnotations(annotations); string humanizedWidth = IntervalParsingHelper.NormalizeInterval(parsed.TimeBucketWidth); @@ -199,6 +227,26 @@ public IReadOnlyList GenerateDataAnnotationAttributes( caNamedArgs[nameof(ContinuousAggregateAttribute.Where)] = parsed.WhereClause; } + bool hasSegmentBy = !string.IsNullOrWhiteSpace(compressionSegmentBy); + bool hasOrderBy = !string.IsNullOrWhiteSpace(compressionOrderBy); + + if (enableCompression && !hasSegmentBy && !hasOrderBy) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.EnableCompression)] = true; + } + + if (hasSegmentBy) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.CompressionSegmentBy)] = + SplitColumns(compressionSegmentBy); + } + + if (hasOrderBy) + { + caNamedArgs[nameof(ContinuousAggregateAttribute.CompressionOrderBy)] = + SplitColumns(compressionOrderBy); + } + return [ new AttributeCodeFragment(typeof(ContinuousAggregateAttribute), [], caNamedArgs), new AttributeCodeFragment(typeof(TimeBucketAttribute), humanizedWidth, timeBucketArg), @@ -287,7 +335,10 @@ private static void ConsumeAllCaAnnotations(IDictionary ann ContinuousAggregateAnnotations.GroupByColumns, ContinuousAggregateAnnotations.WhereClause, ContinuousAggregateAnnotations.WithNoData, - ContinuousAggregateAnnotations.CreateGroupIndexes); + ContinuousAggregateAnnotations.CreateGroupIndexes, + HypertableAnnotations.EnableCompression, + HypertableAnnotations.CompressionSegmentBy, + HypertableAnnotations.CompressionOrderBy); } } } diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs index ece1adf..16d00f2 100644 --- a/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/HypertableAnnotationRenderer.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -30,6 +31,9 @@ private static MethodInfo HypertableMethod(string name) => private static readonly MethodInfo WithMigrateDataMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithMigrateData)); private static readonly MethodInfo HasRangeDimensionMethod = HypertableMethod(nameof(HypertableTypeBuilder.HasRangeDimension)); private static readonly MethodInfo HasHashDimensionMethod = HypertableMethod(nameof(HypertableTypeBuilder.HasHashDimension)); + private static readonly MethodInfo WithSparseIndexMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithSparseIndex)); + private static readonly MethodInfo WithoutAutoSparseIndexesMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithoutAutoSparseIndexes)); + private static readonly MethodInfo WithCompressChunkTimeIntervalMethod = HypertableMethod(nameof(HypertableTypeBuilder.WithCompressChunkTimeInterval)); public IReadOnlyList GenerateFluentApiCalls( IEntityType entityType, IDictionary annotations) @@ -52,8 +56,40 @@ public IReadOnlyList GenerateFluentApiCalls( call = call.Chain(WithChunkTimeIntervalMethod, interval); } - // Both WithCompressionSegmentBy and WithCompressionOrderBy implicitly enable compression, so a - // separate EnableCompression call is only emitted when neither is rendered. + call = AppendCompressionSettingsFluent(entityType, annotations, call); + call = AppendChunkSkippingFluent(entityType, annotations, call); + + if (Find(annotations, HypertableAnnotations.MigrateData)?.Value is true) + { + call = call.Chain(WithMigrateDataMethod); + } + + call = AppendDimensionsFluent(entityType, annotations, call); + + Consume(annotations, + HypertableAnnotations.IsHypertable, + HypertableAnnotations.HypertableTimeColumn, + HypertableAnnotations.ChunkTimeInterval, + HypertableAnnotations.EnableCompression, + HypertableAnnotations.CompressionSegmentBy, + HypertableAnnotations.ChunkSkipColumns, + HypertableAnnotations.MigrateData, + HypertableAnnotations.CompressionSparseIndex, + HypertableAnnotations.CompressChunkTimeInterval); + + return [call]; + } + + /// + /// Chains compression-related calls onto : segmentby, orderby, + /// sparse index, compress-chunk-time-interval, and the bare EnableCompression fallback. + /// + private static MethodCallCodeFragment AppendCompressionSettingsFluent( + IEntityType entityType, IDictionary annotations, MethodCallCodeFragment call) + { + // WithCompressionSegmentBy, WithCompressionOrderBy, WithSparseIndex, and + // WithCompressChunkTimeInterval all implicitly enable compression. A separate + // EnableCompression call is only emitted when none of them is rendered. bool compressionConfigured = false; string[] segmentBy = ResolveColumns(entityType, GetString(annotations, HypertableAnnotations.CompressionSegmentBy)); @@ -71,46 +107,68 @@ public IReadOnlyList GenerateFluentApiCalls( Consume(annotations, HypertableAnnotations.CompressionOrderBy); } + IAnnotation? sparseIndexAnnotation = Find(annotations, HypertableAnnotations.CompressionSparseIndex); + if (sparseIndexAnnotation?.Value is string sparseIndex) + { + call = sparseIndex.Length == 0 + ? call.Chain(WithoutAutoSparseIndexesMethod) + : call.Chain(WithSparseIndexMethod, sparseIndex); + compressionConfigured = true; + } + + if (GetString(annotations, HypertableAnnotations.CompressChunkTimeInterval) is string compressInterval + && !string.IsNullOrWhiteSpace(compressInterval)) + { + call = call.Chain(WithCompressChunkTimeIntervalMethod, compressInterval); + compressionConfigured = true; + } + if (!compressionConfigured && Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) { call = call.Chain(EnableCompressionMethod); } + return call; + } + + /// + /// Chains WithChunkSkipping onto when the annotation is present. + /// + private static MethodCallCodeFragment AppendChunkSkippingFluent( + IEntityType entityType, IDictionary annotations, MethodCallCodeFragment call) + { string[] chunkSkip = ResolveColumns(entityType, GetString(annotations, HypertableAnnotations.ChunkSkipColumns)); if (chunkSkip.Length > 0) { call = call.Chain(WithChunkSkippingMethod, [.. chunkSkip.Select(PropertyAccessor)]); } - if (Find(annotations, HypertableAnnotations.MigrateData)?.Value is true) - { - call = call.Chain(WithMigrateDataMethod); - } + return call; + } + /// + /// Chains HasRangeDimension/HasHashDimension calls onto + /// for each additional dimension, then consumes the annotation. + /// + private static MethodCallCodeFragment AppendDimensionsFluent( + IEntityType entityType, IDictionary annotations, MethodCallCodeFragment call) + { List? dimensions = TryReadDimensions(GetString(annotations, HypertableAnnotations.AdditionalDimensions)); - if (dimensions is { Count: > 0 }) + if (dimensions is not { Count: > 0 }) { - foreach (Dimension dimension in dimensions) - { - PropertyAccessorCodeFragment column = PropertyAccessor(ResolvePropertyName(entityType, dimension.ColumnName)); - call = dimension.Type == EDimensionType.Hash - ? call.Chain(HasHashDimensionMethod, [column, dimension.NumberOfPartitions ?? 0]) - : call.Chain(HasRangeDimensionMethod, [column, dimension.Interval ?? string.Empty]); - } - - Consume(annotations, HypertableAnnotations.AdditionalDimensions); + return call; } - Consume(annotations, - HypertableAnnotations.IsHypertable, - HypertableAnnotations.HypertableTimeColumn, - HypertableAnnotations.ChunkTimeInterval, - HypertableAnnotations.EnableCompression, - HypertableAnnotations.CompressionSegmentBy, - HypertableAnnotations.ChunkSkipColumns, - HypertableAnnotations.MigrateData); + foreach (Dimension dimension in dimensions) + { + PropertyAccessorCodeFragment column = PropertyAccessor(ResolvePropertyName(entityType, dimension.ColumnName)); + call = dimension.Type == EDimensionType.Hash + ? call.Chain(HasHashDimensionMethod, [column, dimension.NumberOfPartitions ?? 0]) + : call.Chain(HasRangeDimensionMethod, [column, dimension.Interval ?? string.Empty]); + } - return [call]; + Consume(annotations, HypertableAnnotations.AdditionalDimensions); + return call; } public IReadOnlyList GenerateDataAnnotationAttributes( @@ -119,7 +177,7 @@ public IReadOnlyList GenerateDataAnnotationAttributes( AttributeCodeFragment? hypertable = GenerateHypertableAttribute(entityType, annotations); return hypertable == null ? [] - : [hypertable, .. GenerateDimensionAttributes(entityType, annotations)]; + : [hypertable, .. GenerateSparseIndexAttributes(annotations), .. GenerateDimensionAttributes(entityType, annotations)]; } public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) @@ -138,7 +196,9 @@ public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) @@ -155,11 +215,6 @@ public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary named = []; - if (Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) - { - named[nameof(HypertableAttribute.EnableCompression)] = true; - } - if (GetString(annotations, HypertableAnnotations.ChunkTimeInterval) is string interval && !string.IsNullOrWhiteSpace(interval) && interval != DefaultValues.ChunkTimeInterval) @@ -172,6 +227,36 @@ public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary + /// Adds compression-related entries to : EnableCompression, segmentby, + /// orderby, DisableAutoSparseIndexes, and CompressChunkTimeInterval. + /// Consumes the sparse-index annotation when its value is the empty string (disable marker). + /// + private static void AddCompressionAttributeArgs( + IEntityType entityType, IDictionary annotations, Dictionary named) + { + if (Find(annotations, HypertableAnnotations.EnableCompression)?.Value is true) + { + named[nameof(HypertableAttribute.EnableCompression)] = true; + } + object[] segmentBy = [.. SplitColumns(GetString(annotations, HypertableAnnotations.CompressionSegmentBy)).Select(column => ColumnReference(entityType, column))]; if (segmentBy.Length > 0) { @@ -184,23 +269,31 @@ public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary + /// Adds the ChunkSkipColumns entry to when the annotation is present. + /// + private static void AddChunkSkipAttributeArgs( + IEntityType entityType, IDictionary annotations, Dictionary named) + { object[] chunkSkip = [.. SplitColumns(GetString(annotations, HypertableAnnotations.ChunkSkipColumns)).Select(column => ColumnReference(entityType, column))]; if (chunkSkip.Length > 0) { named[nameof(HypertableAttribute.ChunkSkipColumns)] = ToArgumentArray(chunkSkip); } - - Consume(annotations, - HypertableAnnotations.IsHypertable, - HypertableAnnotations.HypertableTimeColumn, - HypertableAnnotations.ChunkTimeInterval, - HypertableAnnotations.EnableCompression, - HypertableAnnotations.CompressionSegmentBy, - HypertableAnnotations.CompressionOrderBy, - HypertableAnnotations.ChunkSkipColumns, - HypertableAnnotations.MigrateData); - - return new AttributeCodeFragment(typeof(HypertableAttribute), [ColumnReference(entityType, timeColumn)], named); } /// @@ -226,6 +319,61 @@ private static object ToArgumentArray(object[] entries) ? entries : Array.ConvertAll(entries, entry => (string)entry); + /// + /// Renders non-empty timescaledb.sparse_index entries as one [SparseIndex] + /// attribute per entry. The empty-string case is handled as DisableAutoSparseIndexes = true + /// on the [Hypertable] attribute and does not produce any fragments here. + /// + private static List GenerateSparseIndexAttributes(IDictionary annotations) + { + IAnnotation? sparseIndexAnnotation = Find(annotations, HypertableAnnotations.CompressionSparseIndex); + if (sparseIndexAnnotation?.Value is not string raw || raw.Length == 0) + { + return []; + } + + List attributes = []; + foreach (string entry in CompressionAnnotationExtractor.SplitSparseIndexEntries(raw)) + { + string trimmed = entry.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + int parenOpen = trimmed.IndexOf('('); + int parenClose = trimmed.LastIndexOf(')'); + if (parenOpen < 0 || parenClose < parenOpen) + { + continue; + } + + string funcName = trimmed[..parenOpen].Trim(); + ESparseIndexType kind = string.Equals(funcName, "minmax", StringComparison.OrdinalIgnoreCase) + ? ESparseIndexType.MinMax + : ESparseIndexType.Bloom; + + string argsPart = trimmed[(parenOpen + 1)..parenClose]; + string[] columns = [.. argsPart.Split(',', StringSplitOptions.TrimEntries).Where(c => c.Length > 0)]; + if (columns.Length == 0) + { + continue; + } + + List positional = [kind]; + foreach (string col in columns) + { + positional.Add(col); + } + + attributes.Add(new AttributeCodeFragment(typeof(SparseIndexAttribute), positional.ToArray(), new Dictionary())); + } + + Consume(annotations, HypertableAnnotations.CompressionSparseIndex); + + return attributes; + } + private static List GenerateDimensionAttributes(IEntityType entityType, IDictionary annotations) { List? dimensions = TryReadDimensions(GetString(annotations, HypertableAnnotations.AdditionalDimensions)); diff --git a/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs b/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs index 5c9344a..d9e0262 100644 --- a/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs +++ b/src/Eftdb.Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRenderer.cs @@ -67,11 +67,18 @@ public IReadOnlyList GenerateDataAnnotationAttributes( string? dropAfter = GetString(annotations, RetentionPolicyAnnotations.DropAfter); string? dropCreatedBefore = GetString(annotations, RetentionPolicyAnnotations.DropCreatedBefore); - object?[] positionalArgs = dropAfter is not null - ? [dropAfter] - : [null, dropCreatedBefore]; Dictionary namedArgs = []; + if (!string.IsNullOrWhiteSpace(dropAfter)) + { + namedArgs[nameof(RetentionPolicyAttribute.DropAfter)] = dropAfter; + } + + if (!string.IsNullOrWhiteSpace(dropCreatedBefore)) + { + namedArgs[nameof(RetentionPolicyAttribute.DropCreatedBefore)] = dropCreatedBefore; + } + string? scheduleInterval = GetString(annotations, RetentionPolicyAnnotations.ScheduleInterval); if (!string.IsNullOrWhiteSpace(scheduleInterval)) { @@ -102,7 +109,7 @@ public IReadOnlyList GenerateDataAnnotationAttributes( } ConsumeAllRetentionAnnotations(annotations); - return [new AttributeCodeFragment(typeof(RetentionPolicyAttribute), positionalArgs, namedArgs)]; + return [new AttributeCodeFragment(typeof(RetentionPolicyAttribute), [], namedArgs)]; } public void ConsumeFeatureAnnotations(IEntityType entityType, IDictionary annotations) diff --git a/src/Eftdb.Design/Generators/CompressionPolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/CompressionPolicyCSharpGenerator.cs new file mode 100644 index 0000000..f2f2bee --- /dev/null +++ b/src/Eftdb.Design/Generators/CompressionPolicyCSharpGenerator.cs @@ -0,0 +1,98 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators +{ + /// + /// Emits typed migrationBuilder C# calls into a migration file. + /// + public class CompressionPolicyCSharpGenerator(ICSharpHelper code) + { + private readonly ICSharpHelper code = code; + + public void Generate(AddCompressionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AddCompressionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.After)) + call.Arg("after", code.Literal(operation.After)); + + if (!string.IsNullOrEmpty(operation.CreatedBefore)) + call.Arg("createdBefore", code.Literal(operation.CreatedBefore)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.Timezone)) + call.Arg("timezone", code.Literal(operation.Timezone)); + + if (operation.IfNotExists.HasValue) + call.Arg("ifNotExists", code.Literal(operation.IfNotExists.Value)); + } + + public void Generate(AlterCompressionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "AlterCompressionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + + if (!string.IsNullOrEmpty(operation.After)) + call.Arg("after", code.Literal(operation.After)); + + if (!string.IsNullOrEmpty(operation.CreatedBefore)) + call.Arg("createdBefore", code.Literal(operation.CreatedBefore)); + + if (!string.IsNullOrEmpty(operation.ScheduleInterval)) + call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval)); + + if (operation.InitialStart.HasValue) + call.Arg("initialStart", code.Literal(operation.InitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.Timezone)) + call.Arg("timezone", code.Literal(operation.Timezone)); + + if (operation.IfNotExists.HasValue) + call.Arg("ifNotExists", code.Literal(operation.IfNotExists.Value)); + + if (!string.IsNullOrEmpty(operation.OldAfter)) + call.Arg("oldAfter", code.Literal(operation.OldAfter)); + + if (!string.IsNullOrEmpty(operation.OldCreatedBefore)) + call.Arg("oldCreatedBefore", code.Literal(operation.OldCreatedBefore)); + + if (!string.IsNullOrEmpty(operation.OldScheduleInterval)) + call.Arg("oldScheduleInterval", code.Literal(operation.OldScheduleInterval)); + + if (operation.OldInitialStart.HasValue) + call.Arg("oldInitialStart", code.Literal(operation.OldInitialStart.Value)); + + if (!string.IsNullOrEmpty(operation.OldTimezone)) + call.Arg("oldTimezone", code.Literal(operation.OldTimezone)); + + if (operation.OldIfNotExists.HasValue) + call.Arg("oldIfNotExists", code.Literal(operation.OldIfNotExists.Value)); + } + + public void Generate(DropCompressionPolicyOperation operation, IndentedStringBuilder builder) + { + using MigrationCallWriter call = new(builder, "DropCompressionPolicy"); + + call.Arg("tableName", code.Literal(operation.TableName)); + + if (!string.IsNullOrEmpty(operation.Schema)) + call.Arg("schema", code.Literal(operation.Schema)); + } + } +} diff --git a/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs index 50a6f10..567fecd 100644 --- a/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs +++ b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs @@ -55,6 +55,15 @@ public void Generate(CreateContinuousAggregateOperation operation, IndentedStrin if (!string.IsNullOrEmpty(operation.ViewDefinition)) call.Arg("viewDefinition", code.Literal(operation.ViewDefinition)); + + if (operation.EnableCompression) + call.Arg("enableCompression", code.Literal(true)); + + if (operation.CompressionSegmentBy is { Count: > 0 }) + call.Arg("compressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionSegmentBy)); + + if (operation.CompressionOrderBy is { Count: > 0 }) + call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); } public void Generate(AlterContinuousAggregateOperation operation, IndentedStringBuilder builder) @@ -84,6 +93,24 @@ public void Generate(AlterContinuousAggregateOperation operation, IndentedString if (operation.OldMaterializedOnly) call.Arg("oldMaterializedOnly", code.Literal(true)); + + if (operation.EnableCompression) + call.Arg("enableCompression", code.Literal(true)); + + if (operation.CompressionSegmentBy is { Count: > 0 }) + call.Arg("compressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionSegmentBy)); + + if (operation.CompressionOrderBy is { Count: > 0 }) + call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); + + if (operation.OldEnableCompression) + call.Arg("oldEnableCompression", code.Literal(true)); + + if (operation.OldCompressionSegmentBy is { Count: > 0 }) + call.Arg("oldCompressionSegmentBy", CSharpGeneratorHelper.LiteralStringList(code, operation.OldCompressionSegmentBy)); + + if (operation.OldCompressionOrderBy is { Count: > 0 }) + call.Arg("oldCompressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.OldCompressionOrderBy)); } public void Generate(DropContinuousAggregateOperation operation, IndentedStringBuilder builder) diff --git a/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs index 2d8ab81..ac28e08 100644 --- a/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs +++ b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs @@ -42,6 +42,12 @@ public void Generate(CreateHypertableOperation operation, IndentedStringBuilder if (operation.CompressionOrderBy is { Count: > 0 }) call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); + + if (operation.CompressionSparseIndex != null) + call.Arg("compressionSparseIndex", code.Literal(operation.CompressionSparseIndex)); + + if (!string.IsNullOrEmpty(operation.CompressChunkTimeInterval)) + call.Arg("compressChunkTimeInterval", code.Literal(operation.CompressChunkTimeInterval)); } public void Generate(AlterHypertableOperation operation, IndentedStringBuilder builder) @@ -71,6 +77,12 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b if (operation.CompressionOrderBy is { Count: > 0 }) call.Arg("compressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.CompressionOrderBy)); + if (operation.CompressionSparseIndex != null) + call.Arg("compressionSparseIndex", code.Literal(operation.CompressionSparseIndex)); + + if (!string.IsNullOrEmpty(operation.CompressChunkTimeInterval)) + call.Arg("compressChunkTimeInterval", code.Literal(operation.CompressChunkTimeInterval)); + // Old* values — emitted for Down() reversibility, only when non-default. if (!string.IsNullOrEmpty(operation.OldChunkTimeInterval)) call.Arg("oldChunkTimeInterval", code.Literal(operation.OldChunkTimeInterval)); @@ -89,6 +101,12 @@ public void Generate(AlterHypertableOperation operation, IndentedStringBuilder b if (operation.OldCompressionOrderBy is { Count: > 0 }) call.Arg("oldCompressionOrderBy", CSharpGeneratorHelper.LiteralStringList(code, operation.OldCompressionOrderBy)); + + if (operation.OldCompressionSparseIndex != null) + call.Arg("oldCompressionSparseIndex", code.Literal(operation.OldCompressionSparseIndex)); + + if (!string.IsNullOrEmpty(operation.OldCompressChunkTimeInterval)) + call.Arg("oldCompressChunkTimeInterval", code.Literal(operation.OldCompressChunkTimeInterval)); } // Writes the dimension list directly into the builder so each entry is on its own diff --git a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs index d14237f..a0eb8e4 100644 --- a/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs +++ b/src/Eftdb.Design/Generators/TimescaleDbAnnotationCodeGenerator.cs @@ -35,6 +35,7 @@ public sealed class TimescaleDbAnnotationCodeGenerator( new ContinuousAggregatePolicyAnnotationRenderer(), new RetentionPolicyAnnotationRenderer(), new ReorderPolicyAnnotationRenderer(), + new CompressionPolicyAnnotationRenderer(), ]; private readonly Dictionary> _entityAttributeCache = []; diff --git a/src/Eftdb.Design/Scaffolding/CompressionPolicyAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/CompressionPolicyAnnotationApplier.cs new file mode 100644 index 0000000..b0ca488 --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/CompressionPolicyAnnotationApplier.cs @@ -0,0 +1,66 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.CompressionPolicyScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Applies compression policy annotations to scaffolded database tables. + /// + public sealed class CompressionPolicyAnnotationApplier : IAnnotationApplier + { + public void ApplyAnnotations(DatabaseTable table, object featureInfo) + { + if (featureInfo is not CompressionPolicyInfo policyInfo) + { + throw new ArgumentException($"Expected {nameof(CompressionPolicyInfo)}, got {featureInfo.GetType().Name}", nameof(featureInfo)); + } + + table[CompressionPolicyAnnotations.HasCompressionPolicy] = true; + + if (!string.IsNullOrWhiteSpace(policyInfo.After)) + { + table[CompressionPolicyAnnotations.After] = policyInfo.After; + } + + if (!string.IsNullOrWhiteSpace(policyInfo.CreatedBefore)) + { + table[CompressionPolicyAnnotations.CreatedBefore] = policyInfo.CreatedBefore; + } + + if (policyInfo.InitialStart.HasValue) + { + table[CompressionPolicyAnnotations.InitialStart] = policyInfo.InitialStart.Value; + } + + // Only emit the schedule interval when it differs from the TimescaleDB-computed default for + // this hypertable's chunk interval. This prevents phantom alters on roundtrip: if the user + // never set a schedule_interval and the DB stored the default, the scaffolded model must also + // carry no explicit interval so the differ sees null == null. + // The hypertable annotation applier runs before the compression policy applier (registration + // order in TimescaleDatabaseModelFactory), so the ChunkTimeInterval annotation is already set. + if (!string.IsNullOrWhiteSpace(policyInfo.ScheduleInterval)) + { + string? chunkTimeInterval = table[HypertableAnnotations.ChunkTimeInterval] as string; + string? computedDefault = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + if (computedDefault == null || policyInfo.ScheduleInterval != computedDefault) + { + table[CompressionPolicyAnnotations.ScheduleInterval] = policyInfo.ScheduleInterval; + } + } + + if (!string.IsNullOrWhiteSpace(policyInfo.Timezone)) + { + table[CompressionPolicyAnnotations.Timezone] = policyInfo.Timezone; + } + + if (policyInfo.IfNotExists.HasValue) + { + table[CompressionPolicyAnnotations.IfNotExists] = policyInfo.IfNotExists.Value; + } + } + } +} diff --git a/src/Eftdb.Design/Scaffolding/CompressionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/CompressionPolicyScaffoldingExtractor.cs new file mode 100644 index 0000000..efe3164 --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/CompressionPolicyScaffoldingExtractor.cs @@ -0,0 +1,106 @@ +using System.Data.Common; +using System.Text.Json; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Extracts compression policy metadata from a TimescaleDB database for scaffolding. + /// Handles both hypertable and continuous aggregate compression policies. + /// + public sealed class CompressionPolicyScaffoldingExtractor : ITimescaleFeatureExtractor + { + public sealed record CompressionPolicyInfo( + string? After, + string? CreatedBefore, + DateTime? InitialStart, + string? ScheduleInterval, + string? Timezone, + bool? IfNotExists + ); + + public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) + => ScaffoldingExtractorHelper.UsingConnection(connection, () => + { + Dictionary<(string, string), CompressionPolicyInfo> compressionPolicies = []; + + using (DbCommand command = connection.CreateCommand()) + { + // For hypertables the jobs view reports the user-facing schema/name directly. + // For continuous aggregates the jobs view reports the internal materialization + // hypertable (_timescaledb_internal._materialized_hypertable_N). The LEFT JOIN + // against _timescaledb_catalog.continuous_agg maps those back to the user-facing + // view schema/name so the result key matches the scaffolded CAgg entity. + command.CommandText = @" + SELECT + COALESCE(cagg.user_view_schema, j.hypertable_schema) AS effective_schema, + COALESCE(cagg.user_view_name, j.hypertable_name) AS effective_name, + j.config, + j.initial_start, + j.schedule_interval::text, + bgw.timezone + FROM timescaledb_information.jobs AS j + LEFT JOIN _timescaledb_config.bgw_job AS bgw ON bgw.id = j.job_id + LEFT JOIN _timescaledb_catalog.continuous_agg AS cagg + ON cagg.mat_hypertable_id = ( + SELECT id + FROM _timescaledb_catalog.hypertable + WHERE schema_name = j.hypertable_schema + AND table_name = j.hypertable_name + ) + WHERE j.proc_name IN ('policy_compression', 'policy_columnstore');"; + + using DbDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string schema = reader.GetString(0); + string name = reader.GetString(1); + string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2); + DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); + string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); + string? timezone = reader.IsDBNull(5) ? null : reader.GetString(5); + + // Parse the JSONB config to extract compress_after or compress_created_before + string? after = null; + string? createdBefore = null; + + if (!string.IsNullOrWhiteSpace(configJson)) + { + using JsonDocument doc = JsonDocument.Parse(configJson); + JsonElement root = doc.RootElement; + + if (root.TryGetProperty("compress_after", out JsonElement afterElement)) + { + after = IntervalParsingHelper.ParseIntervalOrInteger(afterElement); + } + + if (root.TryGetProperty("compress_created_before", out JsonElement createdBeforeElement)) + { + createdBefore = IntervalParsingHelper.ParseIntervalOrInteger(createdBeforeElement); + } + } + + // A compression policy must have either compress_after or compress_created_before + if (string.IsNullOrWhiteSpace(after) && string.IsNullOrWhiteSpace(createdBefore)) + { + continue; + } + + compressionPolicies[(schema, name)] = new CompressionPolicyInfo( + After: after, + CreatedBefore: createdBefore, + InitialStart: initialStart, + ScheduleInterval: scheduleInterval, + Timezone: timezone, + IfNotExists: null + ); + } + } + + // Convert to object dictionary to match interface + return compressionPolicies.ToDictionary( + kvp => kvp.Key, + kvp => (object)kvp.Value + ); + }); + } +} diff --git a/src/Eftdb.Design/Scaffolding/CompressionSettingsScaffoldingHelper.cs b/src/Eftdb.Design/Scaffolding/CompressionSettingsScaffoldingHelper.cs new file mode 100644 index 0000000..44666a8 --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/CompressionSettingsScaffoldingHelper.cs @@ -0,0 +1,217 @@ +using System.Data.Common; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Shared helper for reading compression settings for scaffolding extractors. + /// + internal static class CompressionSettingsScaffoldingHelper + { + /// + /// Reads all rows from timescaledb_information.compression_settings and invokes + /// for each row to map the raw (schema, name) key to + /// the consumer's dictionary key. Rows for which returns + /// are silently skipped. + /// + /// An open database connection. + /// + /// A callback that receives the raw (schema, name) key from the view and returns + /// plus the resolved consumer key when the row should be processed. + /// + /// + /// A callback invoked for each accepted row. Receives the resolved consumer key, the column name, + /// whether this is a segment-by entry, and (when it is an order-by entry) the isAscending + /// and isNullsFirst flags. Both the segment-by and order-by checks are independent: a row + /// may contribute to either or both lists, matching the raw view behaviour. + /// + internal static void ReadCompressionSettings( + DbConnection connection, + Func<(string Schema, string Name), ((string, string) Key, bool Accepted)> resolveKey, + Action<(string, string), string, bool, bool, bool, bool> applyRow) + { + using DbCommand command = connection.CreateCommand(); + + command.CommandText = @" + SELECT + hypertable_schema, + hypertable_name, + attname, + segmentby_column_index, + orderby_column_index, + orderby_asc, + orderby_nullsfirst + FROM timescaledb_information.compression_settings + ORDER BY hypertable_schema, hypertable_name, segmentby_column_index, orderby_column_index;"; + + using DbDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string rawSchema = reader.GetString(0); + string rawName = reader.GetString(1); + string columnName = reader.GetString(2); + + ((string, string) resolvedKey, bool accepted) = resolveKey((rawSchema, rawName)); + if (!accepted) + { + continue; + } + + bool isSegmentBy = !reader.IsDBNull(3); + bool isOrderBy = !reader.IsDBNull(4); + + bool isAscending = isOrderBy && reader.GetBoolean(5); + bool isNullsFirst = isOrderBy && reader.GetBoolean(6); + + applyRow(resolvedKey, columnName, isSegmentBy, isOrderBy, isAscending, isNullsFirst); + } + } + + /// + /// Detects whether timescaledb_information.hypertable_columnstore_settings exists + /// (available from TimescaleDB 2.18) and, when present, reads segmentby and orderby lists + /// for each hypertable using the callback. Returns + /// when the view was used so the caller can skip the legacy fallback. + /// + /// An open database connection. + /// + /// A callback that receives the raw (schema, name) key from the view and returns + /// plus the resolved consumer key when the row should be processed. + /// + /// + /// A callback invoked for each segment-by column name of an accepted hypertable. + /// + /// + /// A callback invoked for each order-by entry of an accepted hypertable, already in + /// canonical column ASC|DESC [NULLS FIRST|LAST] form. + /// + internal static bool TryReadCompressionSettingsFromColumnstoreView( + DbConnection connection, + Func<(string Schema, string Name), ((string, string) Key, bool Accepted)> resolveKey, + Action<(string, string), string> applySegmentBy, + Action<(string, string), string> applyOrderBy) + { + if (!ScaffoldingExtractorHelper.ViewExists(connection, "timescaledb_information", "hypertable_columnstore_settings")) + { + return false; + } + + using DbCommand command = connection.CreateCommand(); + + command.CommandText = @" + SELECT + ht.schema_name, + ht.table_name, + hcs.segmentby, + hcs.orderby + FROM timescaledb_information.hypertable_columnstore_settings AS hcs + JOIN _timescaledb_catalog.hypertable AS ht + ON format('%I.%I', ht.schema_name, ht.table_name)::regclass = hcs.hypertable;"; + + using DbDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string rawSchema = reader.GetString(0); + string rawName = reader.GetString(1); + + ((string, string) resolvedKey, bool accepted) = resolveKey((rawSchema, rawName)); + if (!accepted) + { + continue; + } + + string? segmentByText = reader.IsDBNull(2) ? null : reader.GetString(2); + if (segmentByText is not null) + { + foreach (string col in segmentByText.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + applySegmentBy(resolvedKey, col); + } + } + + string? orderByText = reader.IsDBNull(3) ? null : reader.GetString(3); + if (orderByText is not null) + { + foreach (string token in orderByText.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + applyOrderBy(resolvedKey, ParseColumnstoreOrderByToken(token)); + } + } + } + + return true; + } + + /// + /// Builds the ORDER BY entry string from the supplied direction and nulls properties. + /// + internal static string BuildOrderByEntry(string columnName, bool isAscending, bool isNullsFirst) + { + string direction = isAscending ? "ASC" : "DESC"; + bool isDefaultNulls = (isAscending && !isNullsFirst) || (!isAscending && isNullsFirst); + string nulls = isDefaultNulls ? "" : (isNullsFirst ? " NULLS FIRST" : " NULLS LAST"); + + return $"{columnName} {direction}{nulls}"; + } + + /// + /// Parses a single ORDER BY token from timescaledb_information.hypertable_columnstore_settings + /// into the canonical BuildOrderByEntry format: column ASC|DESC [NULLS FIRST|NULLS LAST]. + /// + internal static string ParseColumnstoreOrderByToken(string token) + { + string columnName; + string suffix; + + if (token.Length > 0 && token[0] == '"') + { + // Quoted identifier: scan for the closing double-quote, handling doubled "" escapes. + int end = 1; + while (end < token.Length) + { + if (token[end] == '"') + { + if (end + 1 < token.Length && token[end + 1] == '"') + { + // Escaped double-quote inside identifier — skip both + end += 2; + } + else + { + // Closing quote found + break; + } + } + else + { + end++; + } + } + + columnName = token[1..end].Replace("\"\"", "\""); + suffix = end + 1 < token.Length ? token[(end + 1)..] : string.Empty; + } + else + { + int space = token.IndexOf(' '); + if (space < 0) + { + columnName = token; + suffix = string.Empty; + } + else + { + columnName = token[..space]; + suffix = token[(space + 1)..]; + } + } + + string upper = suffix.ToUpperInvariant().Trim(); + + bool isAscending = !upper.Contains("DESC"); + bool? nullsFirst = upper.Contains("NULLS FIRST") ? true : upper.Contains("NULLS LAST") ? false : null; + bool resolvedNullsFirst = nullsFirst ?? !isAscending; + + return BuildOrderByEntry(columnName, isAscending, resolvedNullsFirst); + } + } +} diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs index 15c3b8f..a7ae4a5 100644 --- a/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs +++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregateAnnotationApplier.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.ContinuousAggregateScaffoldingExtractor; @@ -32,6 +33,24 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo) // aggregate / group_by annotations (which the scaffolder cannot reliably // recover from the catalog). table[ContinuousAggregateAnnotations.ViewDefinition] = info.ViewDefinition; + + bool hasSegmentBy = info.CompressionSegmentBy is { Count: > 0 }; + bool hasOrderBy = info.CompressionOrderBy is { Count: > 0 }; + + if (info.CompressionEnabled || hasSegmentBy || hasOrderBy) + { + table[HypertableAnnotations.EnableCompression] = true; + } + + if (hasSegmentBy) + { + table[HypertableAnnotations.CompressionSegmentBy] = string.Join(",", info.CompressionSegmentBy!); + } + + if (hasOrderBy) + { + table[HypertableAnnotations.CompressionOrderBy] = string.Join(",", info.CompressionOrderBy!); + } } } } diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs index e38a196..62e073b 100644 --- a/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Data.Common; using System.Text.Json; @@ -21,14 +20,7 @@ public sealed record ContinuousAggregatePolicyInfo( ); public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) - { - bool wasOpen = connection.State == ConnectionState.Open; - if (!wasOpen) - { - connection.Open(); - } - - try + => ScaffoldingExtractorHelper.UsingConnection(connection, () => { Dictionary<(string, string), ContinuousAggregatePolicyInfo> policies = []; @@ -128,14 +120,6 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca kvp => kvp.Key, kvp => (object)kvp.Value ); - } - finally - { - if (!wasOpen) - { - connection.Close(); - } - } - } + }); } } diff --git a/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs index 2dffebe..47fb54d 100644 --- a/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ContinuousAggregateScaffoldingExtractor.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Data.Common; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding @@ -15,26 +14,27 @@ public sealed record ContinuousAggregateInfo( string SourceHypertableName, string SourceSchema, bool MaterializedOnly, - string? ChunkInterval + string? ChunkInterval, + bool CompressionEnabled = false, + List? CompressionSegmentBy = null, + List? CompressionOrderBy = null ); public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) { - bool wasOpen = connection.State == ConnectionState.Open; - if (!wasOpen) - { - connection.Open(); - } - try { - Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates = []; - - using (DbCommand command = connection.CreateCommand()) + return ScaffoldingExtractorHelper.UsingConnection(connection, () => { - // Query continuous aggregates from TimescaleDB information schema - // This query supports TimescaleDB v2.16 and higher - command.CommandText = @" + Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates = []; + + using (DbCommand command = connection.CreateCommand()) + { + // Query continuous aggregates from TimescaleDB information schema + // This query supports TimescaleDB v2.16 and higher. + // materialization_hypertable_schema/name identify the internal materialized hypertable + // (_timescaledb_internal._materialized_hypertable_N) used to join compression settings. + command.CommandText = @" SELECT ca.view_schema, ca.view_name, @@ -42,7 +42,10 @@ public sealed record ContinuousAggregateInfo( ca.hypertable_schema, ca.hypertable_name, ca.materialized_only, - dim.time_interval::text AS chunk_interval + dim.time_interval::text AS chunk_interval, + ca.compression_enabled, + ca.materialization_hypertable_schema, + ca.materialization_hypertable_name FROM timescaledb_information.continuous_aggregates ca LEFT JOIN _timescaledb_catalog.continuous_agg cagg ON ca.view_schema = cagg.user_view_schema @@ -54,42 +57,120 @@ LEFT JOIN timescaledb_information.dimensions dim AND dim.hypertable_name = mat_ht.table_name AND dim.dimension_number = 1;"; - using DbDataReader reader = command.ExecuteReader(); - while (reader.Read()) - { - string viewSchema = reader.GetString(0); - string viewName = reader.GetString(1); - string viewDefinition = reader.GetString(2); - string hypertableSchema = reader.GetString(3); - string hypertableName = reader.GetString(4); - bool materializedOnly = reader.GetBoolean(5); - string? chunkInterval = reader.IsDBNull(6) ? null : reader.GetString(6); - - continuousAggregates[(viewSchema, viewName)] = new ContinuousAggregateInfo( - MaterializedViewName: viewName, - Schema: viewSchema, - ViewDefinition: viewDefinition, - SourceHypertableName: hypertableName, - SourceSchema: hypertableSchema, - MaterializedOnly: materializedOnly, - ChunkInterval: chunkInterval - ); + using DbDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string viewSchema = reader.GetString(0); + string viewName = reader.GetString(1); + string viewDefinition = reader.GetString(2); + string hypertableSchema = reader.GetString(3); + string hypertableName = reader.GetString(4); + bool materializedOnly = reader.GetBoolean(5); + string? chunkInterval = reader.IsDBNull(6) ? null : reader.GetString(6); + bool compressionEnabled = !reader.IsDBNull(7) && reader.GetBoolean(7); + string? matSchema = reader.IsDBNull(8) ? null : reader.GetString(8); + string? matName = reader.IsDBNull(9) ? null : reader.GetString(9); + + continuousAggregates[(viewSchema, viewName)] = new ContinuousAggregateInfo( + MaterializedViewName: viewName, + Schema: viewSchema, + ViewDefinition: viewDefinition, + SourceHypertableName: hypertableName, + SourceSchema: hypertableSchema, + MaterializedOnly: materializedOnly, + ChunkInterval: chunkInterval, + CompressionEnabled: compressionEnabled, + CompressionSegmentBy: [], + CompressionOrderBy: [] + ); + + if (matSchema is not null && matName is not null) + { + _matHypertableToView[(matSchema, matName)] = (viewSchema, viewName); + } + } } - } - // Convert to object dictionary to match interface - return continuousAggregates.ToDictionary( - kvp => kvp.Key, - kvp => (object)kvp.Value - ); + GetCompressionConfiguration(connection, continuousAggregates); + + // Convert to object dictionary to match interface + return continuousAggregates.ToDictionary( + kvp => kvp.Key, + kvp => (object)kvp.Value + ); + }); } finally { - if (!wasOpen) + _matHypertableToView.Clear(); + } + } + + private readonly Dictionary<(string, string), (string ViewSchema, string ViewName)> _matHypertableToView = []; + + private void GetCompressionConfiguration( + DbConnection connection, + Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates) + { + bool usedNewView = CompressionSettingsScaffoldingHelper.TryReadCompressionSettingsFromColumnstoreView( + connection, + rawKey => + { + if (!_matHypertableToView.TryGetValue(rawKey, out (string ViewSchema, string ViewName) viewKey)) + { + return (default, false); + } + + return ((viewKey.ViewSchema, viewKey.ViewName), true); + }, + (key, columnName) => + { + if (continuousAggregates.TryGetValue(key, out ContinuousAggregateInfo? info)) + { + info.CompressionSegmentBy?.Add(columnName); + } + }, + (key, orderByEntry) => { - connection.Close(); - } + if (continuousAggregates.TryGetValue(key, out ContinuousAggregateInfo? info)) + { + info.CompressionOrderBy?.Add(orderByEntry); + } + }); + + if (usedNewView) + { + return; } + + CompressionSettingsScaffoldingHelper.ReadCompressionSettings( + connection, + rawKey => + { + if (!_matHypertableToView.TryGetValue(rawKey, out (string ViewSchema, string ViewName) viewKey)) + { + return (default, false); + } + + return ((viewKey.ViewSchema, viewKey.ViewName), true); + }, + (key, columnName, isSegmentBy, isOrderBy, isAscending, isNullsFirst) => + { + if (!continuousAggregates.TryGetValue(key, out ContinuousAggregateInfo? info)) + { + return; + } + + if (isSegmentBy) + { + info.CompressionSegmentBy?.Add(columnName); + } + + if (isOrderBy) + { + info.CompressionOrderBy?.Add(CompressionSettingsScaffoldingHelper.BuildOrderByEntry(columnName, isAscending, isNullsFirst)); + } + }); } } } diff --git a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs index 347c605..0c80bbe 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs @@ -46,6 +46,16 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo) { table[HypertableAnnotations.AdditionalDimensions] = JsonSerializer.Serialize(info.AdditionalDimensions); } + + if (info.CompressionSparseIndex != null) + { + table[HypertableAnnotations.CompressionSparseIndex] = info.CompressionSparseIndex; + } + + if (!string.IsNullOrWhiteSpace(info.CompressChunkTimeInterval)) + { + table[HypertableAnnotations.CompressChunkTimeInterval] = info.CompressChunkTimeInterval; + } } /// diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index adf0ea1..863b83a 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -1,6 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; -using System.Data; using System.Data.Common; +using System.Text.Json; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding { @@ -16,54 +16,41 @@ public sealed record HypertableInfo( List CompressionSegmentBy, List CompressionOrderBy, List ChunkSkipColumns, - List AdditionalDimensions + List AdditionalDimensions, + string? CompressionSparseIndex, + string? CompressChunkTimeInterval ); public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) - { - bool wasOpen = connection.State == ConnectionState.Open; - if (!wasOpen) - { - connection.Open(); - } - - try + => ScaffoldingExtractorHelper.UsingConnection(connection, () => { Dictionary<(string, string), HypertableInfo> hypertables = []; Dictionary<(string, string), bool> compressionSettings = GetCompressionSettings(connection); GetHypertableSettings(connection, hypertables, compressionSettings); GetChunkSkipColumns(connection, hypertables); - GetCompressionConfiguration(connection, hypertables); + + bool usedNewView = GetColumnstoreSettings(connection, hypertables); + if (!usedNewView) + { + GetCompressionConfiguration(connection, hypertables); + } // Convert to object dictionary to match interface return hypertables.ToDictionary( kvp => kvp.Key, kvp => (object)kvp.Value ); - } - finally - { - if (!wasOpen) - { - connection.Close(); - } - } - } + }); private static Dictionary<(string, string), bool> GetCompressionSettings(DbConnection connection) { Dictionary<(string, string), bool> compressionSettings = []; using DbCommand command = connection.CreateCommand(); - command.CommandText = @" + command.CommandText = $@" SELECT hypertable_schema, hypertable_name, compression_enabled FROM timescaledb_information.hypertables - WHERE hypertable_schema NOT IN ( - '_timescaledb_internal', - '_timescaledb_catalog', - '_timescaledb_config', - '_timescaledb_cache' - );"; + WHERE hypertable_schema NOT IN ({ScaffoldingExtractorHelper.TimescaleInternalSchemaExclusion});"; using DbDataReader reader = command.ExecuteReader(); while (reader.Read()) { @@ -78,7 +65,7 @@ private static void GetHypertableSettings( Dictionary<(string, string), bool> compressionSettings) { using DbCommand command = connection.CreateCommand(); - command.CommandText = @" + command.CommandText = $@" SELECT hypertable_schema, hypertable_name, @@ -88,12 +75,7 @@ private static void GetHypertableSettings( time_interval::text AS time_interval_text, integer_interval FROM timescaledb_information.dimensions - WHERE hypertable_schema NOT IN ( - '_timescaledb_internal', - '_timescaledb_catalog', - '_timescaledb_config', - '_timescaledb_cache' - ) + WHERE hypertable_schema NOT IN ({ScaffoldingExtractorHelper.TimescaleInternalSchemaExclusion}) ORDER BY hypertable_schema, hypertable_name, dimension_number;"; using DbDataReader reader = command.ExecuteReader(); @@ -132,7 +114,9 @@ WHERE hypertable_schema NOT IN ( CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); } // For all other dimensions, add them to the AdditionalDimensions list @@ -190,57 +174,186 @@ FROM _timescaledb_catalog.chunk_column_stats AS ccs } } - private static void GetCompressionConfiguration(DbConnection connection, Dictionary<(string, string), HypertableInfo> hypertables) + /// + /// Reads segmentby, orderby, sparse_index, and compress_interval_length from + /// timescaledb_information.hypertable_columnstore_settings in a single query. + /// Returns when the view exists and was used; + /// when the view is absent (TimescaleDB older than 2.18) so the caller can fall back to + /// . + /// + private static bool GetColumnstoreSettings(DbConnection connection, Dictionary<(string, string), HypertableInfo> hypertables) { + if (!ScaffoldingExtractorHelper.ViewExists(connection, "timescaledb_information", "hypertable_columnstore_settings")) + { + return false; + } + using DbCommand command = connection.CreateCommand(); - // This view provides the column-level details for compression. - // segmentby_column_index is not null for segment columns. - // orderby_column_index is not null for order columns. - command.CommandText = @" - SELECT - hypertable_schema, - hypertable_name, - attname, - segmentby_column_index, - orderby_column_index, - orderby_asc, - orderby_nullsfirst - FROM timescaledb_information.compression_settings - ORDER BY hypertable_schema, hypertable_name, segmentby_column_index, orderby_column_index;"; + command.CommandText = $@" + SELECT + ht.schema_name, + ht.table_name, + hcs.segmentby, + hcs.orderby, + hcs.index, + hcs.compress_interval_length + FROM timescaledb_information.hypertable_columnstore_settings AS hcs + JOIN _timescaledb_catalog.hypertable AS ht + ON format('%I.%I', ht.schema_name, ht.table_name)::regclass = hcs.hypertable + WHERE ht.schema_name NOT IN ({ScaffoldingExtractorHelper.TimescaleInternalSchemaExclusion});"; using DbDataReader reader = command.ExecuteReader(); while (reader.Read()) { string schema = reader.GetString(0); string name = reader.GetString(1); - string columnName = reader.GetString(2); - // Find the corresponding hypertable info if (!hypertables.TryGetValue((schema, name), out HypertableInfo? info)) { continue; } - // Handle SegmentBy - if (!reader.IsDBNull(3)) // segmentby_column_index + List segmentBy = []; + string? segmentByText = reader.IsDBNull(2) ? null : reader.GetString(2); + if (segmentByText is not null) { - info.CompressionSegmentBy.Add(columnName); + foreach (string col in segmentByText.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + segmentBy.Add(col); + } } - // Handle OrderBy - if (!reader.IsDBNull(4)) // orderby_column_index + List orderBy = []; + string? orderByText = reader.IsDBNull(3) ? null : reader.GetString(3); + if (orderByText is not null) { - bool isAscending = reader.GetBoolean(5); - bool isNullsFirst = reader.GetBoolean(6); + foreach (string token in orderByText.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + orderBy.Add(CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token)); + } + } + + string? sparseIndexRaw = reader.IsDBNull(4) ? null : reader.GetString(4); + string? sparseIndex = sparseIndexRaw is not null ? ParseSparseIndexJson(sparseIndexRaw) : null; - string direction = isAscending ? "ASC" : "DESC"; - bool isDefaultNulls = (isAscending && !isNullsFirst) || (!isAscending && isNullsFirst); - string nulls = isDefaultNulls ? "" : (isNullsFirst ? " NULLS FIRST" : " NULLS LAST"); + string? compressChunkTimeInterval = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); - info.CompressionOrderBy.Add($"{columnName} {direction}{nulls}"); + bool updated = segmentBy.Count > 0 || orderBy.Count > 0 + || sparseIndex != null || compressChunkTimeInterval != null; + + if (updated) + { + hypertables[(schema, name)] = info with + { + CompressionSegmentBy = segmentBy.Count > 0 ? segmentBy : info.CompressionSegmentBy, + CompressionOrderBy = orderBy.Count > 0 ? orderBy : info.CompressionOrderBy, + CompressionSparseIndex = sparseIndex ?? info.CompressionSparseIndex, + CompressChunkTimeInterval = compressChunkTimeInterval ?? info.CompressChunkTimeInterval, + }; } } + + return true; + } + + /// + /// Fallback path for TimescaleDB servers older than 2.18 that do not have + /// timescaledb_information.hypertable_columnstore_settings. Reads segmentby and + /// orderby from the legacy row-per-column timescaledb_information.compression_settings view. + /// + private static void GetCompressionConfiguration(DbConnection connection, Dictionary<(string, string), HypertableInfo> hypertables) + { + CompressionSettingsScaffoldingHelper.ReadCompressionSettings( + connection, + rawKey => + { + bool accepted = hypertables.ContainsKey(rawKey); + return (rawKey, accepted); + }, + (key, columnName, isSegmentBy, isOrderBy, isAscending, isNullsFirst) => + { + if (!hypertables.TryGetValue(key, out HypertableInfo? info)) + { + return; + } + + if (isSegmentBy) + { + info.CompressionSegmentBy.Add(columnName); + } + + if (isOrderBy) + { + info.CompressionOrderBy.Add(CompressionSettingsScaffoldingHelper.BuildOrderByEntry(columnName, isAscending, isNullsFirst)); + } + }); + } + + /// + /// Parses the index JSON array from hypertable_columnstore_settings and + /// reconstructs the canonical sparse-index annotation string used by the runtime library. + /// + /// + /// A comma-separated string of entries in type(column) form (e.g. + /// "bloom(device_id), minmax(value)"), or when no + /// user-configured entries are present in the array. + /// + private static string? ParseSparseIndexJson(string indexJson) + { + List entries = []; + + using JsonDocument doc = JsonDocument.Parse(indexJson); + foreach (JsonElement element in doc.RootElement.EnumerateArray()) + { + if (!element.TryGetProperty("source", out JsonElement sourceElement) || + !string.Equals(sourceElement.GetString(), "config", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!element.TryGetProperty("type", out JsonElement typeElement)) + { + continue; + } + + string? type = typeElement.GetString(); + if (string.IsNullOrEmpty(type)) + { + continue; + } + + if (!element.TryGetProperty("column", out JsonElement columnElement)) + { + continue; + } + + string columns; + if (columnElement.ValueKind == JsonValueKind.Array) + { + List cols = []; + foreach (JsonElement col in columnElement.EnumerateArray()) + { + string? colName = col.GetString(); + if (!string.IsNullOrEmpty(colName)) + { + cols.Add(colName); + } + } + columns = string.Join(",", cols); + } + else + { + columns = columnElement.GetString() ?? string.Empty; + } + + if (!string.IsNullOrEmpty(columns)) + { + entries.Add($"{type}({columns})"); + } + } + + return entries.Count > 0 ? string.Join(", ", entries) : null; } } diff --git a/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs index df8b38b..b038d69 100644 --- a/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/ReorderPolicyScaffoldingExtractor.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Data.Common; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding @@ -18,14 +17,7 @@ public sealed record ReorderPolicyInfo( ); public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) - { - bool wasOpen = connection.State == ConnectionState.Open; - if (!wasOpen) - { - connection.Open(); - } - - try + => ScaffoldingExtractorHelper.UsingConnection(connection, () => { Dictionary<(string, string), ReorderPolicyInfo> reorderPolicies = []; using (DbCommand command = connection.CreateCommand()) @@ -50,7 +42,6 @@ FROM timescaledb_information.jobs AS j string name = reader.GetString(1); string indexName = reader.GetString(2); DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); - string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); @@ -75,14 +66,6 @@ FROM timescaledb_information.jobs AS j kvp => kvp.Key, kvp => (object)kvp.Value ); - } - finally - { - if (!wasOpen) - { - connection.Close(); - } - } - } + }); } } diff --git a/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs index 3de9152..2387cf8 100644 --- a/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Data.Common; using System.Text.Json; @@ -20,14 +19,7 @@ public sealed record RetentionPolicyInfo( ); public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) - { - bool wasOpen = connection.State == ConnectionState.Open; - if (!wasOpen) - { - connection.Open(); - } - - try + => ScaffoldingExtractorHelper.UsingConnection(connection, () => { Dictionary<(string, string), RetentionPolicyInfo> retentionPolicies = []; @@ -101,14 +93,6 @@ FROM timescaledb_information.jobs AS j kvp => kvp.Key, kvp => (object)kvp.Value ); - } - finally - { - if (!wasOpen) - { - connection.Close(); - } - } - } + }); } } diff --git a/src/Eftdb.Design/Scaffolding/ScaffoldingExtractorHelper.cs b/src/Eftdb.Design/Scaffolding/ScaffoldingExtractorHelper.cs new file mode 100644 index 0000000..36cc87c --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/ScaffoldingExtractorHelper.cs @@ -0,0 +1,73 @@ +using System.Data; +using System.Data.Common; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Shared infrastructure helpers for scaffolding extractors. + /// + internal static class ScaffoldingExtractorHelper + { + /// + /// Internal schema names that are never part of a user model. Embed this fragment in + /// WHERE schema_column NOT IN (...) clauses. + /// + internal const string TimescaleInternalSchemaExclusion = + "'_timescaledb_internal', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_cache'"; + + /// + /// Opens when it is not already open, executes + /// , then closes the connection only if it was opened here. + /// + internal static TResult UsingConnection(DbConnection connection, Func body) + { + bool wasOpen = connection.State == ConnectionState.Open; + if (!wasOpen) + { + connection.Open(); + } + + try + { + return body(); + } + finally + { + if (!wasOpen) + { + connection.Close(); + } + } + } + + /// + /// Returns when the view identified by + /// / exists in + /// information_schema.views. Uses parameterized commands to prevent SQL injection. + /// + internal static bool ViewExists(DbConnection connection, string schema, string viewName) + { + using DbCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.views + WHERE table_schema = @schema + AND table_name = @viewName + );"; + + DbParameter schemaParam = command.CreateParameter(); + schemaParam.ParameterName = "@schema"; + schemaParam.Value = schema; + command.Parameters.Add(schemaParam); + + DbParameter nameParam = command.CreateParameter(); + nameParam.ParameterName = "@viewName"; + nameParam.Value = viewName; + command.Parameters.Add(nameParam); + + object? result = command.ExecuteScalar(); + return result is true; + } + } +} diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index 1be20d8..4727d31 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -42,6 +42,16 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui new RetentionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(dropRetention, builder); return; + case AddCompressionPolicyOperation addCompression: + new CompressionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(addCompression, builder); + return; + case AlterCompressionPolicyOperation alterCompression: + new CompressionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(alterCompression, builder); + return; + case DropCompressionPolicyOperation dropCompression: + new CompressionPolicyCSharpGenerator(Dependencies.CSharpHelper).Generate(dropCompression, builder); + return; + case CreateContinuousAggregateOperation createContinuousAggregate: new ContinuousAggregateCSharpGenerator(Dependencies.CSharpHelper).Generate(createContinuousAggregate, builder); return; diff --git a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs index f82ea0c..004b449 100644 --- a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs +++ b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs @@ -30,7 +30,8 @@ public class TimescaleDatabaseModelFactory(IDiagnosticsLogger + /// Identifies the kind of sparse index function. + /// + public enum ESparseIndexType + { + /// Bloom-filter sparse index + Bloom, + + /// Min/max range sparse index + MinMax, + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAnnotations.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAnnotations.cs new file mode 100644 index 0000000..c97e12e --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAnnotations.cs @@ -0,0 +1,16 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// Contains constants for annotations used by the TimescaleDB compression policy feature. + /// + public static class CompressionPolicyAnnotations + { + public const string HasCompressionPolicy = "TimescaleDB:HasCompressionPolicy"; + public const string After = "TimescaleDB:CompressionPolicy:After"; + public const string CreatedBefore = "TimescaleDB:CompressionPolicy:CreatedBefore"; + public const string InitialStart = "TimescaleDB:CompressionPolicy:InitialStart"; + public const string ScheduleInterval = "TimescaleDB:CompressionPolicy:ScheduleInterval"; + public const string Timezone = "TimescaleDB:CompressionPolicy:Timezone"; + public const string IfNotExists = "TimescaleDB:CompressionPolicy:IfNotExists"; + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAttribute.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAttribute.cs new file mode 100644 index 0000000..261c609 --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyAttribute.cs @@ -0,0 +1,102 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// Schedules automatic compression of chunks on a hypertable or continuous aggregate. + /// Generates a call to add_compression_policy() during migration. + /// + /// + /// Exactly one of or must be set. + /// The entity's table must already have compression enabled via timescaledb.compress + /// (see [Hypertable(EnableCompression = true)] or .IsHypertable().WithCompression()). + /// All interval values are strings (e.g., "7 days"). No overloads exist + /// because the underlying unit depends on the time-column type. + /// + /// + /// + /// [Hypertable("Time", EnableCompression = true)] + /// [CompressionPolicy("7 days")] + /// public class Reading { ... } + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class CompressionPolicyAttribute : Attribute + { + /// + /// Gets or sets the interval after which chunks are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + /// "7 days" + public string? After { get; set; } + + /// + /// Gets or sets the interval relative to chunk creation time. + /// Chunks created more than this interval ago are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + /// "30 days" + public string? CreatedBefore { get; set; } + + /// + /// Gets or sets the interval between policy job executions. + /// When not set TimescaleDB defaults to 12 hours (or half the chunk interval for sub-day intervals). + /// + /// "12 hours" + public string? ScheduleInterval { get; set; } + + /// + /// Gets or sets the first time the policy job is scheduled to run, + /// as a UTC date-time string in ISO 8601 format. + /// When not set the scheduler derives the initial run from the schedule interval. + /// + /// "2025-10-01T03:00:00Z" + public string? InitialStart { get; set; } + + /// + /// Gets or sets the PostgreSQL time zone used when computing the initial start time. + /// + /// "Europe/Berlin" + public string? Timezone { get; set; } + + /// + /// Gets or sets a value indicating whether the operation should succeed silently when the policy + /// already exists, instead of raising an error. + /// Defaults to . + /// + public bool IfNotExists { get; set; } + + /// + /// Supports property-initializer usage, e.g. [CompressionPolicy(After = "7 days")]. + /// + public CompressionPolicyAttribute() + { + } + + /// + /// Configures a compression policy using compress_after. + /// + /// The interval after which chunks are compressed (e.g., "7 days"). + public CompressionPolicyAttribute(string after) + { + if (string.IsNullOrWhiteSpace(after)) + { + throw new ArgumentException("After must be provided.", nameof(after)); + } + + After = after; + } + + /// + /// Configures a compression policy. Exactly one of or must be non-null. + /// + public CompressionPolicyAttribute(string? after = null, string? createdBefore = null) + { + bool hasAfter = !string.IsNullOrWhiteSpace(after); + bool hasCreatedBefore = !string.IsNullOrWhiteSpace(createdBefore); + + ConventionValidationHelper.ValidateExclusiveFields("CompressionPolicy", "After", hasAfter, "CreatedBefore", hasCreatedBefore); + + After = after; + CreatedBefore = createdBefore; + } + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyConvention.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyConvention.cs new file mode 100644 index 0000000..9a4f6a9 --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyConvention.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using System.Reflection; + +using static CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ConventionValidationHelper; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// A convention that configures the compression policy for a hypertable or continuous aggregate + /// based on the presence of the . + /// + public class CompressionPolicyConvention : IEntityTypeAddedConvention + { + /// + /// Called when an entity type is added to the model. + /// + /// The builder for the entity type. + /// Additional information available during convention execution. + public void ProcessEntityTypeAdded( + IConventionEntityTypeBuilder entityTypeBuilder, + IConventionContext context) + { + IConventionEntityType entityType = entityTypeBuilder.Metadata; + CompressionPolicyAttribute? attribute = entityType.ClrType?.GetCustomAttribute(); + + if (attribute == null) + { + return; + } + + bool hasAfter = !string.IsNullOrWhiteSpace(attribute.After); + bool hasCreatedBefore = !string.IsNullOrWhiteSpace(attribute.CreatedBefore); + + ValidateExclusiveFields( + entityType.ClrType?.Name, + "[CompressionPolicy]", + "After", hasAfter, + "CreatedBefore", hasCreatedBefore); + + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + + if (hasAfter) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.After, attribute.After!); + + if (hasCreatedBefore) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.CreatedBefore, attribute.CreatedBefore!); + + if (!string.IsNullOrWhiteSpace(attribute.ScheduleInterval)) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.ScheduleInterval, attribute.ScheduleInterval); + + DateTime? parsedInitialStart = ParseInitialStart(attribute.InitialStart, entityType.ClrType?.Name, "[CompressionPolicy]"); + if (parsedInitialStart.HasValue) + { + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, parsedInitialStart.Value); + } + + if (!string.IsNullOrWhiteSpace(attribute.Timezone)) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.Timezone, attribute.Timezone); + + if (attribute.IfNotExists) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.IfNotExists, true); + } + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyPrerequisiteValidationConvention.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyPrerequisiteValidationConvention.cs new file mode 100644 index 0000000..f7d6ace --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyPrerequisiteValidationConvention.cs @@ -0,0 +1,71 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// Validates that every continuous aggregate configured with a compression policy has compression + /// enabled. Runs at model finalization so that fluent API configuration applied in + /// OnModelCreating is visible — unlike , which fires + /// before OnModelCreating executes. + /// + public class CompressionPolicyPrerequisiteValidationConvention : IModelFinalizedConvention + { + /// + /// Called once the model has been finalized and all conventions have run. + /// + /// The finalized model. + /// The unchanged model. + public IModel ProcessModelFinalized(IModel model) + { + foreach (IEntityType entityType in model.GetEntityTypes()) + { + ValidateContinuousAggregateCompressionPolicyPrerequisite(entityType); + } + + return model; + } + + private static void ValidateContinuousAggregateCompressionPolicyPrerequisite(IEntityType entityType) + { + bool hasPolicy = entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value as bool? ?? false; + if (!hasPolicy) + { + return; + } + + bool isContinuousAggregate = !string.IsNullOrWhiteSpace( + entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)?.Value as string); + + if (!isContinuousAggregate) + { + return; + } + + if (IsCompressionConfigured(entityType)) + { + return; + } + + string displayName = entityType.ClrType?.Name ?? entityType.Name; + + throw new InvalidOperationException( + $"Compression policy on '{displayName}': Compression must be enabled on the continuous aggregate before adding a compression policy. " + + "Enable compression first via [ContinuousAggregate(EnableCompression = true)] or .IsContinuousAggregate(...).WithCompression()."); + } + + /// + /// Reads the compression annotations from the entity and evaluates the shared + /// compression-state rule in . + /// + private static bool IsCompressionConfigured(IEntityType entityType) + => CompressionSettingsSqlHelper.IsCompressionEnabled( + entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value as bool? == true, + entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string, + entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string, + entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value as string); + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs new file mode 100644 index 0000000..58debb7 --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// Provides a fluent API for configuring optional TimescaleDB compression policy parameters + /// whose types cannot be rendered by the scaffold code generator (e.g. ). + /// Returned by the scaffold-targeting overloads of WithCompressionPolicy. + /// + /// The entity type the compression policy is applied to. + public sealed class CompressionPolicyStringBuilder where TEntity : class + { + private readonly EntityTypeBuilder _builder; + + internal EntityTypeBuilder EntityTypeBuilder => _builder; + + internal CompressionPolicyStringBuilder(EntityTypeBuilder builder) + { + _builder = builder; + } + + /// + /// Sets the initial start time for the compression policy job. + /// + /// The first time the policy job is scheduled to run. + /// The builder for method chaining. + public CompressionPolicyStringBuilder WithInitialStart(DateTime initialStart) + { + _builder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart); + return this; + } + } +} diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs new file mode 100644 index 0000000..148d680 --- /dev/null +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs @@ -0,0 +1,188 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy +{ + /// + /// Provides extension methods for configuring TimescaleDB compression policies using the EF Core Fluent API. + /// + public static class CompressionPolicyTypeBuilder + { + /// + /// Configures a TimescaleDB compression policy for the entity. + /// Exactly one of or must be specified. + /// + /// + /// The policy schedules automatic conversion of chunks to the columnstore (compression) via + /// add_compression_policy(). The target table must already have compression enabled + /// (see .IsHypertable().WithCompression() or the EnableCompression hypertable option). + /// All interval values are strings (e.g., "7 days"). No overloads are provided + /// because the underlying unit depends on the time-column type. + /// + /// + /// + /// modelBuilder.Entity<Reading>() + /// .WithCompressionPolicy(after: "7 days", scheduleInterval: "1 day"); + /// + /// + /// The type of the entity being configured. + /// The builder for the entity type being configured. + /// The interval after which chunks are compressed. Mutually exclusive with . + /// The interval relative to chunk creation time. Mutually exclusive with . + /// The interval between policy job executions. + /// The first time the policy job is scheduled to run. When null the scheduler derives the initial run from the schedule interval. + /// The PostgreSQL time zone used when computing the initial start time. + /// When , no error is raised if the policy already exists. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder WithCompressionPolicy( + this EntityTypeBuilder entityTypeBuilder, + string? after = null, + string? createdBefore = null, + string? scheduleInterval = null, + DateTime? initialStart = null, + string? timezone = null, + bool? ifNotExists = null) where TEntity : class + { + WriteCompressionPolicy(entityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); + + if (initialStart.HasValue) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart.Value); + + return entityTypeBuilder; + } + + /// + /// Configures a TimescaleDB compression policy for the entity. + /// Used by code generated by dotnet ef dbcontext scaffold. + /// Exactly one of or must be specified. + /// + /// The type of the entity being configured. + /// The builder for the entity type being configured. + /// The interval after which chunks are compressed. Mutually exclusive with . + /// The interval relative to chunk creation time. Mutually exclusive with . + /// The interval between policy job executions. + /// The PostgreSQL time zone used when computing the initial start time. + /// When , no error is raised if the policy already exists. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static CompressionPolicyStringBuilder WithCompressionPolicy( + this EntityTypeBuilder entityTypeBuilder, + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) where TEntity : class + { + WriteCompressionPolicy(entityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); + return new CompressionPolicyStringBuilder(entityTypeBuilder); + } + + /// + /// Configures a TimescaleDB compression policy for a hypertable that already has a retention policy + /// configured, chained after the retention policy configuration. + /// Used by code generated by dotnet ef dbcontext scaffold. + /// Exactly one of or must be specified. + /// + /// The type of the entity being configured. + /// The retention policy builder being configured. + /// The interval after which chunks are compressed. Mutually exclusive with . + /// The interval relative to chunk creation time. Mutually exclusive with . + /// The interval between policy job executions. + /// The PostgreSQL time zone used when computing the initial start time. + /// When , no error is raised if the policy already exists. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static CompressionPolicyStringBuilder WithCompressionPolicy( + this RetentionPolicyStringBuilder builder, + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) where TEntity : class + { + WriteCompressionPolicy(builder.EntityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); + return new CompressionPolicyStringBuilder(builder.EntityTypeBuilder); + } + + /// + /// Configures a TimescaleDB compression policy for a continuous aggregate, chained directly + /// after the configuration. + /// Exactly one of or must be specified. + /// + /// The type of the entity being configured. + /// The continuous aggregate builder being configured. + /// The interval after which chunks are compressed. Mutually exclusive with . + /// The interval relative to chunk creation time. Mutually exclusive with . + /// The interval between policy job executions. + /// The PostgreSQL time zone used when computing the initial start time. + /// When , no error is raised if the policy already exists. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static CompressionPolicyStringBuilder WithCompressionPolicy( + this ContinuousAggregateStringBuilder builder, + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) where TEntity : class + { + WriteCompressionPolicy(builder.EntityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); + return new CompressionPolicyStringBuilder(builder.EntityTypeBuilder); + } + + /// + /// Configures a TimescaleDB compression policy for a continuous aggregate that already has a + /// refresh policy configured, chained after the refresh policy configuration. + /// Exactly one of or must be specified. + /// + /// The type of the entity being configured. + /// The continuous aggregate policy builder being configured. + /// The interval after which chunks are compressed. Mutually exclusive with . + /// The interval relative to chunk creation time. Mutually exclusive with . + /// The interval between policy job executions. + /// The PostgreSQL time zone used when computing the initial start time. + /// When , no error is raised if the policy already exists. + /// A builder for chaining optional parameters not representable as literal constructor arguments. + public static CompressionPolicyStringBuilder WithCompressionPolicy( + this ContinuousAggregatePolicyStringBuilder builder, + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) where TEntity : class + { + WriteCompressionPolicy(builder.EntityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); + return new CompressionPolicyStringBuilder(builder.EntityTypeBuilder); + } + + private static void WriteCompressionPolicy( + EntityTypeBuilder entityTypeBuilder, + string? after, + string? createdBefore, + string? scheduleInterval, + string? timezone, + bool? ifNotExists) + { + bool hasAfter = !string.IsNullOrWhiteSpace(after); + bool hasCreatedBefore = !string.IsNullOrWhiteSpace(createdBefore); + + ConventionValidationHelper.ValidateExclusiveFields("WithCompressionPolicy", "after", hasAfter, "createdBefore", hasCreatedBefore); + + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + + if (hasAfter) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.After, after!); + + if (hasCreatedBefore) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.CreatedBefore, createdBefore!); + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.ScheduleInterval, scheduleInterval); + + if (!string.IsNullOrWhiteSpace(timezone)) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.Timezone, timezone); + + if (ifNotExists.HasValue) + entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.IfNotExists, ifNotExists.Value); + } + } +} diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs index 17c1012..2565ed2 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateAttribute.cs @@ -64,5 +64,31 @@ public class ContinuousAggregateAttribute : Attribute /// The clause should be a valid SQL string without the "WHERE" keyword itself (e.g., "device_id = 'sensor-1'"). /// public string? Where { get; set; } + + /// + /// Gets or sets a value indicating whether columnstore (compression) is enabled on the continuous aggregate. + /// Corresponds to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). + /// Enabling compression is a prerequisite for adding a [CompressionPolicy] to a continuous aggregate. + /// + public bool EnableCompression { get; set; } = false; + + /// + /// Gets or sets the columns to segment by when compressing the continuous aggregate. + /// Maps to timescaledb.compress_segmentby. Setting this implicitly enables compression. + /// + /// + /// [ContinuousAggregate(..., CompressionSegmentBy = new[] { "device_id" })] + /// + public string[]? CompressionSegmentBy { get; set; } + + /// + /// Gets or sets the columns to order by within each compressed segment. + /// Maps to timescaledb.compress_orderby. Setting this implicitly enables compression. + /// Use SQL syntax for direction and null ordering (e.g., "time DESC", "value ASC NULLS LAST"). + /// + /// + /// [ContinuousAggregate(..., CompressionOrderBy = new[] { "bucket DESC" })] + /// + public string[]? CompressionOrderBy { get; set; } } } diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs index d8b5896..b184252 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilder.cs @@ -1,4 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Linq.Expressions; @@ -69,8 +71,8 @@ public ContinuousAggregateBuilder AddAggregateFunction> sourceColumn, EAggregateFunction function) { - string propertyName = GetPropertyName(propertyExpression); - string sourceColumnName = GetPropertyName(sourceColumn); + string propertyName = ExpressionHelper.GetPropertyName(propertyExpression); + string sourceColumnName = ExpressionHelper.GetPropertyName(sourceColumn); ContinuousAggregateBuilderCore.AddAggregateFunction(EntityTypeBuilder, propertyName, sourceColumnName, function); return this; } @@ -84,7 +86,7 @@ public ContinuousAggregateBuilder AddAggregateFunction AddGroupByColumn( Expression> propertyExpression) { - ContinuousAggregateBuilderCore.AddGroupByColumn(EntityTypeBuilder, GetPropertyName(propertyExpression)); + ContinuousAggregateBuilderCore.AddGroupByColumn(EntityTypeBuilder, ExpressionHelper.GetPropertyName(propertyExpression)); return this; } @@ -110,19 +112,73 @@ public ContinuousAggregateBuilder Where(string whereClau return this; } - internal static string GetPropertyName(Expression> propertyExpression) + /// + /// Enables or disables columnstore (compression) on the continuous aggregate. + /// Corresponds to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). + /// Enabling compression is a prerequisite for adding a compression policy to a continuous aggregate. + /// + /// Whether to enable compression. Defaults to . + /// The builder for method chaining. + public ContinuousAggregateBuilder WithCompression(bool enable = true) { - if (propertyExpression.Body is MemberExpression memberExpression) - { - return memberExpression.Member.Name; - } + ContinuousAggregateBuilderCore.EnableCompression(EntityTypeBuilder, enable); + return this; + } - if (propertyExpression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression unaryMemberExpression) - { - return unaryMemberExpression.Member.Name; - } + /// + /// Specifies the columns to segment by when compressing the continuous aggregate. + /// Corresponds to timescaledb.compress_segmentby. Implicitly enables compression. + /// + /// Lambda expressions selecting the properties to segment by. + /// The builder for method chaining. + public ContinuousAggregateBuilder WithCompressionSegmentBy( + params Expression>[] segmentByColumns) + { + string[] columnNames = [.. segmentByColumns.Select(ExpressionHelper.GetPropertyName)]; + ContinuousAggregateBuilderCore.WithCompressionSegmentBy(EntityTypeBuilder, string.Join(", ", columnNames)); + return this; + } - throw new ArgumentException("Expression must be a simple property access expression.", nameof(propertyExpression)); + /// + /// Specifies the columns to order by within each compressed segment using explicit + /// definitions. Corresponds to timescaledb.compress_orderby. + /// Implicitly enables compression. + /// + /// instances describing the ordering. + /// The builder for method chaining. + public ContinuousAggregateBuilder WithCompressionOrderBy( + params OrderBy[] orderByRules) + { + ContinuousAggregateBuilderCore.WithCompressionOrderBy(EntityTypeBuilder, string.Join(", ", orderByRules.Select(r => r.ToSql()))); + return this; + } + + /// + /// Specifies the columns to order by within each compressed segment using an + /// factory. Corresponds to timescaledb.compress_orderby. + /// Implicitly enables compression. + /// + /// A function that receives an and returns the ordering rules. + /// The builder for method chaining. + public ContinuousAggregateBuilder WithCompressionOrderBy( + Func, IEnumerable> orderSelector) + { + OrderBySelector selector = new(); + return WithCompressionOrderBy([.. orderSelector(selector)]); } + + /// + /// Specifies the columns to order by within each compressed segment, one selector per column. + /// Corresponds to timescaledb.compress_orderby. Implicitly enables compression. + /// + /// Per-column selector functions. + /// The builder for method chaining. + public ContinuousAggregateBuilder WithCompressionOrderBy( + params Func, OrderBy>[] orderSelectors) + { + OrderBySelector selector = new(); + return WithCompressionOrderBy([.. orderSelectors.Select(s => s(selector))]); + } + } } diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs index 78219d4..69b066b 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateBuilderCore.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -27,6 +28,34 @@ public static void WithChunkInterval(EntityTypeBuilder builder, string chunkInte public static void Where(EntityTypeBuilder builder, string whereClause) => builder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, whereClause); + /// + /// Enables or disables columnstore (compression) on the continuous aggregate materialized view. + /// Maps to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). + /// Enabling compression is a prerequisite for adding a compression policy to a continuous aggregate. + /// + public static void EnableCompression(EntityTypeBuilder builder, bool enable) + => builder.HasAnnotation(HypertableAnnotations.EnableCompression, enable); + + /// + /// Sets the segment-by columns for compression on the continuous aggregate. + /// Implicitly enables compression. Maps to timescaledb.compress_segmentby. + /// + public static void WithCompressionSegmentBy(EntityTypeBuilder builder, string segmentBy) + { + builder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, segmentBy); + builder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + } + + /// + /// Sets the order-by clause for compression on the continuous aggregate. + /// Implicitly enables compression. Maps to timescaledb.compress_orderby. + /// + public static void WithCompressionOrderBy(EntityTypeBuilder builder, string orderBy) + { + builder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, orderBy); + builder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + } + /// /// Appends an aggregate mapping in the "alias:function:sourceColumn" annotation format. /// An alias already present is left unchanged. diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs index 14ef480..9e6e7d3 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; @@ -32,6 +33,23 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.MaterializedOnly, continuousAggregateAttribute.MaterializedOnly); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.WhereClause, continuousAggregateAttribute.Where); + if (continuousAggregateAttribute.EnableCompression) + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + } + + if (continuousAggregateAttribute.CompressionSegmentBy is { Length: > 0 } segmentBy) + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, string.Join(", ", segmentBy)); + } + + if (continuousAggregateAttribute.CompressionOrderBy is { Length: > 0 } orderBy) + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, string.Join(", ", orderBy)); + } + // Discover class-level TimeBucket configuration from [TimeBucketAttribute] TimeBucketAttribute? timeBucketAttr = entityType.ClrType?.GetCustomAttribute(); if (timeBucketAttr != null) diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs index e9886b4..2020705 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateStringBuilder.cs @@ -99,5 +99,42 @@ public ContinuousAggregateStringBuilder Where(string whereClause) ContinuousAggregateBuilderCore.Where(_builder, whereClause); return this; } + + /// + /// Enables or disables columnstore (compression) on the continuous aggregate. + /// Corresponds to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). + /// Enabling compression is a prerequisite for adding a compression policy to a continuous aggregate. + /// + /// Whether to enable compression. Defaults to . + /// The builder for method chaining. + public ContinuousAggregateStringBuilder WithCompression(bool enable = true) + { + ContinuousAggregateBuilderCore.EnableCompression(_builder, enable); + return this; + } + + /// + /// Specifies the columns to segment by when compressing the continuous aggregate using column name strings. + /// Corresponds to timescaledb.compress_segmentby. Implicitly enables compression. + /// + /// A comma-separated list of column names to segment by. + /// The builder for method chaining. + public ContinuousAggregateStringBuilder WithCompressionSegmentBy(string segmentBy) + { + ContinuousAggregateBuilderCore.WithCompressionSegmentBy(_builder, segmentBy); + return this; + } + + /// + /// Specifies the order-by clause for compression on the continuous aggregate using a raw SQL string. + /// Corresponds to timescaledb.compress_orderby. Implicitly enables compression. + /// + /// A comma-separated list of column expressions (e.g., "time DESC, value ASC NULLS LAST"). + /// The builder for method chaining. + public ContinuousAggregateStringBuilder WithCompressionOrderBy(string orderBy) + { + ContinuousAggregateBuilderCore.WithCompressionOrderBy(_builder, orderBy); + return this; + } } } diff --git a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs index d97f5ad..585a129 100644 --- a/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs +++ b/src/Eftdb/Configuration/ContinuousAggregate/ContinuousAggregateTypeBuilder.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Linq.Expressions; @@ -163,7 +164,7 @@ private static ContinuousAggregateBuilder IsContinuousAg string parentName = typeof(TSourceEntity).Name; entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.ParentName, parentName); - string timeBucketSourceColumn = ContinuousAggregateBuilder.GetPropertyName(propertyExpression); + string timeBucketSourceColumn = ExpressionHelper.GetPropertyName(propertyExpression); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, timeBucketSourceColumn); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, timeBucketWidth); entityTypeBuilder.HasAnnotation(ContinuousAggregateAnnotations.TimeBucketGroupBy, timeBucketGroupBy); diff --git a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs index 743cb16..450465e 100644 --- a/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs +++ b/src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs @@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Conventions; using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ConventionValidationHelper; + namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy { /// @@ -44,17 +46,9 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.ScheduleInterval, attribute.ScheduleInterval); // Apply initial start if provided - if (!string.IsNullOrWhiteSpace(attribute.InitialStart)) - { - if (DateTime.TryParse(attribute.InitialStart, out DateTime parsedDateTime)) - { - entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, parsedDateTime); - } - else - { - throw new InvalidOperationException($"InitialStart '{attribute.InitialStart}' is not a valid DateTime format. Please use a valid DateTime string in ISO 8601 format (e.g., '2025-12-15T03:00:00Z')."); - } - } + DateTime? parsedInitialStart = ParseInitialStart(attribute.InitialStart, entityType.ClrType?.Name, "[ContinuousAggregatePolicy]"); + if (parsedInitialStart.HasValue) + entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, parsedInitialStart.Value); // Apply if_not_exists flag if (attribute.IfNotExists) diff --git a/src/Eftdb/Configuration/ConventionValidationHelper.cs b/src/Eftdb/Configuration/ConventionValidationHelper.cs new file mode 100644 index 0000000..eb38896 --- /dev/null +++ b/src/Eftdb/Configuration/ConventionValidationHelper.cs @@ -0,0 +1,80 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration +{ + /// + /// Shared validation helpers for entity-type conventions, covering common patterns + /// + internal static class ConventionValidationHelper + { + /// + /// Validates that exactly one of two mutually-exclusive fields is set, throwing when + /// both are set or neither is set. + /// + /// The CLR type name of the entity, for use in exception messages. + /// The attribute name shown in the exception message prefix (e.g., "[CompressionPolicy]"). + /// The name of the first field (e.g., "After"). + /// Whether the first field is set. + /// The name of the second field (e.g., "CreatedBefore"). + /// Whether the second field is set. + internal static void ValidateExclusiveFields( + string? entityName, + string attributeName, + string firstFieldName, + bool hasFirst, + string secondFieldName, + bool hasSecond) + => ValidateExclusiveFields($"{attributeName} on '{entityName}'", firstFieldName, hasFirst, secondFieldName, hasSecond); + + /// + /// Validates that exactly one of two mutually-exclusive fields is set, throwing when + /// both are set or neither is set. + /// + /// The exception message prefix identifying the configuration source (e.g., "WithRetentionPolicy"). + /// The name of the first field (e.g., "dropAfter"). + /// Whether the first field is set. + /// The name of the second field (e.g., "dropCreatedBefore"). + /// Whether the second field is set. + internal static void ValidateExclusiveFields( + string contextPrefix, + string firstFieldName, + bool hasFirst, + string secondFieldName, + bool hasSecond) + { + if (hasFirst && hasSecond) + { + throw new InvalidOperationException( + $"{contextPrefix}: '{firstFieldName}' and '{secondFieldName}' are mutually exclusive. Specify exactly one."); + } + + if (!hasFirst && !hasSecond) + { + throw new InvalidOperationException( + $"{contextPrefix}: Exactly one of '{firstFieldName}' or '{secondFieldName}' must be specified."); + } + } + + /// + /// Parses a from an attribute string value, throwing when + /// the value is present but cannot be parsed. + /// + /// The raw attribute string to parse. + /// The CLR type name of the entity, for use in exception messages. + /// The attribute name shown in the exception message prefix. + /// The parsed , or when is null or whitespace. + internal static DateTime? ParseInitialStart(string? rawValue, string? entityName, string attributeName) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + if (DateTime.TryParse(rawValue, out DateTime parsed)) + { + return parsed; + } + + throw new InvalidOperationException( + $"{attributeName} on '{entityName}': InitialStart '{rawValue}' is not a valid DateTime format. Use an ISO 8601 string."); + } + } +} diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs index 8d36503..1af650e 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs @@ -14,5 +14,16 @@ public static class HypertableAnnotations public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval"; public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns"; public const string AdditionalDimensions = "TimescaleDB:AdditionalDimensions"; + + /// + /// Sparse index configuration for the columnstore. + /// + public const string CompressionSparseIndex = "TimescaleDB:CompressionSparseIndex"; + + /// + /// The minimum time interval to be used for merging chunks during compression. + /// Must be a multiple of the hypertable's chunk_time_interval. + /// + public const string CompressChunkTimeInterval = "TimescaleDB:CompressChunkTimeInterval"; } } diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs index bcbc1f4..661426e 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs @@ -19,7 +19,7 @@ public sealed class HypertableAttribute : Attribute /// Maps to timescaledb.compress_segmentby. /// /// - /// [Hypertable("time", CompressionSegmentBy = ["device_id", "tenant_id"])] + /// [Hypertable("time", CompressionSegmentBy = new[] { "device_id", "tenant_id" })] /// public string[]? CompressionSegmentBy { get; set; } = null; @@ -29,7 +29,7 @@ public sealed class HypertableAttribute : Attribute /// Since attributes cannot use Expressions, you must specify the full SQL syntax if direction is needed. /// /// - /// [Hypertable("time", CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"])] + /// [Hypertable("time", CompressionOrderBy = new[] { "time DESC", "value ASC NULLS LAST" })] /// public string[]? CompressionOrderBy { get; set; } = null; @@ -44,11 +44,26 @@ public sealed class HypertableAttribute : Attribute public string ChunkTimeInterval { get; set; } = DefaultValues.ChunkTimeInterval; /// - /// Enable range statistics for a specific column in a compressed hypertable. This tracks a range of values for that column per chunk. + /// Enable range statistics for a specific column in a compressed hypertable. This tracks a range of values for that column per chunk. /// Used for chunk skipping during query optimization and applies only to the chunks created after chunk skipping is enabled. /// public string[]? ChunkSkipColumns { get; set; } = null; + /// + /// When true, explicitly disables auto-created sparse indexes on the columnstore + /// (sets timescaledb.sparse_index = ''). + /// + public bool DisableAutoSparseIndexes { get; set; } = false; + + /// + /// The minimum time interval to use when merging chunks during compression. + /// Must be a multiple of the hypertable's chunk_time_interval. + /// + /// + /// WARNING: Chunk merges are irreversible — decreasing the value later cannot un-merge already merged chunks. + /// + public string? CompressChunkTimeInterval { get; set; } = null; + public HypertableAttribute(string timeColumnName) { if (string.IsNullOrWhiteSpace(timeColumnName)) diff --git a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs index dda4e42..85a6b16 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; @@ -64,6 +64,42 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, string.Join(", ", attribute.CompressionOrderBy)); } + + SparseIndexAttribute[] sparseIndexAttributes = entityType.ClrType?.GetCustomAttributes().ToArray() ?? []; + bool hasDisable = attribute.DisableAutoSparseIndexes; + bool hasSparseIndexAttrs = sparseIndexAttributes.Length > 0; + + if (hasDisable && hasSparseIndexAttrs) + { + throw new InvalidOperationException( + $"Entity '{entityType.ClrType?.Name}' has both [SparseIndex] attributes and " + + $"{nameof(HypertableAttribute.DisableAutoSparseIndexes)} = true on [Hypertable]. " + + "These are mutually exclusive — remove one or the other."); + } + + if (hasDisable) + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, string.Empty); + } + else if (hasSparseIndexAttrs) + { + string annotationValue = string.Join(", ", sparseIndexAttributes.Select(a => + { + string func = a.Kind == ESparseIndexType.Bloom ? "bloom" : "minmax"; + return $"{func}({string.Join(",", a.Columns)})"; + })); + + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, annotationValue); + } + + if (!string.IsNullOrWhiteSpace(attribute.CompressChunkTimeInterval)) + { + // CompressChunkTimeInterval requires compression to be enabled + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressChunkTimeInterval, attribute.CompressChunkTimeInterval); + } } DimensionAttribute[] dimensionAttributes = entityType.ClrType?.GetCustomAttributes().ToArray() ?? []; diff --git a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs index b1e916c..3f3e5b6 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Linq.Expressions; @@ -72,7 +73,7 @@ private static EntityTypeBuilder IsHypertableCore( EntityTypeBuilder entityTypeBuilder, Expression> timePropertyExpression) where TEntity : class { - string propertyName = GetPropertyName(timePropertyExpression); + string propertyName = ExpressionHelper.GetPropertyName(timePropertyExpression); entityTypeBuilder.HasAnnotation(HypertableAnnotations.IsHypertable, true); entityTypeBuilder.HasAnnotation(HypertableAnnotations.HypertableTimeColumn, propertyName); @@ -128,7 +129,7 @@ public static EntityTypeBuilder HasRangeDimension( this EntityTypeBuilder entityTypeBuilder, Expression> column, string interval) where TEntity : class - => AddDimension(entityTypeBuilder, Dimension.CreateRange(GetPropertyName(column), interval)); + => AddDimension(entityTypeBuilder, Dimension.CreateRange(ExpressionHelper.GetPropertyName(column), interval)); /// /// Adds a hash (space) partitioning dimension to the hypertable. @@ -141,7 +142,7 @@ public static EntityTypeBuilder HasHashDimension( this EntityTypeBuilder entityTypeBuilder, Expression> column, int numberOfPartitions) where TEntity : class - => AddDimension(entityTypeBuilder, Dimension.CreateHash(GetPropertyName(column), numberOfPartitions)); + => AddDimension(entityTypeBuilder, Dimension.CreateHash(ExpressionHelper.GetPropertyName(column), numberOfPartitions)); /// /// Sets the time interval for each chunk of the hypertable. @@ -177,7 +178,7 @@ public static EntityTypeBuilder WithChunkSkipping( this EntityTypeBuilder entityTypeBuilder, params Expression>[] chunkSkipColumns) where TEntity : class { - string[] columnNames = [.. chunkSkipColumns.Select(GetPropertyName)]; + string[] columnNames = [.. chunkSkipColumns.Select(ExpressionHelper.GetPropertyName)]; entityTypeBuilder.HasAnnotation(HypertableAnnotations.ChunkSkipColumns, string.Join(",", columnNames)); return entityTypeBuilder; } @@ -212,7 +213,7 @@ public static EntityTypeBuilder WithCompressionSegmentBy( this EntityTypeBuilder entityTypeBuilder, params Expression>[] segmentByColumns) where TEntity : class { - string[] columnNames = [.. segmentByColumns.Select(GetPropertyName)]; + string[] columnNames = [.. segmentByColumns.Select(ExpressionHelper.GetPropertyName)]; entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, string.Join(", ", columnNames)); entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); @@ -271,6 +272,91 @@ public static EntityTypeBuilder WithCompressionOrderBy( return entityTypeBuilder.WithCompressionOrderBy([.. orderSelectors.Select(orderSelector => orderSelector(selector))]); } + /// + /// Configures sparse indexes for the columnstore using typed entries. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// One or more sparse index entries to configure. + public static EntityTypeBuilder WithSparseIndex( + this EntityTypeBuilder entityTypeBuilder, + params SparseIndex[] indexes) where TEntity : class + { + string annotationValue = string.Join(", ", indexes.Select(i => i.ToSql())); + return entityTypeBuilder.WithSparseIndex(annotationValue); + } + + /// + /// Configures sparse indexes for the columnstore using selector lambdas, one per entry. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// One or more selector functions that produce a per entry. + public static EntityTypeBuilder WithSparseIndex( + this EntityTypeBuilder entityTypeBuilder, + params Func, SparseIndex>[] selectors) where TEntity : class + { + SparseIndexSelector selector = new(); + SparseIndex[] indexes = [.. selectors.Select(s => s(selector))]; + return entityTypeBuilder.WithSparseIndex(indexes); + } + + /// + /// Configures sparse indexes for the columnstore using a raw string. + /// Accepts a comma-separated list of bloom(column) and minmax(column) entries. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// + /// A comma-separated list of sparse index definitions, e.g. "bloom(device_id), minmax(temperature)". + /// + public static EntityTypeBuilder WithSparseIndex( + this EntityTypeBuilder entityTypeBuilder, + string sparseIndex) where TEntity : class + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, sparseIndex ?? string.Empty); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + return entityTypeBuilder; + } + + /// + /// Explicitly disables auto-created sparse indexes on the columnstore + /// (sets timescaledb.sparse_index = ''). + /// + /// The entity type being configured. + /// The builder for the entity type. + public static EntityTypeBuilder WithoutAutoSparseIndexes( + this EntityTypeBuilder entityTypeBuilder) where TEntity : class + { + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, string.Empty); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + return entityTypeBuilder; + } + + /// + /// Sets the minimum time interval to use when merging chunks during compression. + /// The value must be a multiple of the hypertable's chunk_time_interval. + /// + /// + /// WARNING: Chunk merges are irreversible — decreasing the value later cannot un-merge already merged chunks. + /// Calling this method implicitly enables compression on the hypertable. + /// + /// The entity type being configured. + /// The builder for the entity type. + /// + /// A PostgreSQL interval string specifying the chunk merge interval, e.g. "24 hours". + /// + public static EntityTypeBuilder WithCompressChunkTimeInterval( + this EntityTypeBuilder entityTypeBuilder, + string interval) where TEntity : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(interval); + + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressChunkTimeInterval, interval); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + return entityTypeBuilder; + } + /// /// Specifies whether existing data should be migrated when converting a table to a hypertable. /// @@ -290,27 +376,5 @@ public static EntityTypeBuilder WithMigrateData( return entityTypeBuilder; } - /// - /// Extracts the property name from a member access lambda expression. - /// - /// The entity type. - /// The type of the selected property. - /// The expression to parse. - /// The name of the property. - /// Thrown if the expression is not a valid property expression. - private static string GetPropertyName(Expression> propertyExpression) - { - if (propertyExpression.Body is MemberExpression memberExpression) - { - return memberExpression.Member.Name; - } - - if (propertyExpression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression unaryMemberExpression) - { - return unaryMemberExpression.Member.Name; - } - - throw new ArgumentException("Expression is not a valid property expression.", nameof(propertyExpression)); - } } } diff --git a/src/Eftdb/Configuration/Hypertable/OrderBy.cs b/src/Eftdb/Configuration/Hypertable/OrderBy.cs index 1f5f48d..ed71d5d 100644 --- a/src/Eftdb/Configuration/Hypertable/OrderBy.cs +++ b/src/Eftdb/Configuration/Hypertable/OrderBy.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Text; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable { @@ -69,7 +70,7 @@ public static class OrderByBuilder /// public class OrderByConfiguration(Expression> expression) { - private readonly string _propertyName = GetPropertyName(expression); + private readonly string _propertyName = ExpressionHelper.GetPropertyName(expression); /// Creates an OrderBy using the database default direction. /// Optional null sorting behavior. Null uses database default. @@ -82,14 +83,6 @@ public class OrderByConfiguration(Expression> exp /// Creates a descending OrderBy specification. /// Optional null sorting behavior. Null uses database default. public OrderBy Descending(bool? nullsFirst = null) => new(_propertyName, false, nullsFirst); - - // Helper to extract the string name from the expression - private static string GetPropertyName(Expression> expression) - { - if (expression.Body is MemberExpression member) return member.Member.Name; - if (expression.Body is UnaryExpression unary && unary.Operand is MemberExpression m) return m.Member.Name; - throw new ArgumentException("Invalid expression. Please use a simple property access expression."); - } } /// @@ -102,26 +95,19 @@ public class OrderBySelector /// A lambda expression selecting the property to order by. /// Optional null sorting behavior. Null uses database default. public OrderBy By(Expression> expression, bool? nullsFirst = null) - => new(GetPropertyName(expression), null, nullsFirst); + => new(ExpressionHelper.GetPropertyName(expression), null, nullsFirst); /// Creates an ascending OrderBy specification for the selected property. /// A lambda expression selecting the property to order by. /// Optional null sorting behavior. Null uses database default. public OrderBy ByAscending(Expression> expression, bool? nullsFirst = null) - => new(GetPropertyName(expression), true, nullsFirst); + => new(ExpressionHelper.GetPropertyName(expression), true, nullsFirst); /// Creates a descending OrderBy specification for the selected property. /// A lambda expression selecting the property to order by. /// Optional null sorting behavior. Null uses database default. public OrderBy ByDescending(Expression> expression, bool? nullsFirst = null) - => new(GetPropertyName(expression), false, nullsFirst); - - private static string GetPropertyName(Expression> expression) - { - if (expression.Body is MemberExpression m) return m.Member.Name; - if (expression.Body is UnaryExpression u && u.Operand is MemberExpression m2) return m2.Member.Name; - throw new ArgumentException("Expression must be a property access."); - } + => new(ExpressionHelper.GetPropertyName(expression), false, nullsFirst); } /// diff --git a/src/Eftdb/Configuration/Hypertable/SparseIndex.cs b/src/Eftdb/Configuration/Hypertable/SparseIndex.cs new file mode 100644 index 0000000..22778b8 --- /dev/null +++ b/src/Eftdb/Configuration/Hypertable/SparseIndex.cs @@ -0,0 +1,81 @@ +using System.Linq.Expressions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable +{ + /// + /// Represents a single sparse index entry + /// + public sealed class SparseIndex + { + /// The sparse index function to apply. + public ESparseIndexType Kind { get; } + + /// The column (or property) names this entry targets. + public IReadOnlyList Columns { get; } + + /// + /// Initializes a sparse index entry. + /// + /// The sparse index function. + /// One or more column or property names. + /// Thrown when no columns are supplied. + public SparseIndex(ESparseIndexType kind, IReadOnlyList columns) + { + if (columns == null || columns.Count == 0) + { + throw new ArgumentException("At least one column must be specified.", nameof(columns)); + } + + Kind = kind; + Columns = columns; + } + + /// + /// Serializes this entry to its canonical SQL form: bloom(col1,col2) or minmax(col). + /// + public string ToSql() + { + string func = Kind switch + { + ESparseIndexType.Bloom => "bloom", + ESparseIndexType.MinMax => "minmax", + _ => throw new InvalidOperationException($"Unknown ESparseIndexType: {Kind}"), + }; + + return $"{func}({string.Join(",", Columns)})"; + } + } + + /// + /// Fluent builder for creating instances using type-safe lambda expressions. + /// + /// The entity type being configured. + public sealed class SparseIndexSelector + { + /// + /// Creates a bloom-filter sparse index entry for one or more properties. + /// At least one expression must be supplied; composite bloom entries are fully supported. + /// + /// One or more property selectors. + public SparseIndex Bloom(params Expression>[] expressions) + { + if (expressions == null || expressions.Length == 0) + { + throw new ArgumentException("At least one property expression must be supplied.", nameof(expressions)); + } + + List columns = [.. expressions.Select(ExpressionHelper.GetPropertyName)]; + return new SparseIndex(ESparseIndexType.Bloom, columns); + } + + /// + /// Creates a min/max sparse index entry for a single property. + /// Min/max is a single-column index type; use for composite entries. + /// + /// The property selector. + public SparseIndex MinMax(Expression> expression) + => new(ESparseIndexType.MinMax, [ExpressionHelper.GetPropertyName(expression)]); + } +} diff --git a/src/Eftdb/Configuration/Hypertable/SparseIndexAttribute.cs b/src/Eftdb/Configuration/Hypertable/SparseIndexAttribute.cs new file mode 100644 index 0000000..3960f70 --- /dev/null +++ b/src/Eftdb/Configuration/Hypertable/SparseIndexAttribute.cs @@ -0,0 +1,43 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable +{ + /// + /// Configures a single sparse index entry on a hypertable's columnstore. + /// Apply multiple times to declare several entries. + /// + /// + /// + /// Use for one or more columns and + /// for a single column. Specifying more than one column + /// with is detected at model finalization and raises an error, + /// because the type system cannot enforce this at compile time through attributes. + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class SparseIndexAttribute : Attribute + { + /// The sparse index function to apply. + public ESparseIndexType Kind { get; } + + /// The column or property names this entry targets. + public IReadOnlyList Columns { get; } + + /// + /// Initializes a sparse index attribute. + /// + /// The sparse index function. + /// One or more column or property names. Use nameof() for refactoring safety. + /// Thrown when no columns are supplied. + public SparseIndexAttribute(ESparseIndexType kind, params string[] columns) + { + if (columns == null || columns.Length == 0) + { + throw new ArgumentException("At least one column must be specified.", nameof(columns)); + } + + Kind = kind; + Columns = columns; + } + } +} diff --git a/src/Eftdb/Configuration/Hypertable/SparseIndexValidationConvention.cs b/src/Eftdb/Configuration/Hypertable/SparseIndexValidationConvention.cs new file mode 100644 index 0000000..48dbe8c --- /dev/null +++ b/src/Eftdb/Configuration/Hypertable/SparseIndexValidationConvention.cs @@ -0,0 +1,180 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable +{ + /// + /// Validates timescaledb.sparse_index entries against the entity's compress_segmentby + /// and compress_orderby configuration. Runs at model finalization so that all fluent API + /// configuration applied in OnModelCreating is visible. + /// + public class SparseIndexValidationConvention : IModelFinalizedConvention + { + /// + public IModel ProcessModelFinalized(IModel model) + { + foreach (IEntityType entityType in model.GetEntityTypes()) + { + ValidateSparseIndex(entityType); + } + + return model; + } + + private static void ValidateSparseIndex(IEntityType entityType) + { + bool isHypertable = entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value as bool? ?? false; + if (!isHypertable) + { + return; + } + + IAnnotation? sparseIndexAnnotation = entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex); + if (sparseIndexAnnotation == null) + { + return; + } + + string raw = sparseIndexAnnotation.Value as string ?? string.Empty; + string displayName = entityType.ClrType?.Name ?? entityType.Name; + + string? orderByValue = entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + if (string.IsNullOrWhiteSpace(orderByValue)) + { + throw new InvalidOperationException( + $"Hypertable '{displayName}': sparse_index requires compress_orderby to be configured. " + + "Call WithCompressionOrderBy() or set the CompressionOrderBy property before using WithSparseIndex()."); + } + + if (raw.Length == 0) + { + return; + } + + string? tableName = entityType.GetTableName(); + StoreObjectIdentifier storeIdentifier = tableName != null + ? StoreObjectIdentifier.Table(tableName, entityType.GetSchema()) + : default; + + HashSet segmentByColumns = [.. CompressionAnnotationExtractor.ExtractSegmentByColumns(entityType, storeIdentifier) ?? []]; + HashSet orderByColumns = [.. (CompressionAnnotationExtractor.ExtractOrderByColumns(entityType, storeIdentifier) ?? []) + .Select(clause => clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[0])]; + + Dictionary singleColumnEntries = []; + + foreach (string rawEntry in CompressionAnnotationExtractor.SplitSparseIndexEntries(raw)) + { + string entry = rawEntry.Trim(); + if (entry.Length == 0) + { + continue; + } + + ValidateSparseIndexEntry(entry, displayName, entityType, storeIdentifier, segmentByColumns, orderByColumns, singleColumnEntries); + } + } + + private static void ValidateSparseIndexEntry( + string entry, + string displayName, + IEntityType entityType, + StoreObjectIdentifier storeIdentifier, + HashSet segmentByColumns, + HashSet orderByColumns, + Dictionary singleColumnEntries) + { + int parenOpen = entry.IndexOf('('); + int parenClose = entry.LastIndexOf(')'); + + if (parenOpen < 0 || parenClose < 0 || parenClose < parenOpen) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' is malformed — missing or unbalanced parentheses."); + } + + string funcName = entry[..parenOpen].Trim(); + if (!string.Equals(funcName, "bloom", StringComparison.OrdinalIgnoreCase) && + !string.Equals(funcName, "minmax", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' uses unknown function '{funcName}' — only 'bloom' and 'minmax' are supported."); + } + + string argsPart = entry[(parenOpen + 1)..parenClose]; + List columns = []; + foreach (string col in argsPart.Split(',', StringSplitOptions.TrimEntries)) + { + if (col.Length > 0) + { + columns.Add(CompressionAnnotationExtractor.ResolveColumnName(entityType, storeIdentifier, col)); + } + } + + if (columns.Count == 0) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' has an empty argument list — at least one column must be specified."); + } + + bool isComposite = columns.Count > 1; + + if (string.Equals(funcName, "bloom", StringComparison.OrdinalIgnoreCase)) + { + foreach (string col in columns) + { + if (segmentByColumns.Contains(col)) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' includes compress_segmentby column '{col}'. " + + "Segmentby columns are not compressed and cannot have a sparse index."); + } + } + + if (!isComposite && orderByColumns.Contains(columns[0])) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' — bloom({columns[0]}) is redundant because " + + $"'{columns[0]}' is a compress_orderby column that already receives an implicit sparse index. " + + "Remove the explicit entry or use a composite bloom if additional columns are needed."); + } + } + else + { + if (columns.Count > 1) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' — minmax supports a single column only. " + + "Use bloom(...) for composite entries."); + } + + foreach (string col in columns) + { + if (segmentByColumns.Contains(col)) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': entry '{entry}' includes compress_segmentby column '{col}'. " + + "Segmentby columns are not compressed and cannot have a sparse index."); + } + } + } + + if (!isComposite) + { + string singleCol = columns[0]; + if (singleColumnEntries.TryGetValue(singleCol, out string? existingEntry)) + { + throw new InvalidOperationException( + $"Sparse index on '{displayName}': duplicate single-column sparse index entries " + + $"'{existingEntry}' and '{entry}' both target column '{singleCol}'. " + + "Remove one of them."); + } + + singleColumnEntries[singleCol] = entry; + } + } + + } +} diff --git a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyConvention.cs b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyConvention.cs index bd1ae86..2ed38fa 100644 --- a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyConvention.cs +++ b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyConvention.cs @@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Conventions; using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ConventionValidationHelper; + namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy { /// @@ -27,16 +29,10 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.IndexName, attribute.IndexName); - if (!string.IsNullOrWhiteSpace(attribute.InitialStart)) + DateTime? parsedInitialStart = ParseInitialStart(attribute.InitialStart, entityType.ClrType?.Name, "[ReorderPolicy]"); + if (parsedInitialStart.HasValue) { - if (DateTime.TryParse(attribute.InitialStart, out DateTime parsedDateTimeOffset)) - { - entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, parsedDateTimeOffset); - } - else - { - throw new InvalidOperationException($"InitialStart '{attribute.InitialStart}' is not a valid DateTime format. Please use a valid DateTime string."); - } + entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, parsedInitialStart.Value); } if (!string.IsNullOrWhiteSpace(attribute.ScheduleInterval)) diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs index e1c5ddf..8b5873b 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs @@ -69,6 +69,13 @@ public sealed class RetentionPolicyAttribute : Attribute /// public string? RetryPeriod { get; set; } + /// + /// Supports property-initializer usage, e.g. [RetentionPolicy(DropAfter = "7 days")]. + /// + public RetentionPolicyAttribute() + { + } + /// /// Configures a retention policy using drop_after. /// @@ -91,15 +98,7 @@ public RetentionPolicyAttribute(string? dropAfter = null, string? dropCreatedBef bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); - if (hasDropAfter && hasDropCreatedBefore) - { - throw new InvalidOperationException("RetentionPolicy: 'DropAfter' and 'DropCreatedBefore' are mutually exclusive. Specify exactly one."); - } - - if (!hasDropAfter && !hasDropCreatedBefore) - { - throw new InvalidOperationException("RetentionPolicy: Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified."); - } + ConventionValidationHelper.ValidateExclusiveFields("RetentionPolicy", "DropAfter", hasDropAfter, "DropCreatedBefore", hasDropCreatedBefore); DropAfter = dropAfter; DropCreatedBefore = dropCreatedBefore; diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs index bd7a284..04d87b9 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs @@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Conventions; using System.Reflection; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ConventionValidationHelper; + namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy { /// @@ -26,17 +28,11 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde bool hasDropAfter = !string.IsNullOrWhiteSpace(attribute.DropAfter); bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(attribute.DropCreatedBefore); - if (hasDropAfter && hasDropCreatedBefore) - { - throw new InvalidOperationException( - $"[RetentionPolicy] on '{entityType.ClrType?.Name}': 'DropAfter' and 'DropCreatedBefore' are mutually exclusive. Specify exactly one."); - } - - if (!hasDropAfter && !hasDropCreatedBefore) - { - throw new InvalidOperationException( - $"[RetentionPolicy] on '{entityType.ClrType?.Name}': Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified."); - } + ValidateExclusiveFields( + entityType.ClrType?.Name, + "[RetentionPolicy]", + "DropAfter", hasDropAfter, + "DropCreatedBefore", hasDropCreatedBefore); entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); @@ -46,16 +42,10 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde if (hasDropCreatedBefore) entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, attribute.DropCreatedBefore!); - if (!string.IsNullOrWhiteSpace(attribute.InitialStart)) + DateTime? parsedInitialStart = ParseInitialStart(attribute.InitialStart, entityType.ClrType?.Name, "[RetentionPolicy]"); + if (parsedInitialStart.HasValue) { - if (DateTime.TryParse(attribute.InitialStart, out DateTime parsedDateTimeOffset)) - { - entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, parsedDateTimeOffset); - } - else - { - throw new InvalidOperationException($"InitialStart '{attribute.InitialStart}' is not a valid DateTime format. Please use a valid DateTime string."); - } + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, parsedInitialStart.Value); } if (!string.IsNullOrWhiteSpace(attribute.ScheduleInterval)) diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs index 7c54c55..2fc85c9 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs @@ -52,15 +52,7 @@ public static EntityTypeBuilder WithRetentionPolicy( bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); - if (hasDropAfter && hasDropCreatedBefore) - { - throw new InvalidOperationException("WithRetentionPolicy: 'dropAfter' and 'dropCreatedBefore' are mutually exclusive. Specify exactly one."); - } - - if (!hasDropAfter && !hasDropCreatedBefore) - { - throw new InvalidOperationException("WithRetentionPolicy: Exactly one of 'dropAfter' or 'dropCreatedBefore' must be specified."); - } + ConventionValidationHelper.ValidateExclusiveFields("WithRetentionPolicy", "dropAfter", hasDropAfter, "dropCreatedBefore", hasDropCreatedBefore); entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); @@ -180,15 +172,7 @@ private static void WriteRetentionPolicy( bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); - if (hasDropAfter && hasDropCreatedBefore) - { - throw new InvalidOperationException("WithRetentionPolicy: 'dropAfter' and 'dropCreatedBefore' are mutually exclusive. Specify exactly one."); - } - - if (!hasDropAfter && !hasDropCreatedBefore) - { - throw new InvalidOperationException("WithRetentionPolicy: Exactly one of 'dropAfter' or 'dropCreatedBefore' must be specified."); - } + ConventionValidationHelper.ValidateExclusiveFields("WithRetentionPolicy", "dropAfter", hasDropAfter, "dropCreatedBefore", hasDropCreatedBefore); entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); diff --git a/src/Eftdb/DefaultValues.cs b/src/Eftdb/DefaultValues.cs index 9a50386..c6c40d6 100644 --- a/src/Eftdb/DefaultValues.cs +++ b/src/Eftdb/DefaultValues.cs @@ -15,5 +15,18 @@ public static class DefaultValues public const string RetentionPolicyScheduleInterval = "1 day"; public const int RetentionPolicyMaxRetries = -1; public const string RetentionPolicyMaxRuntime = "00:00:00"; + + /// + /// The default schedule_interval for add_compression_policy() when the + /// chunk_time_interval is at least 1 day. + /// + public const string CompressionPolicyScheduleInterval = "12 hours"; + + /// + /// The chunk time interval threshold below which the default schedule_interval for + /// add_compression_policy() becomes chunk_time_interval / 2 instead of + /// . + /// + public const string CompressionPolicyScheduleIntervalThreshold = "1 day"; } } diff --git a/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs b/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs new file mode 100644 index 0000000..a1e25b2 --- /dev/null +++ b/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs @@ -0,0 +1,126 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + /// + /// Generates add_columnstore_policy/remove_columnstore_policy SQL (or the legacy + /// add_compression_policy/remove_compression_policy forms) for compression policy + /// migration operations on hypertables and continuous aggregates. + /// + public static class CompressionPolicySqlGenerator + { + public static List Generate(AddCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + { + List statements = + [ + BuildAddPolicySql( + operation.TableName, + operation.Schema, + operation.After, + operation.CreatedBefore, + operation.ScheduleInterval, + operation.InitialStart, + operation.Timezone, + operation.IfNotExists, + useLegacyCompressionNames) + ]; + + return statements; + } + + public static List Generate(AlterCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = + [ + BuildRemovePolicySql(qualifiedTableName, useLegacyCompressionNames), + BuildAddPolicySql( + operation.TableName, + operation.Schema, + operation.After, + operation.CreatedBefore, + operation.ScheduleInterval, + operation.InitialStart, + operation.Timezone, + operation.IfNotExists, + useLegacyCompressionNames) + ]; + + return statements; + } + + public static List Generate(DropCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); + + List statements = + [ + BuildRemovePolicySql(qualifiedTableName, useLegacyCompressionNames) + ]; + return statements; + } + + private static string BuildRemovePolicySql(string qualifiedTableName, bool useLegacy) + { + if (useLegacy) + { + return $"SELECT remove_compression_policy({qualifiedTableName}, if_exists => true);"; + } + + return $"CALL remove_columnstore_policy({qualifiedTableName}, if_exists => true);"; + } + + private static string BuildAddPolicySql( + string tableName, + string schema, + string? after, + string? createdBefore, + string? scheduleInterval, + DateTime? initialStart, + string? timezone, + bool? ifNotExists, + bool useLegacy) + { + string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema); + + List args = [qualifiedTableName]; + + if (useLegacy) + { + if (!string.IsNullOrWhiteSpace(after)) + args.Add($"compress_after => {SqlBuilderHelper.IntervalOrBigint(after)}"); + else if (!string.IsNullOrWhiteSpace(createdBefore)) + args.Add($"compress_created_before => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(createdBefore)}'"); + } + else + { + if (!string.IsNullOrWhiteSpace(after)) + args.Add($"after => {SqlBuilderHelper.IntervalOrBigint(after)}"); + else if (!string.IsNullOrWhiteSpace(createdBefore)) + args.Add($"created_before => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(createdBefore)}'"); + } + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + args.Add($"schedule_interval => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(scheduleInterval)}'"); + + if (initialStart.HasValue) + { + args.Add($"initial_start => '{SqlBuilderHelper.FormatTimestamp(initialStart.Value)}'"); + } + + if (!string.IsNullOrWhiteSpace(timezone)) + args.Add($"timezone => '{SqlBuilderHelper.EscapeStringLiteral(timezone)}'"); + + if (ifNotExists == true) + args.Add("if_not_exists => true"); + + if (useLegacy) + { + return $"SELECT add_compression_policy({string.Join(", ", args)});"; + } + + return $"CALL add_columnstore_policy({string.Join(", ", args)});"; + } + } +} diff --git a/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs b/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs new file mode 100644 index 0000000..5994d81 --- /dev/null +++ b/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs @@ -0,0 +1,176 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + /// + /// Shared SQL-building helpers for compression settings, used by both the hypertable + /// and continuous-aggregate SQL generators. + /// + internal static class CompressionSettingsSqlHelper + { + /// + /// Quotes the column name within each ORDER BY clause entry while preserving + /// direction keywords and NULLS qualifiers. + /// + /// + /// "Timestamp DESC" becomes "\"Timestamp\" DESC". + /// + internal static string QuoteOrderByList(IEnumerable orderByClauses) + { + return string.Join(", ", orderByClauses.Select(clause => + { + string[] parts = clause.Split(' ', 2); + string col = parts[0]; + string suffix = parts.Length > 1 ? " " + parts[1] : ""; + + return SqlBuilderHelper.QuoteIdentifier(col) + suffix; + })); + } + + /// + /// Returns the option name for enabling compression/columnstore. + /// + internal static string CompressOptionName(bool useLegacy) + => useLegacy ? "timescaledb.compress" : "timescaledb.enable_columnstore"; + + /// + /// Returns the option name for the segment-by setting. + /// + internal static string SegmentByOptionName(bool useLegacy) + => useLegacy ? "timescaledb.compress_segmentby" : "timescaledb.segmentby"; + + /// + /// Returns the option name for the order-by setting. + /// + internal static string OrderByOptionName(bool useLegacy) + => useLegacy ? "timescaledb.compress_orderby" : "timescaledb.orderby"; + + /// + /// Appends a community-feature-guarded compression statement to + /// when compression is configured for a newly created relation (hypertable or materialized view). + /// + /// The statement list to append to. + /// The unqualified relation name. + /// The schema of the relation. + /// Whether the explicit compress flag is set. + /// Segment-by column list, or . + /// Order-by column list, or . + /// The DDL keyword phrase used to target the relation (e.g., "ALTER TABLE" or "ALTER MATERIALIZED VIEW"). + /// The RAISE WARNING text for the Apache Edition path. + /// When , emits pre-2.18 compression option names. + internal static void AppendCreateCompressionStatements( + List statements, + string relationName, + string schema, + bool enableCompression, + IReadOnlyList? compressionSegmentBy, + IReadOnlyList? compressionOrderBy, + string alterDdl, + string warningText, + bool useLegacy = false) + { + bool hasSegmentBy = compressionSegmentBy is { Count: > 0 }; + bool hasOrderBy = compressionOrderBy is { Count: > 0 }; + + if (!enableCompression && !hasSegmentBy && !hasOrderBy) + { + return; + } + + List compressionSettings = []; + + compressionSettings.Add($"{CompressOptionName(useLegacy)} = true"); + + if (hasSegmentBy) + { + string segmentList = string.Join(", ", compressionSegmentBy!.Select(SqlBuilderHelper.QuoteIdentifier)); + compressionSettings.Add($"{SegmentByOptionName(useLegacy)} = '{SqlBuilderHelper.EscapeStringLiteral(segmentList)}'"); + } + + if (hasOrderBy) + { + string orderList = QuoteOrderByList(compressionOrderBy!); + compressionSettings.Add($"{OrderByOptionName(useLegacy)} = '{SqlBuilderHelper.EscapeStringLiteral(orderList)}'"); + } + + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(relationName, schema); + string setClause = $"{alterDdl} {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"; + statements.Add(SqlBuilderHelper.WrapCommunityFeatures([setClause], warningText)); + } + + /// + /// Builds the list of changed compression SET options for an alter operation. + /// Returns an empty list when no compression properties changed. + /// + /// New value of the explicit compress flag. + /// New segment-by column list. + /// New order-by column list. + /// Previous value of the explicit compress flag. + /// Previous segment-by column list. + /// Previous order-by column list. + /// When , emits pre-2.18 compression option names. + internal static List BuildAlterCompressionSettings( + bool newEnable, + IReadOnlyList? newSegmentBy, + IReadOnlyList? newOrderBy, + bool oldEnable, + IReadOnlyList? oldSegmentBy, + IReadOnlyList? oldOrderBy, + bool useLegacy = false) + { + List settings = []; + + bool newCompressionState = IsCompressionEnabled(newEnable, newSegmentBy, newOrderBy); + bool oldCompressionState = IsCompressionEnabled(oldEnable, oldSegmentBy, oldOrderBy); + + if (newCompressionState != oldCompressionState) + { + settings.Add($"{CompressOptionName(useLegacy)} = {newCompressionState.ToString().ToLower()}"); + } + + if (!CompressionDiffHelper.AreStringListsEqual(oldSegmentBy, newSegmentBy)) + { + string val = (newSegmentBy?.Count > 0) + ? $"'{SqlBuilderHelper.EscapeStringLiteral(string.Join(", ", newSegmentBy.Select(SqlBuilderHelper.QuoteIdentifier)))}'" + : "''"; + settings.Add($"{SegmentByOptionName(useLegacy)} = {val}"); + } + + if (!CompressionDiffHelper.AreStringListsEqual(oldOrderBy, newOrderBy)) + { + string val = (newOrderBy?.Count > 0) + ? $"'{SqlBuilderHelper.EscapeStringLiteral(QuoteOrderByList(newOrderBy))}'" + : "''"; + settings.Add($"{OrderByOptionName(useLegacy)} = {val}"); + } + + return settings; + } + + /// + /// Returns when compression is configured by any supported mechanism: + /// an explicit enable flag, non-empty segment-by columns, non-empty order-by columns, + /// or non-empty chunk-skipping columns (which implicitly require compression). + /// + internal static bool IsCompressionEnabled( + bool enableFlag, + IReadOnlyList? segmentBy, + IReadOnlyList? orderBy, + IReadOnlyList? chunkSkipColumns = null) + => enableFlag || (segmentBy?.Count > 0) || (orderBy?.Count > 0) || (chunkSkipColumns?.Count > 0); + + /// + /// Annotation-value variant of + /// for callers holding the comma-joined string form the compression annotations store. + /// + internal static bool IsCompressionEnabled( + bool enableFlag, + string? segmentBy, + string? orderBy, + string? chunkSkipColumns = null) + => enableFlag + || !string.IsNullOrWhiteSpace(segmentBy) + || !string.IsNullOrWhiteSpace(orderBy) + || !string.IsNullOrWhiteSpace(chunkSkipColumns); + } +} diff --git a/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs index 4a411fc..23b7d36 100644 --- a/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs @@ -1,5 +1,4 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using System.Globalization; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -27,15 +26,9 @@ public static List Generate(AddContinuousAggregatePolicyOperation operat { arguments.Add("start_offset => NULL"); } - else if (int.TryParse(operation.StartOffset, out _)) - { - // Integer-based time column - arguments.Add($"start_offset => {operation.StartOffset}"); - } else { - // Interval string - arguments.Add($"start_offset => INTERVAL '{operation.StartOffset}'"); + arguments.Add($"start_offset => {SqlBuilderHelper.IntervalOrBigint(operation.StartOffset)}"); } // end_offset - NULL means latest data @@ -43,21 +36,15 @@ public static List Generate(AddContinuousAggregatePolicyOperation operat { arguments.Add("end_offset => NULL"); } - else if (int.TryParse(operation.EndOffset, out _)) - { - // Integer-based time column - arguments.Add($"end_offset => {operation.EndOffset}"); - } else { - // Interval string - arguments.Add($"end_offset => INTERVAL '{operation.EndOffset}'"); + arguments.Add($"end_offset => {SqlBuilderHelper.IntervalOrBigint(operation.EndOffset)}"); } // Optional parameters - only add if they differ from defaults if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval)) { - arguments.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); + arguments.Add($"schedule_interval => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(operation.ScheduleInterval)}'"); } if (operation.IfNotExists) @@ -87,9 +74,7 @@ public static List Generate(AddContinuousAggregatePolicyOperation operat if (operation.InitialStart.HasValue) { - // Use ISO 8601 format for timestamps to avoid ambiguity - string timestamp = operation.InitialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - arguments.Add($"initial_start => '{timestamp}'"); + arguments.Add($"initial_start => '{SqlBuilderHelper.FormatTimestamp(operation.InitialStart.Value)}'"); } string sql = $"SELECT add_continuous_aggregate_policy({string.Join(", ", arguments)});"; diff --git a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index 4fcdb5d..ed6407f 100644 --- a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -1,14 +1,16 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using System.Text; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { public class ContinuousAggregateSqlGenerator { - public static List Generate(CreateContinuousAggregateOperation operation) + private const string CommunityWarning = "Skipping Community Edition features (compression) - not available in Apache Edition"; + private const string AlterDdl = "ALTER MATERIALIZED VIEW"; + + public static List Generate(CreateContinuousAggregateOperation operation, bool useLegacyCompressionNames = false) { string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); - string parentQualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.ParentName, operation.Schema); List statements = []; @@ -23,40 +25,74 @@ public static List Generate(CreateContinuousAggregateOperation operation // Add optional chunk_interval if specified if (!string.IsNullOrEmpty(operation.ChunkInterval)) { - withOptions.Add($"timescaledb.chunk_interval = '{operation.ChunkInterval}'"); + withOptions.Add($"timescaledb.chunk_interval = '{SqlBuilderHelper.EscapeStringLiteral(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"); + return GenerateFromRawViewDefinition(operation, qualifiedIdentifier, withOptions, useLegacyCompressionNames); + } + + return GenerateFromStructuredQuery(operation, qualifiedIdentifier, withOptions, useLegacyCompressionNames); + } + + private static List GenerateFromRawViewDefinition( + CreateContinuousAggregateOperation operation, + string qualifiedIdentifier, + List withOptions, + bool useLegacyCompressionNames) + { + List statements = []; + + 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(';')); + if (operation.WithNoData) + { rawSqlBuilder.AppendLine(); - rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';')); - if (operation.WithNoData) - { - rawSqlBuilder.AppendLine(); - rawSqlBuilder.Append("WITH NO DATA"); - } - rawSqlBuilder.Append(';'); - statements.Add(rawSqlBuilder.ToString()); - return statements; + rawSqlBuilder.Append("WITH NO DATA"); } + rawSqlBuilder.Append(';'); + statements.Add(rawSqlBuilder.ToString()); + + CompressionSettingsSqlHelper.AppendCreateCompressionStatements( + statements, + operation.MaterializedViewName, + operation.Schema, + operation.EnableCompression, + operation.CompressionSegmentBy, + operation.CompressionOrderBy, + AlterDdl, + CommunityWarning, + useLegacyCompressionNames); + + return statements; + } + + private static List GenerateFromStructuredQuery( + CreateContinuousAggregateOperation operation, + string qualifiedIdentifier, + List withOptions, + bool useLegacyCompressionNames) + { + List statements = []; + string parentQualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.ParentName, operation.Schema); // Build the SELECT list List selectList = []; // Add time_bucket column string timeBucketColumn = $"{SqlBuilderHelper.QuoteIdentifier(operation.TimeBucketSourceColumn)}"; - string timeBucketWidthSql = $"'{operation.TimeBucketWidth}'"; + string timeBucketWidthSql = $"'{SqlBuilderHelper.EscapeStringLiteral(operation.TimeBucketWidth)}'"; selectList.Add($"time_bucket({timeBucketWidthSql}, {timeBucketColumn}) AS time_bucket"); // Add GROUP BY columns to SELECT (only actual columns, not SQL expressions) foreach (string groupByColumn in operation.GroupByColumns) { - // Check if it's a raw SQL expression or a column name bool isRawSqlExpression = groupByColumn.Contains(',') || groupByColumn.Contains('(') || groupByColumn.Contains(' '); if (!isRawSqlExpression) { @@ -70,7 +106,6 @@ public static List Generate(CreateContinuousAggregateOperation operation string[] parts = aggInfo.Split(':'); if (parts.Length != 3) { - // Skip malformed string continue; } @@ -105,17 +140,14 @@ public static List Generate(CreateContinuousAggregateOperation operation groupByList.Add("time_bucket"); } - // Add group by columns foreach (string groupByColumn in operation.GroupByColumns) { if (groupByColumn.Contains(',') || groupByColumn.Contains('(') || groupByColumn.Contains(' ')) { - // It's a raw SQL expression, use as-is groupByList.Add(groupByColumn); } else { - // It's a column name, quote it groupByList.Add($"{SqlBuilderHelper.QuoteIdentifier(groupByColumn)}"); } } @@ -130,22 +162,18 @@ public static List Generate(CreateContinuousAggregateOperation operation sqlBuilder.AppendLine(); sqlBuilder.Append($"FROM {parentQualifiedIdentifier}"); - // Add WHERE clause if specified if (!string.IsNullOrWhiteSpace(operation.WhereClause)) { - string whereClause = operation.WhereClause; sqlBuilder.AppendLine(); - sqlBuilder.Append($"WHERE {whereClause}"); + sqlBuilder.Append($"WHERE {operation.WhereClause}"); } - // Add GROUP BY clause if (groupByList.Count > 0) { sqlBuilder.AppendLine(); sqlBuilder.Append($"GROUP BY {string.Join(", ", groupByList)}"); } - // Add WITH [NO] DATA if (operation.WithNoData) { sqlBuilder.AppendLine(); @@ -155,10 +183,21 @@ public static List Generate(CreateContinuousAggregateOperation operation sqlBuilder.Append(';'); statements.Add(sqlBuilder.ToString()); + CompressionSettingsSqlHelper.AppendCreateCompressionStatements( + statements, + operation.MaterializedViewName, + operation.Schema, + operation.EnableCompression, + operation.CompressionSegmentBy, + operation.CompressionOrderBy, + AlterDdl, + CommunityWarning, + useLegacyCompressionNames); + return statements; } - public static List Generate(AlterContinuousAggregateOperation operation) + public static List Generate(AlterContinuousAggregateOperation operation, bool useLegacyCompressionNames = false) { string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); List statements = []; @@ -171,14 +210,14 @@ public static List Generate(AlterContinuousAggregateOperation operation) // We cannot RESET chunk_interval as TimescaleDB doesn't support it if (!string.IsNullOrEmpty(operation.ChunkInterval)) { - string chunkIntervalSql = $"'{operation.ChunkInterval}'"; + string chunkIntervalSql = $"'{SqlBuilderHelper.EscapeStringLiteral(operation.ChunkInterval)}'"; statements.Add($"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET (timescaledb.chunk_interval = {chunkIntervalSql});"); } else if (!string.IsNullOrEmpty(operation.OldChunkInterval)) { // Special case: If new value is null/empty but old value exists, // restore the old value instead of trying to RESET (which is unsupported) - string chunkIntervalSql = $"'{operation.OldChunkInterval}'"; + string chunkIntervalSql = $"'{SqlBuilderHelper.EscapeStringLiteral(operation.OldChunkInterval)}'"; statements.Add($"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET (timescaledb.chunk_interval = {chunkIntervalSql});"); } } @@ -197,6 +236,21 @@ public static List Generate(AlterContinuousAggregateOperation operation) statements.Add($"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET (timescaledb.materialized_only = {materializedOnlyValue});"); } + List compressionSettings = CompressionSettingsSqlHelper.BuildAlterCompressionSettings( + operation.EnableCompression, + operation.CompressionSegmentBy, + operation.CompressionOrderBy, + operation.OldEnableCompression, + operation.OldCompressionSegmentBy, + operation.OldCompressionOrderBy, + useLegacyCompressionNames); + + if (compressionSettings.Count > 0) + { + string setClause = $"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"; + statements.Add(SqlBuilderHelper.WrapCommunityFeatures([setClause], CommunityWarning)); + } + return statements; } diff --git a/src/Eftdb/Generators/HypertableSqlGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs index 3b401ee..afe81a3 100644 --- a/src/Eftdb/Generators/HypertableSqlGenerator.cs +++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using System.Text; @@ -6,7 +7,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { public class HypertableSqlGenerator { - public static List Generate(CreateHypertableOperation operation) + private const string CommunityWarning = "Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition"; + + public static List Generate(CreateHypertableOperation operation, bool useLegacyCompressionNames = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); @@ -15,19 +18,12 @@ public static List Generate(CreateHypertableOperation operation) List communityStatements = []; StringBuilder createHypertableCall = new(); - createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'"); + createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(operation.TimeColumnName)}'"); createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : ""); if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) { - if (long.TryParse(operation.ChunkTimeInterval, out _)) - { - createHypertableCall.Append($", chunk_time_interval => {operation.ChunkTimeInterval}::bigint"); - } - else - { - createHypertableCall.Append($", chunk_time_interval => INTERVAL '{operation.ChunkTimeInterval}'"); - } + createHypertableCall.Append($", chunk_time_interval => {SqlBuilderHelper.IntervalOrBigint(operation.ChunkTimeInterval)}"); } createHypertableCall.Append(");"); @@ -43,19 +39,29 @@ public static List Generate(CreateHypertableOperation operation) if (shouldEnableCompression) { - compressionSettings.Add("timescaledb.compress = true"); + compressionSettings.Add($"{CompressionSettingsSqlHelper.CompressOptionName(useLegacyCompressionNames)} = true"); } if (hasSegmentBy) { string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(SqlBuilderHelper.QuoteIdentifier)); - compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'"); + compressionSettings.Add($"{CompressionSettingsSqlHelper.SegmentByOptionName(useLegacyCompressionNames)} = '{SqlBuilderHelper.EscapeStringLiteral(segmentList)}'"); } if (hasOrderBy) { - string orderList = QuoteOrderByList(operation.CompressionOrderBy!); - compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'"); + string orderList = CompressionSettingsSqlHelper.QuoteOrderByList(operation.CompressionOrderBy!); + compressionSettings.Add($"{CompressionSettingsSqlHelper.OrderByOptionName(useLegacyCompressionNames)} = '{SqlBuilderHelper.EscapeStringLiteral(orderList)}'"); + } + + if (operation.CompressionSparseIndex != null) + { + compressionSettings.Add($"timescaledb.sparse_index = '{SqlBuilderHelper.EscapeStringLiteral(operation.CompressionSparseIndex)}'"); + } + + if (!string.IsNullOrEmpty(operation.CompressChunkTimeInterval)) + { + compressionSettings.Add($"timescaledb.compress_chunk_time_interval = '{SqlBuilderHelper.EscapeStringLiteral(operation.CompressChunkTimeInterval)}'"); } if (compressionSettings.Count > 0) @@ -69,7 +75,7 @@ public static List Generate(CreateHypertableOperation operation) foreach (string column in operation.ChunkSkipColumns) { - communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(column)}');"); } } @@ -79,28 +85,25 @@ public static List Generate(CreateHypertableOperation operation) { if (dimension.Type == EDimensionType.Range) { - bool isIntegerRange = long.TryParse(dimension.Interval, out _); - string intervalExpression = isIntegerRange - ? dimension.Interval! - : $"INTERVAL '{dimension.Interval}'"; + string intervalExpression = SqlBuilderHelper.IntervalOrBigint(dimension.Interval ?? string.Empty); - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{dimension.ColumnName}', {intervalExpression}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {intervalExpression}));"); } else if (dimension.Type == EDimensionType.Hash) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{dimension.ColumnName}', {dimension.NumberOfPartitions}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(dimension.ColumnName)}', {dimension.NumberOfPartitions}));"); } } } if (communityStatements.Count > 0) { - statements.Add(WrapCommunityFeatures(communityStatements)); + statements.Add(SqlBuilderHelper.WrapCommunityFeatures(communityStatements, CommunityWarning)); } return statements; } - public static List Generate(AlterHypertableOperation operation) + public static List Generate(AlterHypertableOperation operation, bool useLegacyCompressionNames = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); @@ -113,62 +116,95 @@ public static List Generate(AlterHypertableOperation operation) StringBuilder setChunkTimeInterval = new(); setChunkTimeInterval.Append($"SELECT set_chunk_time_interval({qualifiedTableName}, "); - if (long.TryParse(operation.ChunkTimeInterval, out _)) - { - setChunkTimeInterval.Append($"{operation.ChunkTimeInterval}::bigint"); - } - else - { - setChunkTimeInterval.Append($"INTERVAL '{operation.ChunkTimeInterval}'"); - } + setChunkTimeInterval.Append(SqlBuilderHelper.IntervalOrBigint(operation.ChunkTimeInterval)); setChunkTimeInterval.Append(");"); statements.Add(setChunkTimeInterval.ToString()); } - List compressionSettings = []; + ApplyCompressionChanges(operation, qualifiedIdentifier, communityStatements, useLegacyCompressionNames); + ApplyChunkSkippingChanges(operation, qualifiedTableName, communityStatements); + ApplyDimensionChanges(operation, qualifiedTableName, statements); - static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? newList) + if (communityStatements.Count > 0) { - return !(oldList ?? []).SequenceEqual(newList ?? []); + statements.Add(SqlBuilderHelper.WrapCommunityFeatures(communityStatements, CommunityWarning)); } + return statements; + } + + private static void ApplyCompressionChanges( + AlterHypertableOperation operation, + string qualifiedIdentifier, + List communityStatements, + bool useLegacyCompressionNames) + { + List compressionSettings = []; - bool newCompressionState = operation.EnableCompression - || (operation.ChunkSkipColumns?.Count > 0) - || (operation.CompressionSegmentBy?.Count > 0) - || (operation.CompressionOrderBy?.Count > 0); + bool newCompressionState = CompressionSettingsSqlHelper.IsCompressionEnabled( + operation.EnableCompression, operation.CompressionSegmentBy, operation.CompressionOrderBy, operation.ChunkSkipColumns); - bool oldCompressionState = operation.OldEnableCompression - || (operation.OldChunkSkipColumns?.Count > 0) - || (operation.OldCompressionSegmentBy?.Count > 0) - || (operation.OldCompressionOrderBy?.Count > 0); + bool oldCompressionState = CompressionSettingsSqlHelper.IsCompressionEnabled( + operation.OldEnableCompression, operation.OldCompressionSegmentBy, operation.OldCompressionOrderBy, operation.OldChunkSkipColumns); if (newCompressionState != oldCompressionState) { - compressionSettings.Add($"timescaledb.compress = {newCompressionState.ToString().ToLower()}"); + compressionSettings.Add($"{CompressionSettingsSqlHelper.CompressOptionName(useLegacyCompressionNames)} = {newCompressionState.ToString().ToLower()}"); } - if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy)) + if (!CompressionDiffHelper.AreStringListsEqual(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy)) { string val = (operation.CompressionSegmentBy?.Count > 0) - ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(SqlBuilderHelper.QuoteIdentifier))}'" + ? $"'{SqlBuilderHelper.EscapeStringLiteral(string.Join(", ", operation.CompressionSegmentBy.Select(SqlBuilderHelper.QuoteIdentifier)))}'" : "''"; - compressionSettings.Add($"timescaledb.compress_segmentby = {val}"); + compressionSettings.Add($"{CompressionSettingsSqlHelper.SegmentByOptionName(useLegacyCompressionNames)} = {val}"); } - if (ListsChanged(operation.OldCompressionOrderBy, operation.CompressionOrderBy)) + if (!CompressionDiffHelper.AreStringListsEqual(operation.OldCompressionOrderBy, operation.CompressionOrderBy)) { string val = (operation.CompressionOrderBy?.Count > 0) - ? $"'{QuoteOrderByList(operation.CompressionOrderBy)}'" + ? $"'{SqlBuilderHelper.EscapeStringLiteral(CompressionSettingsSqlHelper.QuoteOrderByList(operation.CompressionOrderBy))}'" : "''"; - compressionSettings.Add($"timescaledb.compress_orderby = {val}"); + compressionSettings.Add($"{CompressionSettingsSqlHelper.OrderByOptionName(useLegacyCompressionNames)} = {val}"); + } + + if (operation.CompressionSparseIndex != operation.OldCompressionSparseIndex) + { + if (operation.CompressionSparseIndex != null) + { + compressionSettings.Add($"timescaledb.sparse_index = '{SqlBuilderHelper.EscapeStringLiteral(operation.CompressionSparseIndex)}'"); + } + else + { + communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} RESET (timescaledb.sparse_index);"); + } + } + + if (operation.CompressChunkTimeInterval != operation.OldCompressChunkTimeInterval) + { + if (operation.CompressChunkTimeInterval != null) + { + compressionSettings.Add($"timescaledb.compress_chunk_time_interval = '{SqlBuilderHelper.EscapeStringLiteral(operation.CompressChunkTimeInterval)}'"); + } + else + { + // RESET is rejected for this option ("only columnstore options segmentby and + // orderby can be reset"); setting the interval to '0' clears it instead. + compressionSettings.Add("timescaledb.compress_chunk_time_interval = '0'"); + } } if (compressionSettings.Count > 0) { communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"); } + } + private static void ApplyChunkSkippingChanges( + AlterHypertableOperation operation, + string qualifiedTableName, + List communityStatements) + { IReadOnlyList newColumns = operation.ChunkSkipColumns ?? []; IReadOnlyList oldColumns = operation.OldChunkSkipColumns ?? []; List addedColumns = [.. newColumns.Except(oldColumns)]; @@ -179,7 +215,7 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? foreach (string column in addedColumns) { - communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(column)}');"); } } @@ -188,10 +224,16 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? { foreach (string column in removedColumns) { - communityStatements.Add($"SELECT disable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT disable_chunk_skipping({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(column)}');"); } } + } + private static void ApplyDimensionChanges( + AlterHypertableOperation operation, + string qualifiedTableName, + List statements) + { // TimescaleDB does NOT support removing dimensions from hypertables. // Once added, a dimension cannot be removed, so only additions are generated. IReadOnlyList newDimensions = operation.AdditionalDimensions ?? []; @@ -209,16 +251,13 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? { if (newDim.Type == EDimensionType.Range) { - bool isIntegerRange = long.TryParse(newDim.Interval, out _); - string intervalExpression = isIntegerRange - ? newDim.Interval! - : $"INTERVAL '{newDim.Interval}'"; + string intervalExpression = SqlBuilderHelper.IntervalOrBigint(newDim.Interval ?? string.Empty); - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{newDim.ColumnName}', {intervalExpression}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_range('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {intervalExpression}));"); } else if (newDim.Type == EDimensionType.Hash) { - statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{newDim.ColumnName}', {newDim.NumberOfPartitions}));"); + statements.Add($"SELECT add_dimension({qualifiedTableName}, by_hash('{SqlBuilderHelper.EscapeStringLiteral(newDim.ColumnName)}', {newDim.NumberOfPartitions}));"); } } } @@ -230,59 +269,9 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? if (removedDimensions.Count > 0) { - string dimensionList = string.Join(", ", removedDimensions.Select(d => $"'{d.ColumnName}'")); + string dimensionList = string.Join(", ", removedDimensions.Select(d => $"'{SqlBuilderHelper.EscapeStringLiteral(d.ColumnName)}'")); statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); } - - if (communityStatements.Count > 0) - { - statements.Add(WrapCommunityFeatures(communityStatements)); - } - return statements; - } - - /// - /// Wraps multiple SQL statements in a single license check block to ensure they only run on Community Edition. - /// - private static string WrapCommunityFeatures(List sqlStatements) - { - StringBuilder sb = new(); - sb.AppendLine("DO $$"); - sb.AppendLine("DECLARE"); - sb.AppendLine(" license TEXT;"); - sb.AppendLine("BEGIN"); - sb.AppendLine(" license := current_setting('timescaledb.license', true);"); - sb.AppendLine(" "); - sb.AppendLine(" IF license IS NULL OR license != 'apache' THEN"); - - foreach (string sql in sqlStatements) - { - string cleanSql = sql.TrimEnd(';').Replace("'", "''"); - sb.AppendLine($" EXECUTE '{cleanSql}';"); - } - - sb.AppendLine(" ELSE"); - sb.AppendLine(" RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';"); - sb.AppendLine(" END IF;"); - sb.AppendLine("END $$;"); - - return sb.ToString(); - } - - /// - /// Quotes the column name within an ORDER BY clause while preserving direction/nulls. - /// Example: Timestamp DESC -> "Timestamp" DESC - /// - private static string QuoteOrderByList(IEnumerable orderByClauses) - { - return string.Join(", ", orderByClauses.Select(clause => - { - string[] parts = clause.Split(' ', 2); - string col = parts[0]; - string suffix = parts.Length > 1 ? " " + parts[1] : ""; - - return SqlBuilderHelper.QuoteIdentifier(col) + suffix; - })); } } } diff --git a/src/Eftdb/Generators/PolicyJobSqlBuilder.cs b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs index 8e77e42..28788f0 100644 --- a/src/Eftdb/Generators/PolicyJobSqlBuilder.cs +++ b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs @@ -15,16 +15,16 @@ public static List BuildJobClauses(string? scheduleInterval, string? max List clauses = []; if (!string.IsNullOrWhiteSpace(scheduleInterval)) - clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'"); + clauses.Add($"schedule_interval => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(scheduleInterval)}'"); if (!string.IsNullOrWhiteSpace(maxRuntime)) - clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'"); + clauses.Add($"max_runtime => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(maxRuntime)}'"); if (maxRetries != null) clauses.Add($"max_retries => {maxRetries}"); if (!string.IsNullOrWhiteSpace(retryPeriod)) - clauses.Add($"retry_period => INTERVAL '{retryPeriod}'"); + clauses.Add($"retry_period => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(retryPeriod)}'"); return clauses; } @@ -42,16 +42,16 @@ public static List BuildChangedJobClauses( List clauses = []; if (!string.IsNullOrWhiteSpace(scheduleInterval) && scheduleInterval != oldScheduleInterval) - clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'"); + clauses.Add($"schedule_interval => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(scheduleInterval)}'"); if (!string.IsNullOrWhiteSpace(maxRuntime) && maxRuntime != oldMaxRuntime) - clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'"); + clauses.Add($"max_runtime => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(maxRuntime)}'"); if (maxRetries != null && maxRetries != oldMaxRetries) clauses.Add($"max_retries => {maxRetries}"); if (!string.IsNullOrWhiteSpace(retryPeriod) && retryPeriod != oldRetryPeriod) - clauses.Add($"retry_period => INTERVAL '{retryPeriod}'"); + clauses.Add($"retry_period => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(retryPeriod)}'"); return clauses; } @@ -62,10 +62,14 @@ public static List BuildChangedJobClauses( /// public static string BuildAlterJobSql(string tableName, string schema, string procName, IEnumerable clauses) { + string escapedProcName = SqlBuilderHelper.EscapeStringLiteral(procName); + string escapedSchema = SqlBuilderHelper.EscapeStringLiteral(schema); + string escapedTableName = SqlBuilderHelper.EscapeStringLiteral(tableName); + return $@" SELECT alter_job(job_id, {string.Join(", ", clauses)}) FROM timescaledb_information.jobs - WHERE proc_name = '{procName}' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim(); + WHERE proc_name = '{escapedProcName}' AND hypertable_schema = '{escapedSchema}' AND hypertable_name = '{escapedTableName}';".Trim(); } } } diff --git a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs index 151c7d5..85dea72 100644 --- a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs +++ b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs @@ -1,5 +1,4 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using System.Globalization; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -75,16 +74,14 @@ private static string BuildAddReorderPolicySql(string tableName, string schema, { string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema); - string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{indexName}'"; + string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{SqlBuilderHelper.EscapeStringLiteral(indexName)}'"; List optionalArgs = []; // Add optional arguments if they are provided if (initialStart.HasValue) { - // Use ISO 8601 format for timestamps to avoid ambiguity - string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - optionalArgs.Add($"initial_start => '{timestamp}'"); + optionalArgs.Add($"initial_start => '{SqlBuilderHelper.FormatTimestamp(initialStart.Value)}'"); } if (optionalArgs.Count > 0) diff --git a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs index d9e5d78..1d75c26 100644 --- a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs +++ b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs @@ -1,5 +1,4 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; -using System.Globalization; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -81,14 +80,13 @@ private static string BuildAddRetentionPolicySql(string tableName, string schema List args = []; if (!string.IsNullOrWhiteSpace(dropAfter)) - args.Add($"drop_after => INTERVAL '{dropAfter}'"); + args.Add($"drop_after => {SqlBuilderHelper.IntervalOrBigint(dropAfter)}"); else if (!string.IsNullOrWhiteSpace(dropCreatedBefore)) - args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'"); + args.Add($"drop_created_before => INTERVAL '{SqlBuilderHelper.EscapeStringLiteral(dropCreatedBefore)}'"); if (initialStart.HasValue) { - string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - args.Add($"initial_start => '{timestamp}'"); + args.Add($"initial_start => '{SqlBuilderHelper.FormatTimestamp(initialStart.Value)}'"); } return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});"; diff --git a/src/Eftdb/Generators/SqlBuilderHelper.cs b/src/Eftdb/Generators/SqlBuilderHelper.cs index 8f82aed..04cff20 100644 --- a/src/Eftdb/Generators/SqlBuilderHelper.cs +++ b/src/Eftdb/Generators/SqlBuilderHelper.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; +using System.Text; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -87,32 +87,9 @@ internal static string ReplaceSelectWithPerformMultiLine(string sql) return string.Join('\n', lines); } - public static void BuildQueryString(List statements, IndentedStringBuilder builder, bool suppressTransaction = false) - { - if (statements.Count > 0) - { - builder.AppendLine(".Sql(@\""); - using (builder.Indent()) - { - foreach (string statement in statements) - { - builder.AppendLines(statement, skipFinalNewline: false); - } - } - if (suppressTransaction) - { - builder.Append("\", suppressTransaction: true)"); - } - else - { - builder.Append("\")"); - } - } - } - public static string Regclass(string tableName, string schema = DefaultValues.DefaultSchema) { - return $"'{schema}.{quoteString}{tableName}{quoteString}'"; + return $"'{EscapeStringLiteral(schema)}.{quoteString}{EscapeStringLiteral(tableName)}{quoteString}'"; } public static string QualifiedIdentifier(string tableName, string schema = DefaultValues.DefaultSchema) @@ -125,5 +102,66 @@ public static string QualifiedIdentifier(string tableName, string schema = Defau /// to quote column references in compression segment/order-by lists, group-by clauses, etc. /// public static string QuoteIdentifier(string identifier) => $"\"{identifier}\""; + + /// + /// Escapes a value for safe embedding inside a PostgreSQL single-quoted string literal + /// by doubling any single-quote characters it contains. + /// + /// The raw value to escape. + /// The value with every ' replaced by ''. + public static string EscapeStringLiteral(string value) => value.Replace("'", "''"); + + /// + /// Formats a time value that follows the hypertable's time-column type: plain numeric + /// strings target integer time columns and are emitted with an explicit ::bigint + /// cast, all other values are emitted as an escaped INTERVAL literal. + /// + /// The raw interval string or numeric string. + /// A SQL fragment: {value}::bigint or INTERVAL '{value}'. + public static string IntervalOrBigint(string value) + => long.TryParse(value, out _) + ? $"{value}::bigint" + : $"INTERVAL '{EscapeStringLiteral(value)}'"; + + /// + /// Formats a as an ISO 8601 UTC string for use in PostgreSQL + /// timestamp literals, converting to UTC first to avoid ambiguity. + /// + /// The timestamp to format. + /// An ISO 8601 string in UTC, suitable for embedding in a PostgreSQL string literal. + public static string FormatTimestamp(DateTime value) + => value.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture); + + /// + /// Wraps SQL statements in a Community Edition license-guard DO block. Statements execute + /// only when the TimescaleDB license is not apache; otherwise the supplied warning + /// is raised and the block exits without executing them. + /// + /// The statements to execute inside the guarded block. + /// The text of the RAISE WARNING emitted on the Apache Edition path. + internal static string WrapCommunityFeatures(List sqlStatements, string warningText) + { + StringBuilder sb = new(); + sb.AppendLine("DO $$"); + sb.AppendLine("DECLARE"); + sb.AppendLine(" license TEXT;"); + sb.AppendLine("BEGIN"); + sb.AppendLine(" license := current_setting('timescaledb.license', true);"); + sb.AppendLine(" "); + sb.AppendLine(" IF license IS NULL OR license != 'apache' THEN"); + + foreach (string sql in sqlStatements) + { + string cleanSql = EscapeStringLiteral(sql.TrimEnd(';')); + sb.AppendLine($" EXECUTE '{cleanSql}';"); + } + + sb.AppendLine(" ELSE"); + sb.AppendLine($" RAISE WARNING '{EscapeStringLiteral(warningText)}';"); + sb.AppendLine(" END IF;"); + sb.AppendLine("END $$;"); + + return sb.ToString(); + } } } diff --git a/src/Eftdb/Internals/CompressionAnnotationExtractor.cs b/src/Eftdb/Internals/CompressionAnnotationExtractor.cs new file mode 100644 index 0000000..11be5fb --- /dev/null +++ b/src/Eftdb/Internals/CompressionAnnotationExtractor.cs @@ -0,0 +1,195 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals +{ + /// + /// Shared helpers for extracting compression segment-by and order-by column lists from + /// entity-type annotations, used by both the hypertable and continuous-aggregate model extractors. + /// + internal static class CompressionAnnotationExtractor + { + /// + /// Extracts and resolves compress_segmentby column names from the entity's annotations, + /// mapping CLR property names to their database column names via the supplied store identifier. + /// Returns when the annotation is absent or empty. + /// + internal static List? ExtractSegmentByColumns(IEntityType entityType, StoreObjectIdentifier storeIdentifier) + { + string? segmentByString = entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + if (string.IsNullOrWhiteSpace(segmentByString)) + { + return null; + } + + List result = []; + foreach (string token in segmentByString.Split(',', StringSplitOptions.TrimEntries)) + { + string resolved = ResolveColumnName(entityType, storeIdentifier, token); + if (!string.IsNullOrEmpty(resolved)) + { + result.Add(resolved); + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Extracts and resolves compress_orderby column expressions from the entity's annotations, + /// mapping the leading CLR property name in each clause to its database column name while preserving + /// direction/nulls suffixes (e.g., "Timestamp DESC NULLS LAST"). + /// Returns when the annotation is absent or empty. + /// + internal static List? ExtractOrderByColumns(IEntityType entityType, StoreObjectIdentifier storeIdentifier) + { + string? orderByString = entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + if (string.IsNullOrWhiteSpace(orderByString)) + { + return null; + } + + List result = []; + foreach (string clause in orderByString.Split(',', StringSplitOptions.TrimEntries)) + { + // Split by first space to isolate the property/column name from direction suffixes. + string[] parts = clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + continue; + } + + string columnName = ResolveColumnName(entityType, storeIdentifier, parts[0]); + if (!string.IsNullOrEmpty(columnName)) + { + result.Add(parts.Length > 1 ? $"{columnName} {parts[1]}" : columnName); + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Extracts and resolves timescaledb.sparse_index entries from the entity's annotations. + /// + internal static string? ExtractSparseIndex(IEntityType entityType, StoreObjectIdentifier storeIdentifier) + { + IAnnotation? annotation = entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex); + if (annotation == null) + { + return null; + } + + string raw = annotation.Value as string ?? string.Empty; + + if (raw.Length == 0) + { + return string.Empty; + } + + List canonicalEntries = []; + foreach (string entry in SplitSparseIndexEntries(raw)) + { + string trimmed = entry.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + int parenOpen = trimmed.IndexOf('('); + int parenClose = trimmed.LastIndexOf(')'); + + if (parenOpen < 0 || parenClose < parenOpen) + { + canonicalEntries.Add(trimmed); + continue; + } + + string funcName = trimmed[..parenOpen].Trim(); + string argsPart = trimmed[(parenOpen + 1)..parenClose]; + + List resolvedColumns = []; + foreach (string col in argsPart.Split(',', StringSplitOptions.TrimEntries)) + { + if (col.Length > 0) + { + resolvedColumns.Add(ResolveColumnName(entityType, storeIdentifier, col)); + } + } + + canonicalEntries.Add($"{funcName}({string.Join(",", resolvedColumns)})"); + } + + return string.Join(", ", canonicalEntries); + } + + /// + /// Splits a sparse-index annotation value into individual entries using paren-aware splitting. + /// A top-level comma (one not inside parentheses) separates entries. + /// + internal static IEnumerable SplitSparseIndexEntries(string value) + { + int depth = 0; + int start = 0; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + } + else if (c == ',' && depth == 0) + { + yield return value[start..i]; + start = i + 1; + } + } + + if (start < value.Length) + { + yield return value[start..]; + } + } + + /// + /// Resolves a CLR property name or database column name to the database column name. + /// Falls back to a case-insensitive property and column lookup, then to the input string + /// when no match is found. Shared by extractors and validation conventions so that both + /// resolve references identically. + /// + internal static string ResolveColumnName(IEntityType entityType, StoreObjectIdentifier storeIdentifier, string propertyName) + { + string? exact = entityType.FindProperty(propertyName)?.GetColumnName(storeIdentifier); + if (!string.IsNullOrEmpty(exact)) + { + return exact; + } + + foreach (IProperty property in entityType.GetProperties()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + string? resolved = property.GetColumnName(storeIdentifier); + if (!string.IsNullOrEmpty(resolved)) + { + return resolved; + } + } + + string? columnName = property.GetColumnName(storeIdentifier); + if (string.Equals(columnName, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return columnName!; + } + } + + return propertyName; + } + } +} diff --git a/src/Eftdb/Internals/ExpressionHelper.cs b/src/Eftdb/Internals/ExpressionHelper.cs new file mode 100644 index 0000000..2651569 --- /dev/null +++ b/src/Eftdb/Internals/ExpressionHelper.cs @@ -0,0 +1,30 @@ +using System.Linq.Expressions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals +{ + /// + /// Shared helper for extracting CLR property names from selector lambda expressions. + /// + internal static class ExpressionHelper + { + /// + /// Extracts the property name from a simple property access expression, + /// unwrapping boxing conversions produced by object-typed selectors. + /// + /// Thrown when the expression is not a simple property access. + internal static string GetPropertyName(Expression> propertyExpression) + { + if (propertyExpression.Body is MemberExpression memberExpression) + { + return memberExpression.Member.Name; + } + + if (propertyExpression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression unaryMemberExpression) + { + return unaryMemberExpression.Member.Name; + } + + throw new ArgumentException("Expression must be a simple property access expression.", nameof(propertyExpression)); + } + } +} diff --git a/src/Eftdb/Internals/Features/CompressionDiffHelper.cs b/src/Eftdb/Internals/Features/CompressionDiffHelper.cs new file mode 100644 index 0000000..e74f69d --- /dev/null +++ b/src/Eftdb/Internals/Features/CompressionDiffHelper.cs @@ -0,0 +1,95 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features +{ + /// + /// Shared comparison and rewrite helpers for compression-related differ logic, + /// used by both the hypertable and continuous-aggregate feature differs. + /// + internal static class CompressionDiffHelper + { + /// + /// Returns when both lists are equal in content and order. + /// Treats as an empty list. + /// + internal static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) + => (list1 ?? []).SequenceEqual(list2 ?? []); + + /// + /// Compares two compression ORDER BY lists, treating an implicit direction as ASC + /// so that "col" and "col ASC" compare equal. + /// + internal static bool AreOrderByListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) + { + IReadOnlyList l1 = list1 ?? []; + IReadOnlyList l2 = list2 ?? []; + + if (l1.Count != l2.Count) + { + return false; + } + + return l1.Zip(l2).All(pair => NormalizeOrderByEntry(pair.First) == NormalizeOrderByEntry(pair.Second)); + } + + /// + /// Normalizes a compression ORDER BY entry to its canonical form with an explicit direction + /// keyword immediately after the column name. + /// + /// + /// + /// "Value""Value ASC" + /// "Value ASC""Value ASC" + /// "Value DESC""Value DESC" + /// "Value NULLS FIRST""Value ASC NULLS FIRST" + /// "Value ASC NULLS FIRST""Value ASC NULLS FIRST" + /// + /// + internal static string NormalizeOrderByEntry(string entry) + { + string trimmed = entry.Trim(); + int spaceIndex = trimmed.IndexOf(' '); + + if (spaceIndex < 0) + { + return trimmed + " ASC"; + } + + string columnPart = trimmed[..spaceIndex]; + string suffix = trimmed[(spaceIndex + 1)..].TrimStart(); + + if (suffix.StartsWith("DESC", StringComparison.OrdinalIgnoreCase) + || suffix.StartsWith("ASC", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + return $"{columnPart} ASC {suffix}"; + } + + /// + /// Rewrites plain column names through the rename context. + /// Returns when is . + /// + internal static List? RewriteColumns(IReadOnlyList? columns, string schema, string relation, FeatureDiffContext context) + => columns?.Select(c => context.ResolveColumn(schema, relation, c)).ToList(); + + /// + /// Rewrites the leading column name of each ORDER BY entry through the rename context, + /// preserving any direction/nulls suffix. + /// Returns when is . + /// + internal static List? RewriteOrderByColumns(IReadOnlyList? columns, string schema, string relation, FeatureDiffContext context) + { + return columns?.Select(c => + { + string[] parts = c.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return c; + } + + string column = context.ResolveColumn(schema, relation, parts[0]); + return parts.Length > 1 ? $"{column} {parts[1]}" : column; + }).ToList(); + } + } +} diff --git a/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDefaultHelper.cs b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDefaultHelper.cs new file mode 100644 index 0000000..fc21548 --- /dev/null +++ b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDefaultHelper.cs @@ -0,0 +1,196 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies +{ + /// + /// Computes the TimescaleDB default schedule_interval for add_compression_policy() + /// based on the hypertable's chunk time interval. + /// + /// + /// TimescaleDB applies one of two rules: + /// + /// chunk_time_interval ≥ 1 day → default is "12 hours" + /// chunk_time_interval < 1 day → default is chunk_time_interval / 2 + /// + /// Integer-time hypertables (bigint chunk intervals stored as raw integer strings) cannot be + /// halved meaningfully here, so they fall back to null (treat the value as explicitly configured). + /// + internal static class CompressionPolicyDefaultHelper + { + private const long MicrosecondsPerSecond = 1_000_000L; + private const long MicrosecondsPerMinute = 60L * MicrosecondsPerSecond; + private const long MicrosecondsPerHour = 3_600L * MicrosecondsPerSecond; + private const long MicrosecondsPerDay = 86_400L * MicrosecondsPerSecond; + + /// + /// Returns the expected default schedule_interval for add_compression_policy() + /// for a hypertable with the given chunk time interval, or when the + /// chunk interval cannot be parsed (e.g., integer-time hypertable or composite form). + /// + internal static string? ComputeDefaultScheduleInterval(string? chunkTimeInterval) + { + if (string.IsNullOrWhiteSpace(chunkTimeInterval)) + { + return DefaultValues.CompressionPolicyScheduleInterval; + } + + if (!TryGetTotalMicroseconds(chunkTimeInterval, out long chunkMicroseconds)) + { + return null; + } + + if (chunkMicroseconds >= MicrosecondsPerDay) + { + return DefaultValues.CompressionPolicyScheduleInterval; + } + + long halfMicroseconds = chunkMicroseconds / 2; + return MicrosecondsToInterval(halfMicroseconds); + } + + /// + /// Converts a total microsecond count to a canonical interval string. + /// Only handles fixed sub-day durations (hours, minutes, seconds). + /// Returns for zero or non-representable values. + /// + private static string? MicrosecondsToInterval(long microseconds) + { + if (microseconds <= 0) + { + return null; + } + + if (microseconds % MicrosecondsPerHour == 0) + { + long hours = microseconds / MicrosecondsPerHour; + return hours == 1 ? "1 hour" : $"{hours} hours"; + } + + if (microseconds % MicrosecondsPerMinute == 0) + { + long minutes = microseconds / MicrosecondsPerMinute; + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + + if (microseconds % MicrosecondsPerSecond == 0) + { + long seconds = microseconds / MicrosecondsPerSecond; + return seconds == 1 ? "1 second" : $"{seconds} seconds"; + } + + return null; + } + + /// + /// Converts a fixed-duration interval string to its total length in microseconds. + /// Returns for calendar units (month, year), composite forms + /// ("2 days 03:00:00"), bare integers, and anything else without a fixed duration. + /// + private static bool TryGetTotalMicroseconds(string interval, out long microseconds) + { + microseconds = 0; + if (string.IsNullOrWhiteSpace(interval)) + { + return false; + } + + string value = interval.Trim(); + + if (value.Contains(':')) + { + if (!TryParseTimeParts(value, out long days, out long hours, out long minutes, out long seconds, out long fractionMicroseconds)) + { + return false; + } + + microseconds = (((((days * 24) + hours) * 60) + minutes) * 60 + seconds) * MicrosecondsPerSecond + fractionMicroseconds; + return true; + } + + int spaceIndex = value.IndexOf(' '); + if (spaceIndex <= 0) + { + return false; + } + + string numberPart = value[..spaceIndex]; + string unitPart = value[(spaceIndex + 1)..]; + + if (!long.TryParse(numberPart, out long amount)) + { + return false; + } + + long? unitMicroseconds = unitPart.ToLowerInvariant() switch + { + "us" or "usec" or "usecs" or "microsecond" or "microseconds" => 1L, + "ms" or "msec" or "msecs" or "millisecond" or "milliseconds" => 1_000L, + "s" or "sec" or "secs" or "second" or "seconds" => MicrosecondsPerSecond, + "min" or "mins" or "minute" or "minutes" => MicrosecondsPerMinute, + "h" or "hr" or "hrs" or "hour" or "hours" => MicrosecondsPerHour, + "d" or "day" or "days" => MicrosecondsPerDay, + "w" or "week" or "weeks" => 7L * MicrosecondsPerDay, + _ => null, + }; + + if (unitMicroseconds is null) + { + return false; + } + + microseconds = amount * unitMicroseconds.Value; + return true; + } + + /// + /// Parses a [D.]H+:MM:SS[.ffffff] value into its components. + /// + private static bool TryParseTimeParts(string value, out long days, out long hours, out long minutes, out long seconds, out long fractionMicroseconds) + { + days = hours = minutes = seconds = fractionMicroseconds = 0; + + string[] parts = value.Split(':'); + if (parts.Length != 3) + { + return false; + } + + string hourPart = parts[0]; + int dayDot = hourPart.IndexOf('.'); + if (dayDot >= 0) + { + if (!long.TryParse(hourPart[..dayDot], out days) || days < 0) + { + return false; + } + + hourPart = hourPart[(dayDot + 1)..]; + } + + if (!long.TryParse(hourPart, out hours) || hours < 0 || (dayDot >= 0 && hours > 23)) + { + return false; + } + + if (!long.TryParse(parts[1], out minutes) || minutes is < 0 or >= 60) + { + return false; + } + + string secondsPart = parts[2]; + int secondsDot = secondsPart.IndexOf('.'); + if (secondsDot >= 0) + { + string fraction = secondsPart[(secondsDot + 1)..]; + if (fraction.Length == 0 || !fraction.All(char.IsAsciiDigit)) + { + return false; + } + + string padded = fraction.Length >= 6 ? fraction[..6] : fraction.PadRight(6, '0'); + fractionMicroseconds = long.Parse(padded); + secondsPart = secondsPart[..secondsDot]; + } + + return long.TryParse(secondsPart, out seconds) && seconds is >= 0 and < 60; + } + } +} diff --git a/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs new file mode 100644 index 0000000..0f75b44 --- /dev/null +++ b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs @@ -0,0 +1,140 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies +{ + /// + /// Compares compression policy annotations between source and target EF Core models and + /// produces the migration operations required to bring the source state to the target state. + /// + /// + /// This differ is purely annotation-driven and never reads from the database. It operates + /// exclusively on EF metadata supplied by . + /// Server-side policies that were not modelled in EF Core (including any auto-created default + /// policies that a future TimescaleDB version might generate) are invisible to this differ and + /// never produce spurious migration operations. + /// + public class CompressionPolicyDiffer : IFeatureDiffer + { + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null) + { + context ??= FeatureDiffContext.Empty; + + List operations = []; + + // Apply table renames to the source so a rename isn't seen as a drop-and-add. + List sourceEntries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(source).Select(e => RewriteSourceEntry(e, context))]; + List targetEntries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(target)]; + + // Identify new compression policies + IEnumerable newPolicies = targetEntries + .Where(t => !sourceEntries.Any(s => s.Operation.Schema == t.Operation.Schema && s.Operation.TableName == t.Operation.TableName)) + .Select(e => e.Operation); + operations.AddRange(newPolicies); + + // Identify updated compression policies + var updatedPolicies = targetEntries + .Join( + sourceEntries, + t => (t.Operation.Schema, t.Operation.TableName), + s => (s.Operation.Schema, s.Operation.TableName), + (t, s) => new { Target = t, Source = s } + ) + .Where(x => + x.Target.Operation.After != x.Source.Operation.After || + x.Target.Operation.CreatedBefore != x.Source.Operation.CreatedBefore || + ScheduleIntervalChanged(x.Source, x.Target) || + x.Target.Operation.InitialStart != x.Source.Operation.InitialStart || + x.Target.Operation.Timezone != x.Source.Operation.Timezone + ); + + foreach (var policy in updatedPolicies) + { + operations.Add(new AlterCompressionPolicyOperation + { + TableName = policy.Target.Operation.TableName, + Schema = policy.Target.Operation.Schema, + After = policy.Target.Operation.After, + CreatedBefore = policy.Target.Operation.CreatedBefore, + ScheduleInterval = policy.Target.Operation.ScheduleInterval, + InitialStart = policy.Target.Operation.InitialStart, + Timezone = policy.Target.Operation.Timezone, + IfNotExists = policy.Target.Operation.IfNotExists, + + OldAfter = policy.Source.Operation.After, + OldCreatedBefore = policy.Source.Operation.CreatedBefore, + OldScheduleInterval = policy.Source.Operation.ScheduleInterval, + OldInitialStart = policy.Source.Operation.InitialStart, + OldTimezone = policy.Source.Operation.Timezone, + OldIfNotExists = policy.Source.Operation.IfNotExists, + }); + } + + // Identify removed compression policies + IEnumerable removedPolicies = sourceEntries + .Where(s => !targetEntries.Any(t => t.Operation.TableName == s.Operation.TableName && t.Operation.Schema == s.Operation.Schema)) + .Select(e => new DropCompressionPolicyOperation { TableName = e.Operation.TableName, Schema = e.Operation.Schema }); + operations.AddRange(removedPolicies); + + return operations; + } + + /// + /// Returns true when the schedule interval genuinely changed between models. + /// A null (not configured) and the TimescaleDB-computed default for the hypertable's chunk + /// time interval are treated as equivalent: the database already runs at that cadence and + /// emitting an alter would produce a phantom migration. Each side uses its own chunk interval + /// to compute its expected default, so a change in chunk interval alone does not suppress a + /// real schedule-interval difference. + /// + private static bool ScheduleIntervalChanged( + CompressionPolicyModelExtractor.CompressionPolicyEntry source, + CompressionPolicyModelExtractor.CompressionPolicyEntry target) + { + string? sourceInterval = source.Operation.ScheduleInterval; + string? targetInterval = target.Operation.ScheduleInterval; + + if (sourceInterval == targetInterval) + { + return false; + } + + string? sourceDefault = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(source.ChunkTimeInterval); + string? targetDefault = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(target.ChunkTimeInterval); + + string? normSource = sourceDefault != null && sourceInterval == sourceDefault ? null : sourceInterval; + string? normTarget = targetDefault != null && targetInterval == targetDefault ? null : targetInterval; + + return normSource != normTarget; + } + + /// + /// Produces a copy of a source compression policy entry with its table and schema rewritten + /// through the table-rename map, so that a pure rename compares equal to its target and + /// produces no operation. + /// + private static CompressionPolicyModelExtractor.CompressionPolicyEntry RewriteSourceEntry( + CompressionPolicyModelExtractor.CompressionPolicyEntry entry, + FeatureDiffContext context) + { + (string schema, string tableName) = context.ResolveTable(entry.Operation.Schema, entry.Operation.TableName); + + AddCompressionPolicyOperation rewritten = new() + { + TableName = tableName, + Schema = schema, + After = entry.Operation.After, + CreatedBefore = entry.Operation.CreatedBefore, + ScheduleInterval = entry.Operation.ScheduleInterval, + InitialStart = entry.Operation.InitialStart, + Timezone = entry.Operation.Timezone, + IfNotExists = entry.Operation.IfNotExists, + }; + + return new CompressionPolicyModelExtractor.CompressionPolicyEntry(rewritten, entry.ChunkTimeInterval); + } + } +} diff --git a/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyModelExtractor.cs b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyModelExtractor.cs new file mode 100644 index 0000000..b1c4f84 --- /dev/null +++ b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyModelExtractor.cs @@ -0,0 +1,70 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies +{ + public static class CompressionPolicyModelExtractor + { + /// + /// Pairs a compression policy operation with the chunk time interval of its owning hypertable, + /// so the differ can compute the correct default schedule interval per-table. + /// + internal record CompressionPolicyEntry(AddCompressionPolicyOperation Operation, string ChunkTimeInterval); + + /// + /// Returns all compression policy entries from the model, each paired with the owning + /// hypertable's chunk time interval. + /// + internal static IEnumerable GetCompressionPolicyEntries(IRelationalModel? relationalModel) + { + if (relationalModel == null) + { + yield break; + } + + foreach (IEntityType entityType in relationalModel.Model.GetEntityTypes()) + { + bool hasCompressionPolicy = entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value as bool? ?? false; + if (!hasCompressionPolicy) + { + continue; + } + + string? after = entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value as string; + string? createdBefore = entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)?.Value as string; + + if (string.IsNullOrWhiteSpace(after) && string.IsNullOrWhiteSpace(createdBefore)) + { + continue; + } + + string? targetName = entityType.GetTableName() ?? entityType.GetViewName(); + if (string.IsNullOrWhiteSpace(targetName)) + { + continue; + } + + DateTime? initialStart = entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)?.Value as DateTime?; + string chunkTimeInterval = entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value as string + ?? DefaultValues.ChunkTimeInterval; + + AddCompressionPolicyOperation operation = new() + { + TableName = targetName, + Schema = entityType.GetSchema() ?? entityType.GetViewSchema() ?? DefaultValues.DefaultSchema, + After = after, + CreatedBefore = createdBefore, + InitialStart = initialStart, + ScheduleInterval = entityType.FindAnnotation(CompressionPolicyAnnotations.ScheduleInterval)?.Value as string, + Timezone = entityType.FindAnnotation(CompressionPolicyAnnotations.Timezone)?.Value as string, + IfNotExists = entityType.FindAnnotation(CompressionPolicyAnnotations.IfNotExists)?.Value as bool?, + }; + + yield return new CompressionPolicyEntry(operation, chunkTimeInterval); + } + } + } +} diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs index fbb3219..3c2d806 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -15,10 +15,11 @@ public IReadOnlyList GetDifferences(IRelationalModel? source List sourceAggregates = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(source)]; List targetAggregates = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(target)]; - // Apply the parent table's rename so a renamed parent alone doesn't force a recreate. foreach (CreateContinuousAggregateOperation aggregate in sourceAggregates) { (_, aggregate.ParentName) = context.ResolveTable(aggregate.Schema, aggregate.ParentName); + aggregate.CompressionSegmentBy = CompressionDiffHelper.RewriteColumns(aggregate.CompressionSegmentBy, aggregate.Schema, aggregate.MaterializedViewName, context); + aggregate.CompressionOrderBy = CompressionDiffHelper.RewriteOrderByColumns(aggregate.CompressionOrderBy, aggregate.Schema, aggregate.MaterializedViewName, context); } // Find new continuous aggregates - only compare by MaterializedViewName, not Schema @@ -27,8 +28,9 @@ public IReadOnlyList GetDifferences(IRelationalModel? source operations.AddRange(newAggregates); // Find updated continuous aggregates - // Note: Only certain properties can be altered (ChunkInterval, CreateGroupIndexes, MaterializedOnly) - // For structural changes (time bucket, aggregates, group by, where), drop and recreate is required + // Note: Only certain properties can be altered (ChunkInterval, CreateGroupIndexes, + // MaterializedOnly, and compression settings). + // For structural changes (time bucket, aggregates, group by, where), drop and recreate is required. var updatedAggregates = targetAggregates .Join( sourceAggregates, @@ -39,7 +41,10 @@ public IReadOnlyList GetDifferences(IRelationalModel? source .Where(x => x.Target.ChunkInterval != x.Source.ChunkInterval || x.Target.CreateGroupIndexes != x.Source.CreateGroupIndexes || - x.Target.MaterializedOnly != x.Source.MaterializedOnly + x.Target.MaterializedOnly != x.Source.MaterializedOnly || + x.Target.EnableCompression != x.Source.EnableCompression || + !CompressionDiffHelper.AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || + !CompressionDiffHelper.AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) ); foreach (var aggregate in updatedAggregates) @@ -51,9 +56,15 @@ public IReadOnlyList GetDifferences(IRelationalModel? source ChunkInterval = aggregate.Target.ChunkInterval, CreateGroupIndexes = aggregate.Target.CreateGroupIndexes, MaterializedOnly = aggregate.Target.MaterializedOnly, + EnableCompression = aggregate.Target.EnableCompression, + CompressionSegmentBy = aggregate.Target.CompressionSegmentBy, + CompressionOrderBy = aggregate.Target.CompressionOrderBy, OldChunkInterval = aggregate.Source.ChunkInterval, OldCreateGroupIndexes = aggregate.Source.CreateGroupIndexes, - OldMaterializedOnly = aggregate.Source.MaterializedOnly + OldMaterializedOnly = aggregate.Source.MaterializedOnly, + OldEnableCompression = aggregate.Source.EnableCompression, + OldCompressionSegmentBy = aggregate.Source.CompressionSegmentBy, + OldCompressionOrderBy = aggregate.Source.CompressionOrderBy, }); } diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs index 95a5f65..524b3d5 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs @@ -1,4 +1,5 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -80,70 +81,8 @@ public static IEnumerable GetContinuousAggre bool materializedOnly = entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedOnly)?.Value as bool? ?? false; string? whereClause = entityType.FindAnnotation(ContinuousAggregateAnnotations.WhereClause)?.Value as string; - // Process aggregate functions - convert model property names to database column names - List aggregateFunctions = []; - IAnnotation? aggregateFunctionsAnnotation = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions); - if (aggregateFunctionsAnnotation?.Value is List modelAggregateFunctions) - { - foreach (string aggInfo in modelAggregateFunctions) - { - string[] parts = aggInfo.Split(':'); - if (parts.Length != 3) - { - // Skip malformed string - continue; - } - - string aliasModelName = parts[0]; - string functionEnumString = parts[1]; - string sourceColumnModelName = parts[2]; - - // Resolve source column name from parent entity. "*" is not a column - // but the COUNT(*) wildcard, so it bypasses resolution. - string? sourceColumnDbName = sourceColumnModelName == "*" - ? "*" - : ColumnNameResolver.Resolve(parentEntityType, sourceColumnModelName, parentStoreIdentifier); - if (string.IsNullOrWhiteSpace(sourceColumnDbName)) - { - // Skip if source column not found - continue; - } - - // Resolve alias column name from aggregate entity to respect naming conventions - string? aliasDbName = entityType.FindProperty(aliasModelName)?.GetColumnName(aggregateStoreIdentifier); - if (string.IsNullOrWhiteSpace(aliasDbName)) - { - // Fallback to model name if property not found in aggregate entity - aliasDbName = aliasModelName; - } - - aggregateFunctions.Add($"{aliasDbName}:{functionEnumString}:{sourceColumnDbName}"); - } - } - - // Process group by columns - convert model property names to database column names - // Note: Some group by columns might be raw SQL expressions (e.g., "1, 2"), not property names - List groupByColumns = []; - IAnnotation? groupByColumnsAnnotation = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns); - if (groupByColumnsAnnotation?.Value is List modelGroupByColumns) - { - foreach (string modelColumn in modelGroupByColumns) - { - // Try to resolve as a property name from the parent entity - string? dbColumnName = parentEntityType.FindProperty(modelColumn)?.GetColumnName(parentStoreIdentifier); - - if (!string.IsNullOrWhiteSpace(dbColumnName)) - { - // It's a property name, use the resolved database column name - groupByColumns.Add(dbColumnName); - } - else - { - // It's not a property, assume it's a raw SQL expression and use as-is - groupByColumns.Add(modelColumn); - } - } - } + List aggregateFunctions = ResolveAggregateFunctions(entityType, parentEntityType, parentStoreIdentifier, aggregateStoreIdentifier); + List groupByColumns = ResolveGroupByColumns(entityType, parentEntityType, parentStoreIdentifier); // 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. @@ -152,6 +91,10 @@ public static IEnumerable GetContinuousAggre ?? parentEntityType.GetSchema() ?? DefaultValues.DefaultSchema; + bool enableCompression = entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value as bool? ?? false; + List? compressionSegmentBy = CompressionAnnotationExtractor.ExtractSegmentByColumns(entityType, aggregateStoreIdentifier); + List? compressionOrderBy = CompressionAnnotationExtractor.ExtractOrderByColumns(entityType, aggregateStoreIdentifier); + yield return new CreateContinuousAggregateOperation { Schema = schema, @@ -167,9 +110,82 @@ public static IEnumerable GetContinuousAggre AggregateFunctions = aggregateFunctions, GroupByColumns = groupByColumns, WhereClause = whereClause, - ViewDefinition = useRawDefinition ? viewDefinition : null + ViewDefinition = useRawDefinition ? viewDefinition : null, + EnableCompression = enableCompression || compressionSegmentBy?.Count > 0 || compressionOrderBy?.Count > 0, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy, }; } } + + private static List ResolveAggregateFunctions( + IEntityType entityType, + IEntityType parentEntityType, + StoreObjectIdentifier parentStoreIdentifier, + StoreObjectIdentifier aggregateStoreIdentifier) + { + List aggregateFunctions = []; + IAnnotation? aggregateFunctionsAnnotation = entityType.FindAnnotation(ContinuousAggregateAnnotations.AggregateFunctions); + if (aggregateFunctionsAnnotation?.Value is not List modelAggregateFunctions) + { + return aggregateFunctions; + } + + foreach (string aggInfo in modelAggregateFunctions) + { + string[] parts = aggInfo.Split(':'); + if (parts.Length != 3) + { + continue; + } + + string aliasModelName = parts[0]; + string functionEnumString = parts[1]; + string sourceColumnModelName = parts[2]; + + // Resolve source column name from parent entity. "*" is not a column + // but the COUNT(*) wildcard, so it bypasses resolution. + string? sourceColumnDbName = sourceColumnModelName == "*" + ? "*" + : ColumnNameResolver.Resolve(parentEntityType, sourceColumnModelName, parentStoreIdentifier); + if (string.IsNullOrWhiteSpace(sourceColumnDbName)) + { + continue; + } + + // Resolve alias column name from aggregate entity to respect naming conventions + string? aliasDbName = entityType.FindProperty(aliasModelName)?.GetColumnName(aggregateStoreIdentifier); + if (string.IsNullOrWhiteSpace(aliasDbName)) + { + aliasDbName = aliasModelName; + } + + aggregateFunctions.Add($"{aliasDbName}:{functionEnumString}:{sourceColumnDbName}"); + } + + return aggregateFunctions; + } + + private static List ResolveGroupByColumns( + IEntityType entityType, + IEntityType parentEntityType, + StoreObjectIdentifier parentStoreIdentifier) + { + List groupByColumns = []; + IAnnotation? groupByColumnsAnnotation = entityType.FindAnnotation(ContinuousAggregateAnnotations.GroupByColumns); + if (groupByColumnsAnnotation?.Value is not List modelGroupByColumns) + { + return groupByColumns; + } + + foreach (string modelColumn in modelGroupByColumns) + { + // Try to resolve as a property name from the parent entity + string? dbColumnName = parentEntityType.FindProperty(modelColumn)?.GetColumnName(parentStoreIdentifier); + groupByColumns.Add(!string.IsNullOrWhiteSpace(dbColumnName) ? dbColumnName : modelColumn); + } + + return groupByColumns; + } } } diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs index b51490c..565f732 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -33,8 +33,10 @@ public IReadOnlyList GetDifferences(IRelationalModel? source x.Target.EnableCompression != x.Source.EnableCompression || !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) || !AreDimensionsEqual(x.Target.AdditionalDimensions, x.Source.AdditionalDimensions) || - !AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || - !AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) + !CompressionDiffHelper.AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || + !CompressionDiffHelper.AreOrderByListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) || + x.Target.CompressionSparseIndex != x.Source.CompressionSparseIndex || + x.Target.CompressChunkTimeInterval != x.Source.CompressChunkTimeInterval ); foreach (var hypertable in updatedHypertables) @@ -51,6 +53,8 @@ public IReadOnlyList GetDifferences(IRelationalModel? source AdditionalDimensions = hypertable.Target.AdditionalDimensions, CompressionSegmentBy = hypertable.Target.CompressionSegmentBy, CompressionOrderBy = hypertable.Target.CompressionOrderBy, + CompressionSparseIndex = hypertable.Target.CompressionSparseIndex, + CompressChunkTimeInterval = hypertable.Target.CompressChunkTimeInterval, // Old values OldChunkTimeInterval = hypertable.Source.ChunkTimeInterval, @@ -58,7 +62,9 @@ public IReadOnlyList GetDifferences(IRelationalModel? source OldChunkSkipColumns = hypertable.Source.ChunkSkipColumns, OldAdditionalDimensions = hypertable.Source.AdditionalDimensions, OldCompressionSegmentBy = hypertable.Source.CompressionSegmentBy, - OldCompressionOrderBy = hypertable.Source.CompressionOrderBy + OldCompressionOrderBy = hypertable.Source.CompressionOrderBy, + OldCompressionSparseIndex = hypertable.Source.CompressionSparseIndex, + OldCompressChunkTimeInterval = hypertable.Source.CompressChunkTimeInterval, }); } @@ -81,32 +87,15 @@ private static CreateHypertableOperation RewriteSource(CreateHypertableOperation ChunkTimeInterval = source.ChunkTimeInterval, EnableCompression = source.EnableCompression, MigrateData = source.MigrateData, - ChunkSkipColumns = RewriteColumns(source.ChunkSkipColumns, schema, tableName, context), - CompressionSegmentBy = RewriteColumns(source.CompressionSegmentBy, schema, tableName, context), - CompressionOrderBy = RewriteOrderByColumns(source.CompressionOrderBy, schema, tableName, context), + ChunkSkipColumns = CompressionDiffHelper.RewriteColumns(source.ChunkSkipColumns, schema, tableName, context), + CompressionSegmentBy = CompressionDiffHelper.RewriteColumns(source.CompressionSegmentBy, schema, tableName, context), + CompressionOrderBy = CompressionDiffHelper.RewriteOrderByColumns(source.CompressionOrderBy, schema, tableName, context), AdditionalDimensions = RewriteDimensions(source.AdditionalDimensions, schema, tableName, context), + CompressionSparseIndex = source.CompressionSparseIndex, + CompressChunkTimeInterval = source.CompressChunkTimeInterval, }; } - private static List? RewriteColumns(IReadOnlyList? columns, string schema, string table, FeatureDiffContext context) - => columns?.Select(c => context.ResolveColumn(schema, table, c)).ToList(); - - private static List? RewriteOrderByColumns(IReadOnlyList? columns, string schema, string table, FeatureDiffContext context) - { - // Order-by entries carry a direction suffix (e.g. "time DESC"); only the leading column name is renamed. - return columns?.Select(c => - { - string[] parts = c.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) - { - return c; - } - - string column = context.ResolveColumn(schema, table, parts[0]); - return parts.Length > 1 ? $"{column} {parts[1]}" : column; - }).ToList(); - } - private static List? RewriteDimensions(IReadOnlyList? dimensions, string schema, string table, FeatureDiffContext context) { return dimensions?.Select(d => new Dimension @@ -118,59 +107,6 @@ private static CreateHypertableOperation RewriteSource(CreateHypertableOperation }).ToList(); } - private static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) - { - return (list1 ?? []).SequenceEqual(list2 ?? []); - } - - /// - /// Compares two compression ORDER BY lists treating an implicit direction as ASC. - /// - private static bool AreOrderByListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) - { - IReadOnlyList l1 = list1 ?? []; - IReadOnlyList l2 = list2 ?? []; - - if (l1.Count != l2.Count) - { - return false; - } - - return l1.Zip(l2).All(pair => NormalizeOrderByEntry(pair.First) == NormalizeOrderByEntry(pair.Second)); - } - - /// - /// Normalizes a compression ORDER BY entry to its canonical form with an explicit direction - /// keyword immediately after the column name. - /// Examples: - /// "Value" -> "Value ASC" - /// "Value ASC" -> "Value ASC" - /// "Value DESC" -> "Value DESC" - /// "Value NULLS FIRST" -> "Value ASC NULLS FIRST" - /// "Value ASC NULLS FIRST" -> "Value ASC NULLS FIRST" - /// - private static string NormalizeOrderByEntry(string entry) - { - string trimmed = entry.Trim(); - int spaceIndex = trimmed.IndexOf(' '); - - if (spaceIndex < 0) - { - return trimmed + " ASC"; - } - - string columnPart = trimmed[..spaceIndex]; - string suffix = trimmed[(spaceIndex + 1)..].TrimStart(); - - if (suffix.StartsWith("DESC", StringComparison.OrdinalIgnoreCase) - || suffix.StartsWith("ASC", StringComparison.OrdinalIgnoreCase)) - { - return trimmed; - } - - return $"{columnPart} ASC {suffix}"; - } - private static bool AreChunkSkipColumnsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { if (list1 == null && list2 == null) return true; @@ -204,4 +140,4 @@ private static bool AreDimensionsEqual(IReadOnlyList? list1, IReadOnl return true; } } -} \ No newline at end of file +} diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs index 882f036..77efcc7 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; @@ -52,40 +52,8 @@ public static IEnumerable GetHypertables(IRelationalM .ToList()!; } - string? segmentByString = entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; - List? compressionSegmentBy = null; - if (!string.IsNullOrWhiteSpace(segmentByString)) - { - compressionSegmentBy = segmentByString.Split(',', StringSplitOptions.TrimEntries) - .Select(propName => ResolveColumnName(entityType, storeIdentifier, propName)) - .Where(name => !string.IsNullOrEmpty(name)) - .ToList()!; - } - - string? orderByString = entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; - List? compressionOrderBy = null; - if (!string.IsNullOrWhiteSpace(orderByString)) - { - compressionOrderBy = []; - string[] clauses = orderByString.Split(',', StringSplitOptions.TrimEntries); - - foreach (string clause in clauses) - { - // Split by the first space to separate PropertyName from Directions (ASC/DESC/NULLS) - string[] parts = clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length > 0) - { - string propName = parts[0]; - string suffix = parts.Length > 1 ? " " + parts[1] : ""; - - string columnName = ResolveColumnName(entityType, storeIdentifier, propName); - if (!string.IsNullOrEmpty(columnName)) - { - compressionOrderBy.Add(columnName + suffix); - } - } - } - } + List? compressionSegmentBy = CompressionAnnotationExtractor.ExtractSegmentByColumns(entityType, storeIdentifier); + List? compressionOrderBy = CompressionAnnotationExtractor.ExtractOrderByColumns(entityType, storeIdentifier); List? additionalDimensions = null; IAnnotation? additionalDimensionsAnnotations = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions); @@ -111,6 +79,8 @@ public static IEnumerable GetHypertables(IRelationalM string chunkTimeInterval = entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value as string ?? DefaultValues.ChunkTimeInterval; bool enableCompression = entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value as bool? ?? false; bool migrateData = entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value as bool? ?? false; + string? compressionSparseIndex = CompressionAnnotationExtractor.ExtractSparseIndex(entityType, storeIdentifier); + string? compressChunkTimeInterval = entityType.FindAnnotation(HypertableAnnotations.CompressChunkTimeInterval)?.Value as string; yield return new CreateHypertableOperation { @@ -123,18 +93,11 @@ public static IEnumerable GetHypertables(IRelationalM ChunkSkipColumns = chunkSkipColumns, AdditionalDimensions = additionalDimensions, CompressionSegmentBy = compressionSegmentBy, - CompressionOrderBy = compressionOrderBy + CompressionOrderBy = compressionOrderBy, + CompressionSparseIndex = compressionSparseIndex, + CompressChunkTimeInterval = compressChunkTimeInterval, }; } } - - /// - /// Resolves a C# property name to a Database column name. - /// If the property is not found (e.g., user provided a raw column name via Attribute), returns the input string. - /// - private static string ResolveColumnName(IEntityType entityType, StoreObjectIdentifier storeIdentifier, string propertyName) - { - return entityType.FindProperty(propertyName)?.GetColumnName(storeIdentifier) ?? propertyName; - } } -} \ No newline at end of file +} diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs index 5a728f8..4429567 100644 --- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs +++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregatePolicies; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; @@ -40,6 +41,7 @@ public override IReadOnlyList GetDifferences(IRelationalMode allOperations.AddRange(new ContinuousAggregatePolicyDiffer().GetDifferences(source, target, context)); allOperations.AddRange(new RetentionPolicyDiffer().GetDifferences(source, target, context)); + allOperations.AddRange(new CompressionPolicyDiffer().GetDifferences(source, target, context)); // Sort the entire list based on the priority defined in the helper method List sortedOperations = [.. allOperations.OrderBy(GetOperationPriority)]; @@ -136,11 +138,13 @@ private static int GetOperationPriority(MigrationOperation operation) case DropContinuousAggregateOperation: return -40; + case DropCompressionPolicyOperation: + return -45; + // Reorder policies depend on hypertables case DropReorderPolicyOperation: return -20; - // --- Add/Alter operations: positive priorities, dependency order --- case CreateHypertableOperation: return 10; case AlterHypertableOperation: @@ -155,6 +159,10 @@ private static int GetOperationPriority(MigrationOperation operation) case AlterContinuousAggregateOperation: return 40; + case AddCompressionPolicyOperation: + case AlterCompressionPolicyOperation: + return 45; + case AddContinuousAggregatePolicyOperation: return 50; diff --git a/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs b/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs index f8f0d45..14a39ef 100644 --- a/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs +++ b/src/Eftdb/Internals/WhereClauseEpressionVisitor.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using System.Linq.Expressions; using System.Reflection; @@ -108,8 +109,7 @@ private void AppendSqlLiteral(object? value) } else if (value is string str) { - // Simple string quoting; for production, you might need more robust escaping - sqlBuilder.Append($"'{str.Replace("'", "''")}'"); + sqlBuilder.Append($"'{SqlBuilderHelper.EscapeStringLiteral(str)}'"); } else if (value is bool b) { diff --git a/src/Eftdb/MigrationExtensions/CompressionPolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/CompressionPolicyMigrationExtensions.cs new file mode 100644 index 0000000..4e96aa4 --- /dev/null +++ b/src/Eftdb/MigrationExtensions/CompressionPolicyMigrationExtensions.cs @@ -0,0 +1,89 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; + +namespace Microsoft.EntityFrameworkCore.Migrations +{ + public static class CompressionPolicyMigrationExtensions + { + public static OperationBuilder AddCompressionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null, + string? after = null, + string? createdBefore = null, + string? scheduleInterval = null, + DateTime? initialStart = null, + string? timezone = null, + bool? ifNotExists = null) + { + AddCompressionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + After = after, + CreatedBefore = createdBefore, + ScheduleInterval = scheduleInterval, + InitialStart = initialStart, + Timezone = timezone, + IfNotExists = ifNotExists, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder AlterCompressionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null, + string? after = null, + string? createdBefore = null, + string? scheduleInterval = null, + DateTime? initialStart = null, + string? timezone = null, + bool? ifNotExists = null, + string? oldAfter = null, + string? oldCreatedBefore = null, + string? oldScheduleInterval = null, + DateTime? oldInitialStart = null, + string? oldTimezone = null, + bool? oldIfNotExists = null) + { + AlterCompressionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + After = after, + CreatedBefore = createdBefore, + ScheduleInterval = scheduleInterval, + InitialStart = initialStart, + Timezone = timezone, + IfNotExists = ifNotExists, + OldAfter = oldAfter, + OldCreatedBefore = oldCreatedBefore, + OldScheduleInterval = oldScheduleInterval, + OldInitialStart = oldInitialStart, + OldTimezone = oldTimezone, + OldIfNotExists = oldIfNotExists, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + + public static OperationBuilder DropCompressionPolicy( + this MigrationBuilder migrationBuilder, + string tableName, + string? schema = null) + { + DropCompressionPolicyOperation operation = new() + { + TableName = tableName, + Schema = schema ?? string.Empty, + }; + + migrationBuilder.Operations.Add(operation); + return new OperationBuilder(operation); + } + } +} diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs index 837d464..1a29e5b 100644 --- a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs +++ b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs @@ -21,7 +21,10 @@ public static OperationBuilder CreateContinu IReadOnlyList? aggregateFunctions = null, IReadOnlyList? groupByColumns = null, string? whereClause = null, - string? viewDefinition = null) + string? viewDefinition = null, + bool enableCompression = false, + IReadOnlyList? compressionSegmentBy = null, + IReadOnlyList? compressionOrderBy = null) { CreateContinuousAggregateOperation operation = new() { @@ -39,6 +42,9 @@ public static OperationBuilder CreateContinu GroupByColumns = groupByColumns ?? [], WhereClause = whereClause, ViewDefinition = viewDefinition, + EnableCompression = enableCompression, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy, }; migrationBuilder.Operations.Add(operation); @@ -52,9 +58,15 @@ public static OperationBuilder AlterContinuou string? chunkInterval = null, bool createGroupIndexes = false, bool materializedOnly = false, + bool enableCompression = false, + IReadOnlyList? compressionSegmentBy = null, + IReadOnlyList? compressionOrderBy = null, string? oldChunkInterval = null, bool oldCreateGroupIndexes = false, - bool oldMaterializedOnly = false) + bool oldMaterializedOnly = false, + bool oldEnableCompression = false, + IReadOnlyList? oldCompressionSegmentBy = null, + IReadOnlyList? oldCompressionOrderBy = null) { AlterContinuousAggregateOperation operation = new() { @@ -63,9 +75,15 @@ public static OperationBuilder AlterContinuou ChunkInterval = chunkInterval, CreateGroupIndexes = createGroupIndexes, MaterializedOnly = materializedOnly, + EnableCompression = enableCompression, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy, OldChunkInterval = oldChunkInterval, OldCreateGroupIndexes = oldCreateGroupIndexes, OldMaterializedOnly = oldMaterializedOnly, + OldEnableCompression = oldEnableCompression, + OldCompressionSegmentBy = oldCompressionSegmentBy, + OldCompressionOrderBy = oldCompressionOrderBy, }; migrationBuilder.Operations.Add(operation); diff --git a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs index a5cf9a2..3624fa7 100644 --- a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs +++ b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs @@ -17,7 +17,9 @@ public static OperationBuilder CreateHypertable( IReadOnlyList? chunkSkipColumns = null, IReadOnlyList? additionalDimensions = null, IReadOnlyList? compressionSegmentBy = null, - IReadOnlyList? compressionOrderBy = null) + IReadOnlyList? compressionOrderBy = null, + string? compressionSparseIndex = null, + string? compressChunkTimeInterval = null) { CreateHypertableOperation operation = new() { @@ -31,6 +33,8 @@ public static OperationBuilder CreateHypertable( AdditionalDimensions = additionalDimensions, CompressionSegmentBy = compressionSegmentBy, CompressionOrderBy = compressionOrderBy, + CompressionSparseIndex = compressionSparseIndex, + CompressChunkTimeInterval = compressChunkTimeInterval, }; migrationBuilder.Operations.Add(operation); @@ -47,12 +51,16 @@ public static OperationBuilder AlterHypertable( IReadOnlyList? additionalDimensions = null, IReadOnlyList? compressionSegmentBy = null, IReadOnlyList? compressionOrderBy = null, + string? compressionSparseIndex = null, + string? compressChunkTimeInterval = null, string? oldChunkTimeInterval = null, bool oldEnableCompression = false, IReadOnlyList? oldChunkSkipColumns = null, IReadOnlyList? oldAdditionalDimensions = null, IReadOnlyList? oldCompressionSegmentBy = null, - IReadOnlyList? oldCompressionOrderBy = null) + IReadOnlyList? oldCompressionOrderBy = null, + string? oldCompressionSparseIndex = null, + string? oldCompressChunkTimeInterval = null) { AlterHypertableOperation operation = new() { @@ -64,12 +72,16 @@ public static OperationBuilder AlterHypertable( AdditionalDimensions = additionalDimensions, CompressionSegmentBy = compressionSegmentBy, CompressionOrderBy = compressionOrderBy, + CompressionSparseIndex = compressionSparseIndex, + CompressChunkTimeInterval = compressChunkTimeInterval, OldChunkTimeInterval = oldChunkTimeInterval ?? string.Empty, OldEnableCompression = oldEnableCompression, OldChunkSkipColumns = oldChunkSkipColumns, OldAdditionalDimensions = oldAdditionalDimensions, OldCompressionSegmentBy = oldCompressionSegmentBy, OldCompressionOrderBy = oldCompressionOrderBy, + OldCompressionSparseIndex = oldCompressionSparseIndex, + OldCompressChunkTimeInterval = oldCompressChunkTimeInterval, }; migrationBuilder.Operations.Add(operation); diff --git a/src/Eftdb/Operations/AddCompressionPolicyOperation.cs b/src/Eftdb/Operations/AddCompressionPolicyOperation.cs new file mode 100644 index 0000000..b616e6f --- /dev/null +++ b/src/Eftdb/Operations/AddCompressionPolicyOperation.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + /// + /// Represents a migration operation that schedules automatic compression of chunks on a hypertable + /// or continuous aggregate by calling add_compression_policy(). + /// + /// + /// Exactly one of or must be set. + /// The target table must already have compression enabled (timescaledb.compress). + /// + public class AddCompressionPolicyOperation : MigrationOperation + { + /// Gets or sets the table (or materialized view) name. + public string TableName { get; set; } = string.Empty; + + /// Gets or sets the schema of the table or materialized view. + public string Schema { get; set; } = string.Empty; + + /// + /// Gets or sets the interval after which chunks are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + /// "7 days" + public string? After { get; set; } + + /// + /// Gets or sets the interval relative to chunk creation time; chunks created before this interval are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + /// "30 days" + public string? CreatedBefore { get; set; } + + /// + /// Gets or sets the interval between policy job executions. + /// Defaults to 12 hours when chunk_time_interval is at least 1 day, otherwise half the chunk interval. + /// + /// "12 hours" + public string? ScheduleInterval { get; set; } + + /// + /// Gets or sets the first time the policy job is scheduled to run. + /// When null the scheduler derives the initial run from the schedule interval. + /// + public DateTime? InitialStart { get; set; } + + /// + /// Gets or sets the PostgreSQL time zone used when computing the initial start time. + /// + /// "Europe/Berlin" + public string? Timezone { get; set; } + + /// + /// Gets or sets a value indicating whether the operation should succeed silently when the policy + /// already exists, instead of raising an error. + /// + public bool? IfNotExists { get; set; } + } +} diff --git a/src/Eftdb/Operations/AlterCompressionPolicyOperation.cs b/src/Eftdb/Operations/AlterCompressionPolicyOperation.cs new file mode 100644 index 0000000..1b1ac6c --- /dev/null +++ b/src/Eftdb/Operations/AlterCompressionPolicyOperation.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + /// + /// Represents a migration operation that modifies an existing compression policy on a hypertable + /// or continuous aggregate by calling remove_compression_policy() followed by + /// add_compression_policy(). + /// + /// + /// Exactly one of or must be set. + /// The Old* properties capture the previous state so the down migration can restore it. + /// + public class AlterCompressionPolicyOperation : MigrationOperation + { + /// Gets or sets the table (or materialized view) name. + public string TableName { get; set; } = string.Empty; + + /// Gets or sets the schema of the table or materialized view. + public string Schema { get; set; } = string.Empty; + + // ── New (target) state ────────────────────────────────────────────────── + + /// + /// Gets or sets the new interval after which chunks are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + public string? After { get; set; } + + /// + /// Gets or sets the new interval relative to chunk creation; chunks created before this interval are compressed. + /// Mutually exclusive with ; exactly one must be set. + /// + public string? CreatedBefore { get; set; } + + /// Gets or sets the new interval between policy job executions. + public string? ScheduleInterval { get; set; } + + /// Gets or sets the new first time the policy job is scheduled to run. + public DateTime? InitialStart { get; set; } + + /// Gets or sets the new PostgreSQL time zone used when computing the initial start time. + public string? Timezone { get; set; } + + /// Gets or sets the new if-not-exists flag. + public bool? IfNotExists { get; set; } + + // ── Old (source) state — used to generate the Down migration ─────────── + + /// Gets or sets the previous value of . + public string? OldAfter { get; set; } + + /// Gets or sets the previous value of . + public string? OldCreatedBefore { get; set; } + + /// Gets or sets the previous schedule interval. + public string? OldScheduleInterval { get; set; } + + /// Gets or sets the previous initial start time. + public DateTime? OldInitialStart { get; set; } + + /// Gets or sets the previous time zone. + public string? OldTimezone { get; set; } + + /// Gets or sets the previous if-not-exists flag. + public bool? OldIfNotExists { get; set; } + } +} diff --git a/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs b/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs index bd42ce7..a9f40a3 100644 --- a/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/AlterContinuousAggregateOperation.cs @@ -15,5 +15,32 @@ public class AlterContinuousAggregateOperation : MigrationOperation public bool MaterializedOnly { get; set; } public bool OldMaterializedOnly { get; set; } + + /// + /// Whether columnstore (compression) should be enabled after the alter. + /// Corresponds to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true/false). + /// + public bool EnableCompression { get; set; } + + /// Previous value of . + public bool OldEnableCompression { get; set; } + + /// + /// The columns to segment by for compression after the alter. Comma-separated database column names. + /// Corresponds to timescaledb.compress_segmentby. + /// + public IReadOnlyList? CompressionSegmentBy { get; set; } + + /// Previous value of . + public IReadOnlyList? OldCompressionSegmentBy { get; set; } + + /// + /// The columns to order by within each compressed segment after the alter. + /// Comma-separated SQL expressions. Corresponds to timescaledb.compress_orderby. + /// + public IReadOnlyList? CompressionOrderBy { get; set; } + + /// Previous value of . + public IReadOnlyList? OldCompressionOrderBy { get; set; } } } diff --git a/src/Eftdb/Operations/AlterHypertableOperation.cs b/src/Eftdb/Operations/AlterHypertableOperation.cs index 98296ef..af76584 100644 --- a/src/Eftdb/Operations/AlterHypertableOperation.cs +++ b/src/Eftdb/Operations/AlterHypertableOperation.cs @@ -27,5 +27,11 @@ public class AlterHypertableOperation : MigrationOperation public IReadOnlyList? CompressionOrderBy { get; set; } public IReadOnlyList? OldCompressionOrderBy { get; set; } + + public string? CompressionSparseIndex { get; set; } + public string? OldCompressionSparseIndex { get; set; } + + public string? CompressChunkTimeInterval { get; set; } + public string? OldCompressChunkTimeInterval { get; set; } } } diff --git a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs index ae2b562..5d7d0e4 100644 --- a/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs +++ b/src/Eftdb/Operations/CreateContinuousAggregateOperation.cs @@ -29,5 +29,23 @@ public class CreateContinuousAggregateOperation : MigrationOperation /// from the TimescaleDB catalog. /// public string? ViewDefinition { get; set; } + + /// + /// Whether to enable columnstore (compression) on the continuous aggregate immediately after creation. + /// Corresponds to ALTER MATERIALIZED VIEW ... SET (timescaledb.compress = true). + /// + public bool EnableCompression { get; set; } + + /// + /// The columns to segment by for compression. Comma-separated database column names. + /// Corresponds to timescaledb.compress_segmentby. + /// + public IReadOnlyList? CompressionSegmentBy { get; set; } + + /// + /// The columns to order by within each compressed segment. Comma-separated SQL expressions + /// (e.g., "time DESC"). Corresponds to timescaledb.compress_orderby. + /// + public IReadOnlyList? CompressionOrderBy { get; set; } } } diff --git a/src/Eftdb/Operations/CreateHypertableOperation.cs b/src/Eftdb/Operations/CreateHypertableOperation.cs index b8ac733..89d6b8b 100644 --- a/src/Eftdb/Operations/CreateHypertableOperation.cs +++ b/src/Eftdb/Operations/CreateHypertableOperation.cs @@ -11,6 +11,8 @@ public class CreateHypertableOperation : MigrationOperation public string ChunkTimeInterval { get; set; } = string.Empty; public bool EnableCompression { get; set; } public bool MigrateData { get; set; } = false; + public string? CompressionSparseIndex { get; set; } + public string? CompressChunkTimeInterval { get; set; } public IReadOnlyList? ChunkSkipColumns { get; set; } public IReadOnlyList? AdditionalDimensions { get; set; } diff --git a/src/Eftdb/Operations/DropCompressionPolicyOperation.cs b/src/Eftdb/Operations/DropCompressionPolicyOperation.cs new file mode 100644 index 0000000..7ec8eca --- /dev/null +++ b/src/Eftdb/Operations/DropCompressionPolicyOperation.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + /// + /// Represents a migration operation that removes an existing compression policy from a hypertable + /// or continuous aggregate by calling remove_compression_policy(). + /// + public class DropCompressionPolicyOperation : MigrationOperation + { + /// Gets or sets the table (or materialized view) name. + public string TableName { get; set; } = string.Empty; + + /// Gets or sets the schema of the table or materialized view. + public string Schema { get; set; } = string.Empty; + } +} diff --git a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs index 9e7cef5..18d9638 100644 --- a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs +++ b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; @@ -35,28 +36,69 @@ public static DbContextOptionsBuilder UseTimescaleDb( return optionsBuilder; } + /// + /// Configures the DbContext to use TimescaleDB-aware migrations and conventions. + /// + /// The type of the DbContext. + /// The options builder for the DbContext. + /// An action to configure TimescaleDB-specific options. + public static DbContextOptionsBuilder UseTimescaleDb( + this DbContextOptionsBuilder optionsBuilder, + Action configure) + where TContext : DbContext + { + ((DbContextOptionsBuilder)optionsBuilder).UseTimescaleDb(configure); + return optionsBuilder; + } + /// /// Configures the DbContext to use TimescaleDB-aware migrations and conventions. /// /// The options builder for the DbContext. public static DbContextOptionsBuilder UseTimescaleDb(this DbContextOptionsBuilder optionsBuilder) { - TimescaleDbOptionsExtension extension = optionsBuilder.Options.FindExtension() ?? new TimescaleDbOptionsExtension(); + return ApplyTimescaleDbExtension(optionsBuilder, configure: null); + } + + /// + /// Configures the DbContext to use TimescaleDB-aware migrations and conventions. + /// + /// The options builder for the DbContext. + /// An action to configure TimescaleDB-specific options. + public static DbContextOptionsBuilder UseTimescaleDb( + this DbContextOptionsBuilder optionsBuilder, + Action configure) + { + return ApplyTimescaleDbExtension(optionsBuilder, configure); + } + + private static DbContextOptionsBuilder ApplyTimescaleDbExtension( + DbContextOptionsBuilder optionsBuilder, + Action? configure) + { + TimescaleDbOptions options = new(); + configure?.Invoke(options); + + TimescaleDbOptionsExtension extension = (configure != null || optionsBuilder.Options.FindExtension() is null) + ? new TimescaleDbOptionsExtension(options) + : optionsBuilder.Options.FindExtension()!; + ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension); return optionsBuilder; } /// - /// The internal options extension that carries the TimescaeDB configuration. + /// The internal options extension that carries the TimescaleDB configuration. /// - private class TimescaleDbOptionsExtension : IDbContextOptionsExtension + private class TimescaleDbOptionsExtension(TimescaleDbOptions timescaleDbOptions) : IDbContextOptionsExtension { private DbContextOptionsExtensionInfo? _info; public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); public void ApplyServices(IServiceCollection services) { + services.AddSingleton(timescaleDbOptions); services.AddSingleton(); services.AddScoped(); services.Replace(ServiceDescriptor.Scoped()); @@ -88,7 +130,10 @@ public ConventionSet ModifyConventions(ConventionSet conventionSet) conventionSet.EntityTypeAddedConventions.Add(new ContinuousAggregateConvention()); conventionSet.EntityTypeAddedConventions.Add(new ContinuousAggregatePolicyConvention()); conventionSet.EntityTypeAddedConventions.Add(new RetentionPolicyConvention()); + conventionSet.EntityTypeAddedConventions.Add(new CompressionPolicyConvention()); conventionSet.ModelFinalizedConventions.Add(new TimeColumnStoreTypeValidationConvention()); + conventionSet.ModelFinalizedConventions.Add(new CompressionPolicyPrerequisiteValidationConvention()); + conventionSet.ModelFinalizedConventions.Add(new SparseIndexValidationConvention()); return conventionSet; } } diff --git a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs index a5bad98..356bc13 100644 --- a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs +++ b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs @@ -9,8 +9,13 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB { #pragma warning disable EF1001 - public class TimescaleDbMigrationsSqlGenerator(MigrationsSqlGeneratorDependencies dependencies, INpgsqlSingletonOptions npgsqlSingletonOptions) : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions) + public class TimescaleDbMigrationsSqlGenerator( + MigrationsSqlGeneratorDependencies dependencies, + INpgsqlSingletonOptions npgsqlSingletonOptions, + TimescaleDbOptions? timescaleDbOptions = null) : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions) { + private readonly bool _useLegacyCompressionNames = timescaleDbOptions?.UseLegacyCompressionNames ?? false; + protected override void Generate( MigrationOperation operation, IModel? model, @@ -22,11 +27,11 @@ protected override void Generate( switch (operation) { case CreateHypertableOperation hypertableOperation: - statements = HypertableSqlGenerator.Generate(hypertableOperation); + statements = HypertableSqlGenerator.Generate(hypertableOperation, _useLegacyCompressionNames); break; case AlterHypertableOperation alterHypertableOperation: - statements = HypertableSqlGenerator.Generate(alterHypertableOperation); + statements = HypertableSqlGenerator.Generate(alterHypertableOperation, _useLegacyCompressionNames); break; case AlterReorderPolicyOperation alterReorderPolicyOperation: @@ -53,13 +58,25 @@ protected override void Generate( statements = RetentionPolicySqlGenerator.Generate(dropRetentionPolicyOperation); break; + case AddCompressionPolicyOperation addCompressionPolicyOperation: + statements = CompressionPolicySqlGenerator.Generate(addCompressionPolicyOperation, _useLegacyCompressionNames); + break; + + case AlterCompressionPolicyOperation alterCompressionPolicyOperation: + statements = CompressionPolicySqlGenerator.Generate(alterCompressionPolicyOperation, _useLegacyCompressionNames); + break; + + case DropCompressionPolicyOperation dropCompressionPolicyOperation: + statements = CompressionPolicySqlGenerator.Generate(dropCompressionPolicyOperation, _useLegacyCompressionNames); + break; + case CreateContinuousAggregateOperation createContinuousAggregateOperation: - statements = ContinuousAggregateSqlGenerator.Generate(createContinuousAggregateOperation); + statements = ContinuousAggregateSqlGenerator.Generate(createContinuousAggregateOperation, _useLegacyCompressionNames); suppressTransaction = true; break; case AlterContinuousAggregateOperation alterContinuousAggregateOperation: - statements = ContinuousAggregateSqlGenerator.Generate(alterContinuousAggregateOperation); + statements = ContinuousAggregateSqlGenerator.Generate(alterContinuousAggregateOperation, _useLegacyCompressionNames); break; case DropContinuousAggregateOperation dropContinuousAggregateOperation: diff --git a/src/Eftdb/TimescaleDbOptions.cs b/src/Eftdb/TimescaleDbOptions.cs new file mode 100644 index 0000000..f042e32 --- /dev/null +++ b/src/Eftdb/TimescaleDbOptions.cs @@ -0,0 +1,30 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB +{ + /// + /// Provider-level options for TimescaleDB migrations. + /// + public sealed class TimescaleDbOptions + { + /// + /// When , migration SQL uses the pre-2.18 compression API + /// (timescaledb.compress, timescaledb.compress_segmentby, + /// timescaledb.compress_orderby, add_compression_policy, + /// remove_compression_policy). + /// When (default), migration SQL uses the 2.18+ columnstore API + /// (timescaledb.enable_columnstore, timescaledb.segmentby, + /// timescaledb.orderby, CALL add_columnstore_policy, + /// CALL remove_columnstore_policy). + /// + public bool UseLegacyCompressionNames { get; private set; } + + /// + /// Configures the provider to emit pre-2.18 compression SQL names. + /// Use this when targeting a TimescaleDB version earlier than 2.18. + /// + public TimescaleDbOptions UseLegacyCompressionSql() + { + UseLegacyCompressionNames = true; + return this; + } + } +} diff --git a/tests/Eftdb.FunctionalTests/TimescaleDbSqlGenerationTests.cs b/tests/Eftdb.FunctionalTests/TimescaleDbSqlGenerationTests.cs index 7ff5eb4..0f5e011 100644 --- a/tests/Eftdb.FunctionalTests/TimescaleDbSqlGenerationTests.cs +++ b/tests/Eftdb.FunctionalTests/TimescaleDbSqlGenerationTests.cs @@ -210,7 +210,6 @@ public override void InsertDataOperation_required_args_multiple_rows() public override void InsertDataOperation_throws_for_unsupported_column_types() { - // Npgsql supports most (if not all) column types from the base test } public override void DeleteDataOperation_all_args() diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleConnectionHelper.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleConnectionHelper.cs index 805b458..1b30b97 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleConnectionHelper.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleConnectionHelper.cs @@ -17,7 +17,7 @@ public static string GetConnectionString(string database) Username = Username, Password = Password, Database = database, - Pooling = false // Disable pooling for test isolation + Pooling = false }.ToString(); } } diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs index 435f08d..d15a581 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs @@ -14,7 +14,6 @@ public class TimescaleMigrationsFixture : MigrationsInfrastructureFixtureBase, I public string ConnectionString => _dbContainer.GetConnectionString(); - // Start the container before tests run public override async Task InitializeAsync() { await _dbContainer.StartAsync(); @@ -22,7 +21,6 @@ public override async Task InitializeAsync() await base.InitializeAsync(); } - // Stop the container after tests finish public override async Task DisposeAsync() { await base.DisposeAsync(); diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsTestInterceptor.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsTestInterceptor.cs index 884c1ff..29f92d4 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsTestInterceptor.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsTestInterceptor.cs @@ -16,9 +16,7 @@ public class TimescaleMigrationsTestInterceptor : DbCommandInterceptor private static string Fix(string commandText) { return commandText - // Fixes the invalid string-to-integer insert. .Replace("' '", "0") - // Fixes the unquoted, case-sensitive table and column names. .Replace("INSERT INTO Table1 (Id, Bar, Description)", "INSERT INTO \"Table1\" (\"Id\", \"Bar\", \"Description\")"); } diff --git a/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs b/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs index 43b3594..976cfc7 100644 --- a/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs +++ b/tests/Eftdb.Tests/Abstractions/ContinuousAggregateFunctionTests.cs @@ -44,8 +44,7 @@ public void Constructor_ExposesPropertiesUnchanged() [Fact] public void RoundTrip_AnnotationValue_FeedsContinuousAggregateGenerator() { - // Arrange — the wire format produced by ContinuousAggregateFunction must be - // parseable by ContinuousAggregateSqlGenerator's ':'-delimited parser. + // Arrange ContinuousAggregateFunction function = new("avg_t", EAggregateFunction.Avg, "temp"); CreateContinuousAggregateOperation operation = new() @@ -63,7 +62,7 @@ public void RoundTrip_AnnotationValue_FeedsContinuousAggregateGenerator() List statements = ContinuousAggregateSqlGenerator.Generate(operation); string sql = string.Join("\n", statements); - // Assert — Avg maps to AVG("temp") with the alias "avg_t" + // Assert Assert.Contains("AVG(\"temp\") AS \"avg_t\"", sql); } diff --git a/tests/Eftdb.Tests/Abstractions/OrderByTests.cs b/tests/Eftdb.Tests/Abstractions/OrderByTests.cs index d136852..7d02c50 100644 --- a/tests/Eftdb.Tests/Abstractions/OrderByTests.cs +++ b/tests/Eftdb.Tests/Abstractions/OrderByTests.cs @@ -196,7 +196,7 @@ public void OrderByConfiguration_With_Invalid_Expression_Should_Throw_ArgumentEx ArgumentException ex = Assert.Throws(() => OrderByBuilder.For(x => x.Time.ToString())); - Assert.Contains("Invalid expression", ex.Message); + Assert.Contains("simple property access expression", ex.Message); } #endregion @@ -273,7 +273,7 @@ public void OrderBySelector_With_Invalid_Expression_Should_Throw_ArgumentExcepti ArgumentException ex = Assert.Throws(() => selector.By(x => x.Time.ToString())); - Assert.Contains("Expression must be a property access", ex.Message); + Assert.Contains("simple property access expression", ex.Message); } #endregion diff --git a/tests/Eftdb.Tests/Abstractions/SparseIndexTests.cs b/tests/Eftdb.Tests/Abstractions/SparseIndexTests.cs new file mode 100644 index 0000000..8ca05d3 --- /dev/null +++ b/tests/Eftdb.Tests/Abstractions/SparseIndexTests.cs @@ -0,0 +1,267 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Abstractions; + +public class SparseIndexTests +{ + // ── SparseIndex.ToSql() ── + + #region ToSql_Bloom_Single_Column + + [Fact] + public void ToSql_Bloom_Single_Column_Emits_Canonical_Form() + { + // Arrange + SparseIndex index = new(ESparseIndexType.Bloom, ["device_id"]); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("bloom(device_id)", sql); + } + + #endregion + + #region ToSql_Bloom_Composite_Columns_No_Spaces + + [Fact] + public void ToSql_Bloom_Composite_Columns_Emits_No_Spaces_In_Args() + { + // Arrange + SparseIndex index = new(ESparseIndexType.Bloom, ["device_id", "tenant_id"]); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("bloom(device_id,tenant_id)", sql); + } + + #endregion + + #region ToSql_MinMax_Single_Column + + [Fact] + public void ToSql_MinMax_Single_Column_Emits_Canonical_Form() + { + // Arrange + SparseIndex index = new(ESparseIndexType.MinMax, ["temperature"]); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("minmax(temperature)", sql); + } + + #endregion + + #region ToSql_Preserves_Column_Name_Casing + + [Fact] + public void ToSql_Preserves_Column_Name_Casing() + { + // Arrange + SparseIndex index = new(ESparseIndexType.Bloom, ["DeviceId"]); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("bloom(DeviceId)", sql); + } + + #endregion + + // ── SparseIndex constructor validation ── + + #region Constructor_Throws_When_No_Columns + + [Fact] + public void Constructor_Throws_When_Columns_Is_Empty() + { + // Arrange / Act / Assert + Assert.Throws(() => new SparseIndex(ESparseIndexType.Bloom, [])); + } + + #endregion + + #region Constructor_Throws_When_Columns_Is_Null + + [Fact] + public void Constructor_Throws_When_Columns_Is_Null() + { + // Arrange / Act / Assert + Assert.Throws(() => new SparseIndex(ESparseIndexType.MinMax, null!)); + } + + #endregion + + // ── SparseIndexSelector ── + + #region Selector_Bloom_Extracts_Property_Name + + private class SelectorTestEntity { public int DeviceId { get; set; } public double Value { get; set; } } + + [Fact] + public void Selector_Bloom_Extracts_Property_Name_From_Simple_Lambda() + { + // Arrange + SparseIndexSelector selector = new(); + + // Act + SparseIndex index = selector.Bloom(x => x.DeviceId); + + // Assert + Assert.Equal(ESparseIndexType.Bloom, index.Kind); + Assert.Equal(["DeviceId"], index.Columns); + } + + #endregion + + #region Selector_Bloom_Composite_Extracts_All_Property_Names + + [Fact] + public void Selector_Bloom_Composite_Extracts_All_Property_Names() + { + // Arrange + SparseIndexSelector selector = new(); + + // Act + SparseIndex index = selector.Bloom(x => x.DeviceId, x => x.Value); + + // Assert + Assert.Equal(ESparseIndexType.Bloom, index.Kind); + Assert.Equal(["DeviceId", "Value"], index.Columns); + } + + #endregion + + #region Selector_MinMax_Extracts_Property_Name + + [Fact] + public void Selector_MinMax_Extracts_Property_Name_From_Simple_Lambda() + { + // Arrange + SparseIndexSelector selector = new(); + + // Act + SparseIndex index = selector.MinMax(x => x.Value); + + // Assert + Assert.Equal(ESparseIndexType.MinMax, index.Kind); + Assert.Equal(["Value"], index.Columns); + } + + #endregion + + #region Selector_Bloom_Throws_When_No_Expressions + + [Fact] + public void Selector_Bloom_Throws_When_No_Expressions_Supplied() + { + // Arrange + SparseIndexSelector selector = new(); + + // Act / Assert + Assert.Throws(() => selector.Bloom([])); + } + + #endregion + + #region Selector_Bloom_ToSql_Matches_Canonical_Form + + [Fact] + public void Selector_Bloom_ToSql_Matches_Canonical_Form() + { + // Arrange + SparseIndexSelector selector = new(); + SparseIndex index = selector.Bloom(x => x.DeviceId, x => x.Value); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("bloom(DeviceId,Value)", sql); + } + + #endregion + + #region Selector_MinMax_ToSql_Matches_Canonical_Form + + [Fact] + public void Selector_MinMax_ToSql_Matches_Canonical_Form() + { + // Arrange + SparseIndexSelector selector = new(); + SparseIndex index = selector.MinMax(x => x.Value); + + // Act + string sql = index.ToSql(); + + // Assert + Assert.Equal("minmax(Value)", sql); + } + + #endregion + + #region ToSql_Unknown_Kind_Throws_InvalidOperationException + + [Fact] + public void ToSql_Unknown_Kind_Throws_InvalidOperationException() + { + // Arrange + SparseIndex index = new((ESparseIndexType)99, ["col"]); + + // Act & Assert + Assert.Throws(() => index.ToSql()); + } + + #endregion +} + +// ── SparseIndexAttribute ────────────────────────────────────────────────────── + +public class SparseIndexAttributeTests +{ + #region Constructor_Throws_When_Columns_Is_Empty + + [Fact] + public void Constructor_Throws_When_Columns_Is_Empty() + { + // Arrange / Act / Assert + Assert.Throws( + () => new SparseIndexAttribute(ESparseIndexType.Bloom)); + } + + #endregion + + #region Constructor_Throws_When_Columns_Is_Null + + [Fact] + public void Constructor_Throws_When_Columns_Is_Null() + { + // Arrange / Act / Assert + Assert.Throws( + () => new SparseIndexAttribute(ESparseIndexType.Bloom, null!)); + } + + #endregion + + #region Constructor_Stores_Kind_And_Columns + + [Fact] + public void Constructor_Stores_Kind_And_Columns() + { + // Arrange & Act + SparseIndexAttribute attr = new(ESparseIndexType.MinMax, "device_id"); + + // Assert + Assert.Equal(ESparseIndexType.MinMax, attr.Kind); + Assert.Equal(["device_id"], attr.Columns); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs b/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs index 1fbd46e..07fa3a3 100644 --- a/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs +++ b/tests/Eftdb.Tests/Abstractions/TimescaleCopyConfigTests.cs @@ -19,7 +19,7 @@ private class EntityWithUnsupportedType [Fact] public void Constructor_ExcludesProperty_When_Type_Has_No_NpgsqlDbType_Mapping() { - // Arrange & + // Arrange & Act TimescaleCopyConfig config = new(); // Assert diff --git a/tests/Eftdb.Tests/Configuration/CompressionPolicyAttributeTests.cs b/tests/Eftdb.Tests/Configuration/CompressionPolicyAttributeTests.cs new file mode 100644 index 0000000..c371fb6 --- /dev/null +++ b/tests/Eftdb.Tests/Configuration/CompressionPolicyAttributeTests.cs @@ -0,0 +1,230 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify CompressionPolicyAttribute constructor validation, mutual exclusivity, and default values. +/// +public class CompressionPolicyAttributeTests +{ + #region Constructor1 Validation Tests (string after) + + [Fact] + public void Constructor1_With_Null_After_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new CompressionPolicyAttribute(null!)); + Assert.Contains("After must be provided", ex.Message); + Assert.Equal("after", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Empty_After_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new CompressionPolicyAttribute("")); + Assert.Contains("After must be provided", ex.Message); + Assert.Equal("after", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Whitespace_After_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new CompressionPolicyAttribute(" ")); + Assert.Contains("After must be provided", ex.Message); + Assert.Equal("after", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Valid_After_SetsAfterCorrectly() + { + // Arrange & Act + CompressionPolicyAttribute attr = new("7 days"); + + // Assert + Assert.Equal("7 days", attr.After); + Assert.Null(attr.CreatedBefore); + } + + #endregion + + #region Constructor2 Mutual Exclusivity Tests (string? after, string? createdBefore) + + [Fact] + public void Constructor2_With_AfterOnly_SetsAfterAndCreatedBeforeIsNull() + { + // Arrange & Act + CompressionPolicyAttribute attr = new(after: "7 days"); + + // Assert + Assert.Equal("7 days", attr.After); + Assert.Null(attr.CreatedBefore); + } + + [Fact] + public void Constructor2_With_CreatedBeforeOnly_SetsCreatedBeforeAndAfterIsNull() + { + // Arrange & Act + CompressionPolicyAttribute attr = new(createdBefore: "30 days"); + + // Assert + Assert.Null(attr.After); + Assert.Equal("30 days", attr.CreatedBefore); + } + + [Fact] + public void Constructor2_With_BothSpecified_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new CompressionPolicyAttribute(after: "7 days", createdBefore: "30 days")); + Assert.Contains("mutually exclusive", ex.Message); + } + + [Fact] + public void Constructor2_With_NeitherSpecified_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new CompressionPolicyAttribute(after: null, createdBefore: null)); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor2_With_Both_Whitespace_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new CompressionPolicyAttribute(after: " ", createdBefore: " ")); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Default Values Tests + + [Fact] + public void Constructor1_With_Valid_After_SetsDefaultValues() + { + // Arrange & Act + CompressionPolicyAttribute attr = new("7 days"); + + // Assert + Assert.Null(attr.CreatedBefore); + Assert.Null(attr.ScheduleInterval); + Assert.Null(attr.InitialStart); + Assert.Null(attr.Timezone); + Assert.False(attr.IfNotExists); + } + + #endregion + + #region Property Initializer Style Tests + + [Fact] + public void PropertyInitializer_With_After_SetsAfterWithoutThrowing() + { + // Arrange & Act + CompressionPolicyAttribute attr = new() { After = "7 days" }; + + // Assert + Assert.Equal("7 days", attr.After); + Assert.Null(attr.CreatedBefore); + } + + [Fact] + public void PropertyInitializer_With_CreatedBefore_SetsCreatedBeforeWithoutThrowing() + { + // Arrange & Act + CompressionPolicyAttribute attr = new() { CreatedBefore = "30 days" }; + + // Assert + Assert.Null(attr.After); + Assert.Equal("30 days", attr.CreatedBefore); + } + + #endregion + + #region Property Assignment Tests + + [Fact] + public void ScheduleInterval_CanBeSet() + { + // Arrange + CompressionPolicyAttribute attr = new("7 days") + { + // Act + ScheduleInterval = "12 hours" + }; + + // Assert + Assert.Equal("12 hours", attr.ScheduleInterval); + } + + [Fact] + public void InitialStart_CanBeSet() + { + // Arrange + CompressionPolicyAttribute attr = new("7 days") + { + // Act + InitialStart = "2025-10-01T03:00:00Z" + }; + + // Assert + Assert.Equal("2025-10-01T03:00:00Z", attr.InitialStart); + } + + [Fact] + public void Timezone_CanBeSet() + { + // Arrange + CompressionPolicyAttribute attr = new("7 days") + { + // Act + Timezone = "Europe/Berlin" + }; + + // Assert + Assert.Equal("Europe/Berlin", attr.Timezone); + } + + [Fact] + public void IfNotExists_CanBeSetToTrue() + { + // Arrange + CompressionPolicyAttribute attr = new("7 days") + { + // Act + IfNotExists = true + }; + + // Assert + Assert.True(attr.IfNotExists); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + // Arrange + CompressionPolicyAttribute attr = new("7 days") + { + // Act + ScheduleInterval = "12 hours", + InitialStart = "2025-01-01T00:00:00Z", + Timezone = "UTC", + IfNotExists = true + }; + + // Assert + Assert.Equal("7 days", attr.After); + Assert.Null(attr.CreatedBefore); + Assert.Equal("12 hours", attr.ScheduleInterval); + Assert.Equal("2025-01-01T00:00:00Z", attr.InitialStart); + Assert.Equal("UTC", attr.Timezone); + Assert.True(attr.IfNotExists); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs index 8af88b0..3cc6e01 100644 --- a/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs +++ b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs @@ -100,6 +100,32 @@ public void Constructor2_With_NeitherSpecified_ThrowsInvalidOperationException() #endregion + #region Property Initializer Style Tests + + [Fact] + public void PropertyInitializer_With_DropAfter_SetsDropAfterWithoutThrowing() + { + // Arrange & Act + RetentionPolicyAttribute attr = new() { DropAfter = "7 days" }; + + // Assert + Assert.Equal("7 days", attr.DropAfter); + Assert.Null(attr.DropCreatedBefore); + } + + [Fact] + public void PropertyInitializer_With_DropCreatedBefore_SetsDropCreatedBeforeWithoutThrowing() + { + // Arrange & Act + RetentionPolicyAttribute attr = new() { DropCreatedBefore = "30 days" }; + + // Assert + Assert.Null(attr.DropAfter); + Assert.Equal("30 days", attr.DropCreatedBefore); + } + + #endregion + #region Default Values Tests [Fact] diff --git a/tests/Eftdb.Tests/Conventions/CompressionPolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/CompressionPolicyConventionTests.cs new file mode 100644 index 0000000..03c3d14 --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/CompressionPolicyConventionTests.cs @@ -0,0 +1,600 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +public class CompressionPolicyConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Process_Minimal_After_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days")] + private class MinimalAfterEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalAfterContext : DbContext + { + public DbSet Entities => 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("MinimalAfterEntity"); + }); + } + } + + [Fact] + public void Should_Process_Minimal_After_Attribute() + { + // Arrange + using MinimalAfterContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalAfterEntity))!; + + // Assert + Assert.NotNull(entityType); + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)); + } + + #endregion + + #region Should_Process_CreatedBefore_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy(createdBefore: "30 days")] + private class CreatedBeforeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreatedBeforeContext : DbContext + { + public DbSet Entities => 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("CreatedBeforeEntity"); + }); + } + } + + [Fact] + public void Should_Process_CreatedBefore_Attribute() + { + // Arrange + using CreatedBeforeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CreatedBeforeEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)?.Value); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.After)); + } + + #endregion + + #region Should_Process_ScheduleInterval_From_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", ScheduleInterval = "12 hours")] + private class ScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : DbContext + { + public DbSet Entities => 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("ScheduleIntervalEntity"); + }); + } + } + + [Fact] + public void Should_Process_ScheduleInterval_From_Attribute() + { + // Arrange + using ScheduleIntervalContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScheduleIntervalEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("12 hours", entityType.FindAnnotation(CompressionPolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region Should_Process_InitialStart_From_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", InitialStart = "2025-10-01T03:00:00Z")] + private class InitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : DbContext + { + public DbSet Entities => 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("InitialStartEntity"); + }); + } + } + + [Fact] + public void Should_Process_InitialStart_From_Attribute() + { + // Arrange + using InitialStartContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(InitialStartEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + object? initialStartValue = entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + DateTime parsed = ((DateTime)initialStartValue).ToUniversalTime(); + Assert.Equal(2025, parsed.Year); + Assert.Equal(10, parsed.Month); + Assert.Equal(1, parsed.Day); + Assert.Equal(3, parsed.Hour); + } + + #endregion + + #region Should_Throw_When_InitialStart_Has_Invalid_Format + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", InitialStart = "not-a-date")] + private class InvalidInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InvalidInitialStartContext : DbContext + { + public DbSet Entities => 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("InvalidInitialStartEntity"); + }); + } + } + + [Fact] + public void Should_Throw_When_InitialStart_Has_Invalid_Format() + { + // Arrange & Act + InvalidOperationException ex = Assert.Throws(() => + { + using InvalidInitialStartContext context = new(); + _ = GetModel(context); + }); + + // Assert + Assert.Contains("InitialStart", ex.Message); + Assert.Contains("not a valid DateTime format", ex.Message); + } + + #endregion + + #region Should_Process_Timezone_From_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", Timezone = "Europe/Berlin")] + private class TimezoneEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimezoneContext : DbContext + { + public DbSet Entities => 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("TimezoneEntity"); + }); + } + } + + [Fact] + public void Should_Process_Timezone_From_Attribute() + { + // Arrange + using TimezoneContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(TimezoneEntity))!; + + // Assert + Assert.Equal("Europe/Berlin", entityType.FindAnnotation(CompressionPolicyAnnotations.Timezone)?.Value); + } + + #endregion + + #region Should_Process_IfNotExists_From_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", IfNotExists = true)] + private class IfNotExistsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IfNotExistsContext : DbContext + { + public DbSet Entities => 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("IfNotExistsEntity"); + }); + } + } + + [Fact] + public void Should_Process_IfNotExists_From_Attribute() + { + // Arrange + using IfNotExistsContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IfNotExistsEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.IfNotExists)?.Value); + } + + #endregion + + #region Should_Not_Annotate_IfNotExists_When_False + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days")] + private class IfNotExistsFalseEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IfNotExistsFalseContext : DbContext + { + public DbSet Entities => 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("IfNotExistsFalseEntity"); + }); + } + } + + [Fact] + public void Should_Not_Annotate_IfNotExists_When_False() + { + // Arrange + using IfNotExistsFalseContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(IfNotExistsFalseEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.IfNotExists)); + } + + #endregion + + #region Should_Throw_When_Both_After_And_CreatedBefore_Specified + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", CreatedBefore = "30 days")] + private class BothSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BothSpecifiedContext : DbContext + { + public DbSet Entities => 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("BothSpecifiedEntity"); + }); + } + } + + [Fact] + public void Should_Throw_When_Both_After_And_CreatedBefore_Specified() + { + // Arrange & Act + InvalidOperationException ex = Assert.Throws(() => + { + using BothSpecifiedContext context = new(); + _ = GetModel(context); + }); + + // Assert + Assert.Contains("mutually exclusive", ex.Message); + } + + #endregion + + #region Should_Throw_When_Neither_After_Nor_CreatedBefore_Specified + + [Hypertable("Timestamp")] + [CompressionPolicy] + private class NoneSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoneSpecifiedContext : DbContext + { + public DbSet Entities => 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("NoneSpecifiedEntity"); + }); + } + } + + [Fact] + public void Should_Throw_When_Neither_After_Nor_CreatedBefore_Specified() + { + // Arrange & Act + InvalidOperationException ex = Assert.Throws(() => + { + using NoneSpecifiedContext context = new(); + _ = GetModel(context); + }); + + // Assert + Assert.Contains("Exactly one of 'After' or 'CreatedBefore' must be specified", ex.Message); + } + + #endregion + + #region Should_Not_Process_Entity_Without_Attribute + + [Hypertable("Timestamp")] + private class PlainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class PlainContext : DbContext + { + public DbSet Entities => 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("PlainEntity"); + }); + } + } + + [Fact] + public void Should_Not_Process_Entity_Without_Attribute() + { + // Arrange + using PlainContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(PlainEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.After)); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)); + } + + #endregion + + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days", ScheduleInterval = "12 hours", Timezone = "UTC")] + private class EquivalenceAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [Hypertable("Timestamp")] + private class EquivalenceFluentEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttributeBasedContext : DbContext + { + public DbSet Entities => 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("EquivalenceAttributeEntity"); + }); + } + } + + private class FluentApiBasedContext : DbContext + { + public DbSet Entities => 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("EquivalenceAttributeEntity"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", scheduleInterval: "12 hours", timezone: "UTC"); + }); + } + } + + [Fact] + public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() + { + // Arrange + using AttributeBasedContext attrCtx = new(); + using FluentApiBasedContext fluentCtx = new(); + + // Act + IModel attrModel = GetModel(attrCtx); + IModel fluentModel = GetModel(fluentCtx); + IEntityType attrEntity = attrModel.FindEntityType(typeof(EquivalenceAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(EquivalenceFluentEntity))!; + + // Assert + Assert.Equal( + attrEntity.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value, + fluentEntity.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal( + attrEntity.FindAnnotation(CompressionPolicyAnnotations.After)?.Value, + fluentEntity.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + Assert.Equal( + attrEntity.FindAnnotation(CompressionPolicyAnnotations.ScheduleInterval)?.Value, + fluentEntity.FindAnnotation(CompressionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal( + attrEntity.FindAnnotation(CompressionPolicyAnnotations.Timezone)?.Value, + fluentEntity.FindAnnotation(CompressionPolicyAnnotations.Timezone)?.Value); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregateCompressionConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregateCompressionConventionTests.cs new file mode 100644 index 0000000..d1377e5 --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregateCompressionConventionTests.cs @@ -0,0 +1,458 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +public class ContinuousAggregateCompressionConventionTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + private const string ConnStr = "Host=localhost;Database=test;Username=test;Password=test"; + + // ── Attribute parity tests ──────────────────────────────────────────────── + + #region Attribute_EnableCompression_SetsAnnotation + + [ContinuousAggregate(MaterializedViewName = "attr_comp_cagg", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + EnableCompression = true)] + private class AttrCompAgg1 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AttrCompSource1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttrCompContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void Attribute_EnableCompression_SetsAnnotation() + { + // Arrange + using AttrCompContext1 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(AttrCompAgg1))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Attribute_CompressionSegmentBy_SetsAnnotationAndImpliesEnable + + [ContinuousAggregate(MaterializedViewName = "attr_seg_cagg", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + CompressionSegmentBy = ["Region"])] + private class AttrSegAgg2 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = ""; + } + + private class AttrSegSource2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttrSegContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void Attribute_CompressionSegmentBy_SetsAnnotationAndImpliesEnable() + { + // Arrange + using AttrSegContext2 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(AttrSegAgg2))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? segBy = et.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.NotNull(segBy); + Assert.Contains("Region", segBy); + } + + #endregion + + #region Attribute_CompressionOrderBy_SetsAnnotationAndImpliesEnable + + [ContinuousAggregate(MaterializedViewName = "attr_ord_cagg", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + CompressionOrderBy = ["TimeBucket DESC"])] + private class AttrOrdAgg3 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AttrOrdSource3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttrOrdContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void Attribute_CompressionOrderBy_SetsAnnotationAndImpliesEnable() + { + // Arrange + using AttrOrdContext3 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(AttrOrdAgg3))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? orderBy = et.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.NotNull(orderBy); + Assert.Contains("TimeBucket", orderBy); + Assert.Contains("DESC", orderBy); + } + + #endregion + + #region Attribute_MultipleSegmentBy_JoinedWithComma + + [ContinuousAggregate(MaterializedViewName = "attr_multi_seg_cagg", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + CompressionSegmentBy = ["Region", "DeviceId"])] + private class AttrMultiSegAgg4 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = ""; + public string DeviceId { get; set; } = ""; + } + + private class AttrMultiSegSource4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttrMultiSegContext4 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void Attribute_MultipleSegmentBy_JoinedWithComma() + { + // Arrange + using AttrMultiSegContext4 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(AttrMultiSegAgg4))!; + + // Assert + string? segBy = et.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.NotNull(segBy); + Assert.Contains("Region", segBy); + Assert.Contains("DeviceId", segBy); + } + + #endregion + + // ── CompressionPolicyConvention prerequisite guard ──────────────────────── + + #region CompressionPolicyConvention_Without_Compression_Throws_With_CAgg_Message + + [ContinuousAggregate(MaterializedViewName = "no_comp_cagg_policy", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp")] + [CompressionPolicy("7 days")] + private class NoCompCAggPolicy5 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoCompCAggPolicySource5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoCompCAggPolicyContext5 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void CompressionPolicyConvention_Without_Compression_Throws_With_CAgg_Message() + { + // Arrange + using NoCompCAggPolicyContext5 ctx = new(); + + // Act & Assert + InvalidOperationException ex = Assert.Throws(() => GetModel(ctx)); + Assert.Contains("continuous aggregate", ex.Message); + Assert.Contains("IsContinuousAggregate", ex.Message); + Assert.Contains("WithCompression", ex.Message); + } + + #endregion + + #region CompressionPolicyConvention_With_Compression_Does_Not_Throw + + [ContinuousAggregate(MaterializedViewName = "comp_cagg_policy_ok", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + EnableCompression = true)] + [CompressionPolicy("7 days")] + private class CompCAggPolicyOk6 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CompCAggPolicySource6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompCAggPolicyContext6 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + [Fact] + public void CompressionPolicyConvention_With_Compression_Does_Not_Throw() + { + // Arrange + using CompCAggPolicyContext6 ctx = new(); + + // Act + IModel model = GetModel(ctx); + + // Assert + Assert.NotNull(model.FindEntityType(typeof(CompCAggPolicyOk6))); + } + + #endregion + + #region Hypertable_With_CompressionPolicy_And_No_Compression_Does_Not_Throw + + [Hypertable("Timestamp")] + [CompressionPolicy("14 days")] + private class HtNoCompEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HtNoCompPolicyContext7 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("ht_no_comp7"); }); + } + } + + [Fact] + public void Hypertable_With_CompressionPolicy_And_No_Compression_Does_Not_Throw() + { + // Arrange + using HtNoCompPolicyContext7 ctx = new(); + + // Act + IModel model = GetModel(ctx); + + // Assert + Assert.NotNull(model.FindEntityType(typeof(HtNoCompEntity7))); + } + + #endregion + + // ── Attribute/fluent parity ─────────────────────────────────────────────── + + #region Attribute_And_FluentAPI_Produce_Same_CompressionAnnotations + + private class ParitySource8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "parity_attr_cagg", ParentName = "Metrics", + TimeBucketWidth = "1 hour", TimeBucketSourceColumn = "Timestamp", + EnableCompression = true, + CompressionSegmentBy = ["Region"], + CompressionOrderBy = ["TimeBucket DESC"])] + private class ParityAttrAgg8 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = ""; + } + + private class ParityFluentAgg8 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = ""; + } + + private class ParityAttrContext8 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => e.HasNoKey()); + } + } + + private class ParityFluentContext8 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("Metrics"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("parity_fluent_cagg", "1 hour", x => x.Timestamp) + .WithCompressionSegmentBy(x => x.Region) + .WithCompressionOrderBy(s => s.ByDescending(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void Attribute_And_FluentAPI_Produce_Same_CompressionAnnotations() + { + // Arrange + using ParityAttrContext8 attrCtx = new(); + using ParityFluentContext8 fluentCtx = new(); + + // Act + IModel attrModel = GetModel(attrCtx); + IModel fluentModel = GetModel(fluentCtx); + IEntityType attrEt = attrModel.FindEntityType(typeof(ParityAttrAgg8))!; + IEntityType fluentEt = fluentModel.FindEntityType(typeof(ParityFluentAgg8))!; + + // Assert + Assert.Equal( + attrEt.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value, + fluentEt.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + + string? attrSeg = attrEt.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + string? fluentSeg = fluentEt.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.NotNull(attrSeg); + Assert.NotNull(fluentSeg); + Assert.Contains("Region", attrSeg); + Assert.Contains("Region", fluentSeg); + + string? attrOrd = attrEt.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + string? fluentOrd = fluentEt.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.NotNull(attrOrd); + Assert.NotNull(fluentOrd); + Assert.Contains("DESC", attrOrd); + Assert.Contains("DESC", fluentOrd); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs index d230c1e..26a1be8 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregateConventionTests.cs @@ -1105,7 +1105,7 @@ public void Should_Skip_CA_Annotations_For_Property_Bag_Entity() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType("PropertyBagCaTestEntity")!; - // Act + Assert + // Act & Assert Assert.NotNull(entityType); Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)); Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.ParentName)); diff --git a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs index b612723..30331f7 100644 --- a/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/ContinuousAggregatePolicyConventionTests.cs @@ -598,11 +598,10 @@ public void Should_Not_Apply_Default_Values() IEntityType entityType = model.FindEntityType(typeof(AggregateEntity12))!; // Assert - // Default values should not have annotations - Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists)); // Default is false - Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch)); // Default is 1 - Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)); // Default is 0 - Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)); // Default is true + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.BucketsPerBatch)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.MaxBatchesPerExecution)); + Assert.Null(entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)); } #endregion @@ -795,7 +794,6 @@ private class MetricEntity14 public double Value { get; set; } } - // Note: Missing [ContinuousAggregate] attribute [ContinuousAggregatePolicy(StartOffset = "1 month", EndOffset = "1 hour", ScheduleInterval = "1 hour")] private class AggregateEntity14 { @@ -839,11 +837,7 @@ public void Should_Require_ContinuousAggregate_Attribute() IEntityType entityType = model.FindEntityType(typeof(AggregateEntity14))!; // Assert - // The policy annotation should be applied even without ContinuousAggregate attribute - // This is because the convention processes the attribute independently Assert.Equal(true, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.HasRefreshPolicy)?.Value); - - // However, the entity won't be recognized as a continuous aggregate without the ContinuousAggregate attribute Assert.Null(entityType.FindAnnotation(ContinuousAggregateAnnotations.MaterializedViewName)); } @@ -1197,4 +1191,61 @@ public void Should_Not_Set_BucketsPerBatch_Annotation_When_Equal_To_Default_One( } #endregion + + #region Should_Set_RefreshNewestFirst_Annotation_When_Set_To_False + + private class MetricEntity17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [ContinuousAggregate(MaterializedViewName = "hourly_metrics17", ParentName = "Metrics17")] + [ContinuousAggregatePolicy(StartOffset = "1 month", RefreshNewestFirst = false)] + private class AggregateEntity17 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class RefreshNewestFirstFalseContext17 : 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.ToTable("Metrics17"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + }); + } + } + + [Fact] + public void Should_Set_RefreshNewestFirst_Annotation_When_Set_To_False() + { + // Arrange + using RefreshNewestFirstFalseContext17 context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(AggregateEntity17))!; + + // Assert + Assert.Equal(false, entityType.FindAnnotation(ContinuousAggregatePolicyAnnotations.RefreshNewestFirst)?.Value); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Conventions/HypertableColumnstoreConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableColumnstoreConventionTests.cs new file mode 100644 index 0000000..2e7dce9 --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/HypertableColumnstoreConventionTests.cs @@ -0,0 +1,894 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +public class HypertableColumnstoreConventionTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + // ── Fluent API: WithSparseIndex ── + + #region Should_Set_SparseIndex_Annotation_Via_FluentApi + + private class SparseIndexFluentEntity { public DateTime Ts { get; set; } } + + private class SparseIndexFluentContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_sparse_fluent"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id), minmax(temperature)"); + }); + } + + [Fact] + public void Should_Set_SparseIndex_Annotation_Via_FluentApi() + { + // Arrange + using SparseIndexFluentContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SparseIndexFluentEntity))!; + + // Assert + Assert.Equal("bloom(device_id), minmax(temperature)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + } + + #endregion + + #region Should_Implicitly_Enable_Compression_When_SparseIndex_Set_Via_FluentApi + + private class SparseIndexImplicitCompressionEntity { public DateTime Ts { get; set; } } + + private class SparseIndexImplicitCompressionContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_sparse_implicit_comp"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(col)"); + }); + } + + [Fact] + public void Should_Implicitly_Enable_Compression_When_SparseIndex_Set_Via_FluentApi() + { + // Arrange + using SparseIndexImplicitCompressionContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SparseIndexImplicitCompressionEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Preserve_Empty_String_SparseIndex_Via_FluentApi + + private class EmptySparseIndexFluentEntity { public DateTime Ts { get; set; } } + + private class EmptySparseIndexFluentContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_sparse_empty"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(string.Empty); + }); + } + + [Fact] + public void Should_Preserve_Empty_String_SparseIndex_Via_FluentApi() + { + // Arrange + using EmptySparseIndexFluentContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EmptySparseIndexFluentEntity))!; + + // Assert + Assert.Equal(string.Empty, entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + // ── Fluent API: WithCompressChunkTimeInterval ── + + #region Should_Set_CompressChunkTimeInterval_Annotation_Via_FluentApi + + private class CompressChunkIntervalFluentEntity { public DateTime Ts { get; set; } } + + private class CompressChunkIntervalFluentContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_ccti_fluent"); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + [Fact] + public void Should_Set_CompressChunkTimeInterval_Annotation_Via_FluentApi() + { + // Arrange + using CompressChunkIntervalFluentContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressChunkIntervalFluentEntity))!; + + // Assert + Assert.Equal("24 hours", entityType.FindAnnotation(HypertableAnnotations.CompressChunkTimeInterval)?.Value); + } + + #endregion + + #region Should_Implicitly_Enable_Compression_When_CompressChunkTimeInterval_Set_Via_FluentApi + + private class CompressChunkIntervalImplicitCompressionEntity { public DateTime Ts { get; set; } } + + private class CompressChunkIntervalImplicitCompressionContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_ccti_implicit_comp"); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("7 days"); + }); + } + + [Fact] + public void Should_Implicitly_Enable_Compression_When_CompressChunkTimeInterval_Set_Via_FluentApi() + { + // Arrange + using CompressChunkIntervalImplicitCompressionContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressChunkIntervalImplicitCompressionEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Throw_When_WithCompressChunkTimeInterval_Called_With_Null + + private class CompressChunkIntervalNullThrowEntity { public DateTime Ts { get; set; } } + + private class CompressChunkIntervalNullThrowContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_ccti_null_throw"); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval(null!); + }); + } + + [Fact] + public void Should_Throw_When_WithCompressChunkTimeInterval_Called_With_Null() + { + // Arrange / Act / Assert + Assert.Throws(() => + { + using CompressChunkIntervalNullThrowContext context = new(); + _ = GetModel(context); + }); + } + + #endregion + + #region Should_Throw_When_WithCompressChunkTimeInterval_Called_With_Whitespace + + private class CompressChunkIntervalWhitespaceThrowEntity { public DateTime Ts { get; set; } } + + private class CompressChunkIntervalWhitespaceThrowContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_ccti_ws_throw"); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval(" "); + }); + } + + [Fact] + public void Should_Throw_When_WithCompressChunkTimeInterval_Called_With_Whitespace() + { + // Arrange / Act / Assert + Assert.Throws(() => + { + using CompressChunkIntervalWhitespaceThrowContext context = new(); + _ = GetModel(context); + }); + } + + #endregion + + // ── Attribute: SparseIndex ── + + #region Should_Set_SparseIndex_Annotation_Via_Attribute + + [Hypertable("Ts")] + [SparseIndex(ESparseIndexType.Bloom, "device_id")] + private class SparseIndexAttributeEntity { public DateTime Ts { get; set; } } + + private class SparseIndexAttributeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_sparse_attr"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Set_SparseIndex_Annotation_Via_Attribute() + { + // Arrange + using SparseIndexAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SparseIndexAttributeEntity))!; + + // Assert + Assert.Equal("bloom(device_id)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Preserve_Empty_SparseIndex_Via_Attribute + + [Hypertable("Ts", DisableAutoSparseIndexes = true)] + private class EmptySparseIndexAttributeEntity { public DateTime Ts { get; set; } } + + private class EmptySparseIndexAttributeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_sparse_attr_empty"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Preserve_Empty_SparseIndex_Via_Attribute() + { + // Arrange + using EmptySparseIndexAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EmptySparseIndexAttributeEntity))!; + + // Assert + Assert.Equal(string.Empty, entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Not_Set_SparseIndex_Annotation_When_Attribute_Property_Is_Null + + [Hypertable("Ts")] + private class NullSparseIndexAttributeEntity { public DateTime Ts { get; set; } } + + private class NullSparseIndexAttributeContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("conv_sparse_attr_null"); }); + } + + [Fact] + public void Should_Not_Set_SparseIndex_Annotation_When_Attribute_Property_Is_Null() + { + // Arrange + using NullSparseIndexAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NullSparseIndexAttributeEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + // ── Attribute: CompressChunkTimeInterval ── + + #region Should_Set_CompressChunkTimeInterval_Annotation_Via_Attribute + + [Hypertable("Ts", CompressChunkTimeInterval = "24 hours")] + private class CompressChunkIntervalAttributeEntity { public DateTime Ts { get; set; } } + + private class CompressChunkIntervalAttributeContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("conv_ccti_attr"); }); + } + + [Fact] + public void Should_Set_CompressChunkTimeInterval_Annotation_Via_Attribute() + { + // Arrange + using CompressChunkIntervalAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CompressChunkIntervalAttributeEntity))!; + + // Assert + Assert.Equal("24 hours", entityType.FindAnnotation(HypertableAnnotations.CompressChunkTimeInterval)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Attribute_Property_Is_Null + + [Hypertable("Ts")] + private class NullCompressChunkIntervalAttributeEntity { public DateTime Ts { get; set; } } + + private class NullCompressChunkIntervalAttributeContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("conv_ccti_attr_null"); }); + } + + [Fact] + public void Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Attribute_Property_Is_Null() + { + // Arrange + using NullCompressChunkIntervalAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NullCompressChunkIntervalAttributeEntity))!; + + // Assert + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressChunkTimeInterval)); + } + + #endregion + + // ── Attribute path: Fluent vs Attribute equivalence ── + + #region FluentApi_And_Attribute_Produce_Same_Annotations_For_SparseIndex + + [Hypertable("Ts")] + [SparseIndex(ESparseIndexType.Bloom, "device_id")] + private class SparseIndexEquivAttributeEntity { public DateTime Ts { get; set; } } + + private class SparseIndexEquivFluentEntity { public DateTime Ts { get; set; } } + + private class SparseIndexEquivAttributeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("equiv_sparse"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + private class SparseIndexEquivFluentContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("equiv_sparse"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + [Fact] + public void FluentApi_And_Attribute_Produce_Same_Annotations_For_SparseIndex() + { + // Arrange + using SparseIndexEquivAttributeContext attrContext = new(); + using SparseIndexEquivFluentContext fluentContext = new(); + + // Act + IEntityType attrEntity = GetModel(attrContext).FindEntityType(typeof(SparseIndexEquivAttributeEntity))!; + IEntityType fluentEntity = GetModel(fluentContext).FindEntityType(typeof(SparseIndexEquivFluentEntity))!; + + // Assert + Assert.Equal( + attrEntity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal( + attrEntity.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value, + fluentEntity.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + // ── Typed fluent API: selector form ── + + #region Should_Set_Bloom_Annotation_Via_Selector_FluentApi + + private class SelectorBloomEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SelectorBloomContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_selector_bloom"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(s => s.Bloom(x => x.DeviceId)); + }); + } + + [Fact] + public void Should_Set_Bloom_Annotation_Via_Selector_FluentApi() + { + // Arrange + using SelectorBloomContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SelectorBloomEntity))!; + + // Assert + Assert.Equal("bloom(DeviceId)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Set_MinMax_Annotation_Via_Selector_FluentApi + + private class SelectorMinMaxEntity { public DateTime Ts { get; set; } public double Value { get; set; } } + + private class SelectorMinMaxContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_selector_minmax"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(s => s.MinMax(x => x.Value)); + }); + } + + [Fact] + public void Should_Set_MinMax_Annotation_Via_Selector_FluentApi() + { + // Arrange + using SelectorMinMaxContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SelectorMinMaxEntity))!; + + // Assert + Assert.Equal("minmax(Value)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Set_Mixed_Bloom_MinMax_Annotation_Via_Selector_FluentApi + + private class SelectorMixedEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public double Value { get; set; } } + + private class SelectorMixedContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_selector_mixed"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex( + s => s.Bloom(x => x.DeviceId), + s => s.MinMax(x => x.Value)); + }); + } + + [Fact] + public void Should_Set_Mixed_Bloom_MinMax_Annotation_Via_Selector_FluentApi() + { + // Arrange + using SelectorMixedContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SelectorMixedEntity))!; + + // Assert + Assert.Equal("bloom(DeviceId), minmax(Value)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Set_Composite_Bloom_Annotation_Via_Selector_FluentApi + + private class SelectorCompositeBloomEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public string TenantId { get; set; } = string.Empty; } + + private class SelectorCompositeBloomContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_selector_composite_bloom"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(s => s.Bloom(x => x.DeviceId, x => x.TenantId)); + }); + } + + [Fact] + public void Should_Set_Composite_Bloom_Annotation_Via_Selector_FluentApi() + { + // Arrange + using SelectorCompositeBloomContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SelectorCompositeBloomEntity))!; + + // Assert + Assert.Equal("bloom(DeviceId,TenantId)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Set_SparseIndex_Annotation_Via_Array_FluentApi + + private class ArrayFormEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public double Value { get; set; } } + + private class ArrayFormContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_array_form"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex( + new SparseIndex(ESparseIndexType.Bloom, ["DeviceId"]), + new SparseIndex(ESparseIndexType.MinMax, ["Value"])); + }); + } + + [Fact] + public void Should_Set_SparseIndex_Annotation_Via_Array_FluentApi() + { + // Arrange + using ArrayFormContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ArrayFormEntity))!; + + // Assert + Assert.Equal("bloom(DeviceId), minmax(Value)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Set_Empty_Annotation_Via_WithoutAutoSparseIndexes_FluentApi + + private class WithoutAutoSparseIndexesEntity { public DateTime Ts { get; set; } } + + private class WithoutAutoSparseIndexesContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_without_auto_si"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithoutAutoSparseIndexes(); + }); + } + + [Fact] + public void Should_Set_Empty_Annotation_Via_WithoutAutoSparseIndexes_FluentApi() + { + // Arrange + using WithoutAutoSparseIndexesContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(WithoutAutoSparseIndexesEntity))!; + + // Assert + Assert.Equal(string.Empty, entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + // ── Attribute convention: multiple [SparseIndex] attributes ── + + #region Should_Combine_Multiple_SparseIndex_Attributes_In_Declaration_Order + + [Hypertable("Ts")] + [SparseIndex(ESparseIndexType.Bloom, "DeviceId")] + [SparseIndex(ESparseIndexType.MinMax, "Value")] + private class MultiSparseIndexAttributeEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public double Value { get; set; } } + + private class MultiSparseIndexAttributeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_multi_si_attr"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Combine_Multiple_SparseIndex_Attributes_In_Declaration_Order() + { + // Arrange + using MultiSparseIndexAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MultiSparseIndexAttributeEntity))!; + + // Assert + Assert.Equal("bloom(DeviceId), minmax(Value)", entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Throw_When_SparseIndex_Attribute_And_DisableAutoSparseIndexes_Both_Set + + [Hypertable("Ts", DisableAutoSparseIndexes = true)] + [SparseIndex(ESparseIndexType.Bloom, "DeviceId")] + private class ConflictingSparseIndexEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class ConflictingSparseIndexContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_conflict_si"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Throw_When_SparseIndex_Attribute_And_DisableAutoSparseIndexes_Both_Set() + { + // Arrange / Act / Assert + Assert.Throws(() => + { + using ConflictingSparseIndexContext context = new(); + _ = GetModel(context); + }); + } + + #endregion + + #region Should_Set_Empty_Annotation_Via_DisableAutoSparseIndexes_Attribute + + [Hypertable("Ts", DisableAutoSparseIndexes = true)] + private class DisableAutoSparseIndexesAttributeEntity { public DateTime Ts { get; set; } } + + private class DisableAutoSparseIndexesAttributeContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_disable_auto_si"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Set_Empty_Annotation_Via_DisableAutoSparseIndexes_Attribute() + { + // Arrange + using DisableAutoSparseIndexesAttributeContext context = new(); + + // Act + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DisableAutoSparseIndexesAttributeEntity))!; + + // Assert + Assert.Equal(string.Empty, entityType.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + // ── Validation: minmax with multiple columns ── + + #region Should_Throw_When_MinMax_Has_Multiple_Columns_Via_String_Overload + + private class MinMaxMultiColumnEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public double Value { get; set; } } + + private class MinMaxMultiColumnContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_minmax_multicol"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("minmax(DeviceId,Value)"); + }); + } + + [Fact] + public void Should_Throw_When_MinMax_Has_Multiple_Columns_Via_String_Overload() + { + // Arrange / Act / Assert + InvalidOperationException ex = Assert.Throws(() => + { + using MinMaxMultiColumnContext context = new(); + _ = GetModel(context); + }); + Assert.Contains("minmax supports a single column only", ex.Message); + Assert.Contains("bloom(...)", ex.Message); + } + + #endregion + + #region Should_Throw_When_MinMax_Has_Multiple_Columns_Via_SparseIndex_Attribute + + [Hypertable("Ts")] + [SparseIndex(ESparseIndexType.MinMax, "DeviceId", "Value")] + private class MinMaxMultiColAttrEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } public double Value { get; set; } } + + private class MinMaxMultiColAttrContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("conv_minmax_multicol_attr"); + e.IsHypertable(x => x.Ts).WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public void Should_Throw_When_MinMax_Has_Multiple_Columns_Via_SparseIndex_Attribute() + { + // Arrange / Act / Assert + InvalidOperationException ex = Assert.Throws(() => + { + using MinMaxMultiColAttrContext context = new(); + _ = GetModel(context); + }); + Assert.Contains("minmax supports a single column only", ex.Message); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs index 0987911..c4b6d54 100644 --- a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs @@ -176,9 +176,7 @@ public void Should_Process_Hypertable_With_CompressionSegmentBy() IEntityType entityType = model.FindEntityType(typeof(SegmentByEntity))!; Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); - // Should implicitly enable compression Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); - // Should join array with comma space Assert.Equal("TenantId, DeviceId", entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value); } @@ -219,9 +217,7 @@ public void Should_Process_Hypertable_With_CompressionOrderBy() IEntityType entityType = model.FindEntityType(typeof(OrderByEntity))!; Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); - // Should implicitly enable compression Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); - // Should preserve raw SQL strings joined by comma space Assert.Equal("Timestamp DESC, Value ASC NULLS LAST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); } @@ -262,11 +258,7 @@ public void Should_Not_Apply_Compression_Settings_When_Arrays_Empty() IEntityType entityType = model.FindEntityType(typeof(EmptyCompressionSettingsEntity))!; Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); - - // Should NOT enable compression because arrays are empty Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); - - // Should NOT set the segment/order annotations Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)); Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)); } @@ -471,9 +463,7 @@ public void Should_Not_Apply_ChunkTimeInterval_When_Empty() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(EmptyChunkIntervalEntity))!; - // ChunkTimeInterval annotation should be null when the attribute property is empty Assert.Null(entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)); - // But IsHypertable should still be set Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); } @@ -514,10 +504,8 @@ public void Should_Not_Enable_Compression_For_Empty_ChunkSkipColumns_Array() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(EmptyChunkSkipColumnsEntity))!; - // Empty ChunkSkipColumns should NOT enable compression or set ChunkSkipColumns annotation Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); Assert.Null(entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)); - // But IsHypertable should still be set Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); } @@ -557,7 +545,6 @@ public void Should_Verify_EnableCompression_False_Explicitly() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(NoCompressionEntity))!; - // EnableCompression should be null (not set) when false Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); } @@ -598,8 +585,6 @@ public void Should_Apply_Default_ChunkTimeInterval_When_Not_Set() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(DefaultChunkIntervalEntity))!; - // When ChunkTimeInterval is not explicitly set, it uses the DefaultValues.ChunkTimeInterval - // and should still be applied as an annotation Assert.Equal(DefaultValues.ChunkTimeInterval, entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value); } @@ -639,7 +624,6 @@ public void Should_Handle_Single_ChunkSkipColumn() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(SingleChunkSkipColumnEntity))!; - // Single column should be stored without extra commas Assert.Equal("Value", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); } @@ -806,9 +790,7 @@ public void Should_Not_Apply_MigrateData_When_False() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(MigrateDataFalseEntity))!; - // MigrateData annotation should be null when the attribute property is false Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData)); - // But IsHypertable should still be set Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); } @@ -848,9 +830,7 @@ public void Should_Not_Apply_MigrateData_By_Default() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(MigrateDataDefaultEntity))!; - // When MigrateData is not explicitly set in attribute, annotation should be null Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData)); - // But IsHypertable should still be set Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); } diff --git a/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs index 1e25a1c..3d995bf 100644 --- a/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs @@ -676,7 +676,7 @@ public void Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified_ IModel model = GetModel(context); }); - Assert.Contains("RetentionPolicy: Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified.", exception.Message); + Assert.Contains("Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified", exception.Message); } #endregion diff --git a/tests/Eftdb.Tests/Conventions/SparseIndexValidationConventionTests.cs b/tests/Eftdb.Tests/Conventions/SparseIndexValidationConventionTests.cs new file mode 100644 index 0000000..7ce2192 --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/SparseIndexValidationConventionTests.cs @@ -0,0 +1,1018 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +public class SparseIndexValidationConventionTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + // ── SplitSparseIndexEntries ── + + #region Should_Split_Simple_Entries + + [Fact] + public void Should_Split_Simple_Entries() + { + // Arrange + string input = "bloom(a),minmax(b)"; + + // Act + List entries = [.. CompressionAnnotationExtractor.SplitSparseIndexEntries(input)]; + + // Assert + Assert.Equal(2, entries.Count); + Assert.Equal("bloom(a)", entries[0]); + Assert.Equal("minmax(b)", entries[1]); + } + + #endregion + + #region Should_Not_Split_Comma_Inside_Parens + + [Fact] + public void Should_Not_Split_Comma_Inside_Parens() + { + // Arrange + string input = "bloom(a, b),minmax(c)"; + + // Act + List entries = [.. CompressionAnnotationExtractor.SplitSparseIndexEntries(input)]; + + // Assert + Assert.Equal(2, entries.Count); + Assert.Equal("bloom(a, b)", entries[0]); + Assert.Equal("minmax(c)", entries[1]); + } + + #endregion + + #region Should_Return_Single_Entry_When_No_Top_Level_Comma + + [Fact] + public void Should_Return_Single_Entry_When_No_Top_Level_Comma() + { + // Arrange + string input = "bloom(a,b,c)"; + + // Act + List entries = [.. CompressionAnnotationExtractor.SplitSparseIndexEntries(input)]; + + // Assert + Assert.Single(entries); + Assert.Equal("bloom(a,b,c)", entries[0]); + } + + #endregion + + #region Should_Return_Single_Entry_For_Single_Token + + [Fact] + public void Should_Return_Single_Entry_For_Single_Token() + { + // Arrange + string input = "bloom(col)"; + + // Act + List entries = [.. CompressionAnnotationExtractor.SplitSparseIndexEntries(input)]; + + // Assert + Assert.Single(entries); + Assert.Equal("bloom(col)", entries[0]); + } + + #endregion + + #region Should_Return_Empty_Sequence_For_Empty_String + + [Fact] + public void Should_Return_Empty_Sequence_For_Empty_String() + { + // Arrange + string input = string.Empty; + + // Act + List entries = [.. CompressionAnnotationExtractor.SplitSparseIndexEntries(input)]; + + // Assert + Assert.Empty(entries); + } + + #endregion + + // ── Absent annotation / empty string ── + + #region Should_Not_Throw_When_SparseIndex_Annotation_Absent + + private class NoSparseAnnotationEntity { public DateTime Ts { get; set; } } + + private class NoSparseAnnotationContext : 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(e => + { + e.ToTable("val_sparse_absent"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public void Should_Not_Throw_When_SparseIndex_Annotation_Absent() + { + // Arrange / Act + using NoSparseAnnotationContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(NoSparseAnnotationEntity))!; + Assert.Null(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + #region Should_Throw_When_SparseIndex_Set_Without_CompressionOrderBy + + private class SparseNoOrderByEntity { public DateTime Ts { get; set; } } + + private class SparseNoOrderByContext : 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(e => + { + e.ToTable("val_sparse_no_orderby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithSparseIndex("bloom(col)"); + }); + } + + [Fact] + public void Should_Throw_When_SparseIndex_Set_Without_CompressionOrderBy() + { + // Arrange + using SparseNoOrderByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("sparse_index requires compress_orderby to be configured", exception.Message); + } + + #endregion + + #region Should_Throw_When_EmptySparseIndex_Set_Without_CompressionOrderBy + + private class SparseEmptyNoOrderByEntity { public DateTime Ts { get; set; } } + + private class SparseEmptyNoOrderByContext : 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(e => + { + e.ToTable("val_sparse_empty_no_orderby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithSparseIndex(string.Empty); + }); + } + + [Fact] + public void Should_Throw_When_EmptySparseIndex_Set_Without_CompressionOrderBy() + { + // Arrange + using SparseEmptyNoOrderByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("sparse_index requires compress_orderby to be configured", exception.Message); + } + + #endregion + + #region Should_Not_Throw_When_SparseIndex_Is_Empty_String + + private class SparseEmptyStringEntity { public DateTime Ts { get; set; } } + + private class SparseEmptyStringContext : 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(e => + { + e.ToTable("val_sparse_empty"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(string.Empty); + }); + } + + [Fact] + public void Should_Not_Throw_When_SparseIndex_Is_Empty_String() + { + // Arrange / Act + using SparseEmptyStringContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(SparseEmptyStringEntity))!; + Assert.Equal(string.Empty, entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)?.Value); + } + + #endregion + + // ── Valid configurations that must NOT throw ── + + #region Should_Not_Throw_For_Valid_Mixed_Entries + + private class ValidMixedEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public double Temperature { get; set; } + } + + private class ValidMixedContext : 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(e => + { + e.ToTable("val_sparse_valid_mixed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(TenantId), minmax(Temperature)"); + }); + } + + [Fact] + public void Should_Not_Throw_For_Valid_Mixed_Entries() + { + // Arrange / Act + using ValidMixedContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(ValidMixedEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + #region Should_Not_Throw_For_Entries_On_Columns_Without_Segmentby_Or_Orderby + + private class UnrelatedColumnsEntity + { + public DateTime Ts { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class UnrelatedColumnsContext : 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(e => + { + e.ToTable("val_sparse_unrelated"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(DeviceId), minmax(Value)"); + }); + } + + [Fact] + public void Should_Not_Throw_For_Entries_On_Columns_Without_Segmentby_Or_Orderby() + { + // Arrange / Act + using UnrelatedColumnsContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(UnrelatedColumnsEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + // ── Rule 1: bloom including a segmentby column ── + + #region Should_Throw_Rule1_When_Bloom_Includes_SegmentBy_Column + + private class BloomSegmentByEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class BloomSegmentByContext : 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(e => + { + e.ToTable("val_rule1_bloom_segmentby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(TenantId)"); + }); + } + + [Fact] + public void Should_Throw_Rule1_When_Bloom_Includes_SegmentBy_Column() + { + // Arrange + using BloomSegmentByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("includes compress_segmentby column", exception.Message); + } + + #endregion + + #region Should_Throw_Rule1_When_Composite_Bloom_Includes_SegmentBy_Column + + private class CompositeBloomSegmentByEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class CompositeBloomSegmentByContext : 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(e => + { + e.ToTable("val_rule1_composite_bloom_segmentby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(DeviceId,TenantId)"); + }); + } + + [Fact] + public void Should_Throw_Rule1_When_Composite_Bloom_Includes_SegmentBy_Column() + { + // Arrange + using CompositeBloomSegmentByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("includes compress_segmentby column", exception.Message); + } + + #endregion + + // ── Rule 2: single-column bloom on an orderby column ── + + #region Should_Throw_Rule2_When_Single_Bloom_On_OrderBy_Column + + private class SingleBloomOrderByEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class SingleBloomOrderByContext : 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(e => + { + e.ToTable("val_rule2_single_bloom_orderby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Value)]) + .WithSparseIndex("bloom(Value)"); + }); + } + + [Fact] + public void Should_Throw_Rule2_When_Single_Bloom_On_OrderBy_Column() + { + // Arrange + using SingleBloomOrderByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("is redundant because", exception.Message); + Assert.Contains("compress_orderby column", exception.Message); + } + + #endregion + + #region Should_Not_Throw_Rule2_Exempt_Composite_Bloom_With_OrderBy_Column + + private class CompositeBloomOrderByExemptEntity + { + public DateTime Ts { get; set; } + public int Region { get; set; } + public double Value { get; set; } + } + + private class CompositeBloomOrderByExemptContext : 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(e => + { + e.ToTable("val_rule2_exempt_composite"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Value)]) + .WithSparseIndex("bloom(Region,Value)"); + }); + } + + [Fact] + public void Should_Not_Throw_Rule2_Exempt_Composite_Bloom_With_OrderBy_Column() + { + // Arrange / Act + using CompositeBloomOrderByExemptContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(CompositeBloomOrderByExemptEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + // ── Rule 3: minmax on a segmentby column ── + + #region Should_Throw_Rule3_When_Minmax_On_SegmentBy_Column + + private class MinmaxSegmentByEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class MinmaxSegmentByContext : 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(e => + { + e.ToTable("val_rule3_minmax_segmentby"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("minmax(TenantId)"); + }); + } + + [Fact] + public void Should_Throw_Rule3_When_Minmax_On_SegmentBy_Column() + { + // Arrange + using MinmaxSegmentByContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("includes compress_segmentby column", exception.Message); + } + + #endregion + + // ── Rule 4: minmax on an orderby column is ALLOWED ── + + #region Should_Not_Throw_Rule4_Minmax_On_OrderBy_Column_Is_Allowed + + private class MinmaxOrderByAllowedEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class MinmaxOrderByAllowedContext : 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(e => + { + e.ToTable("val_rule4_minmax_orderby_ok"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Value)]) + .WithSparseIndex("minmax(Value)"); + }); + } + + [Fact] + public void Should_Not_Throw_Rule4_Minmax_On_OrderBy_Column_Is_Allowed() + { + // Arrange / Act + using MinmaxOrderByAllowedContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(MinmaxOrderByAllowedEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + // ── Rule 5: duplicate single-column entries ── + + #region Should_Throw_Rule5_When_Two_Single_Column_Entries_Target_Same_Column + + private class DuplicateSingleColumnEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class DuplicateSingleColumnContext : 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(e => + { + e.ToTable("val_rule5_duplicate_single"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(Value), minmax(Value)"); + }); + } + + [Fact] + public void Should_Throw_Rule5_When_Two_Single_Column_Entries_Target_Same_Column() + { + // Arrange + using DuplicateSingleColumnContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("duplicate single-column sparse index entries", exception.Message); + } + + #endregion + + #region Should_Not_Throw_Rule5_Exempt_Composite_Entry_Plus_SingleColumn_Same_Column + + private class CompositePlusSingleExemptEntity + { + public DateTime Ts { get; set; } + public int Region { get; set; } + public int Dev { get; set; } + public double Value { get; set; } + } + + private class CompositePlusSingleExemptContext : 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(e => + { + e.ToTable("val_rule5_composite_exempt"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(Region,Dev), bloom(Dev)"); + }); + } + + [Fact] + public void Should_Not_Throw_Rule5_Exempt_Composite_Entry_Plus_SingleColumn_Same_Column() + { + // Arrange / Act + using CompositePlusSingleExemptContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(CompositePlusSingleExemptEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion + + // ── Rule 6: malformed entries ── + + #region Should_Throw_Rule6_When_Entry_Missing_Parens + + private class MissingParensEntity { public DateTime Ts { get; set; } } + + private class MissingParensContext : 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(e => + { + e.ToTable("val_rule6_missing_parens"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom_col"); + }); + } + + [Fact] + public void Should_Throw_Rule6_When_Entry_Missing_Parens() + { + // Arrange + using MissingParensContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("missing or unbalanced parentheses", exception.Message); + } + + #endregion + + #region Should_Throw_Rule6_When_Entry_Has_Unknown_Function + + private class UnknownFunctionEntity { public DateTime Ts { get; set; } } + + private class UnknownFunctionContext : 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(e => + { + e.ToTable("val_rule6_unknown_func"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("brin(col)"); + }); + } + + [Fact] + public void Should_Throw_Rule6_When_Entry_Has_Unknown_Function() + { + // Arrange + using UnknownFunctionContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("only 'bloom' and 'minmax' are supported", exception.Message); + } + + #endregion + + #region Should_Throw_Rule6_When_Entry_Has_Empty_Argument_List + + private class EmptyArgListEntity { public DateTime Ts { get; set; } } + + private class EmptyArgListContext : 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(e => + { + e.ToTable("val_rule6_empty_args"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom()"); + }); + } + + [Fact] + public void Should_Throw_Rule6_When_Entry_Has_Empty_Argument_List() + { + // Arrange + using EmptyArgListContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("empty argument list", exception.Message); + } + + #endregion + + // ── Column name resolution: CLR names on segmentby/orderby, column names in sparse index ── + + #region Should_Resolve_Column_Names_When_SegmentBy_Uses_CLR_Name_And_SparseIndex_Uses_Column_Name + + private class ClrVsColumnNameEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class ClrVsColumnNameContext : 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(e => + { + e.ToTable("val_clr_vs_col"); + e.HasNoKey(); + e.Property(x => x.TenantId).HasColumnName("tenant_id"); + e.IsHypertable(x => x.Ts) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(tenant_id)"); + }); + } + + [Fact] + public void Should_Resolve_Column_Names_When_SegmentBy_Uses_CLR_Name_And_SparseIndex_Uses_Column_Name() + { + // Arrange + using ClrVsColumnNameContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("includes compress_segmentby column", exception.Message); + Assert.Contains("tenant_id", exception.Message); + } + + #endregion + + #region Should_Resolve_Column_Names_When_SparseIndex_Uses_CLR_Name_And_SegmentBy_Uses_Column_Name + + private class SparseClrSegmentByColNameEntity + { + public DateTime Ts { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class SparseClrSegmentByColNameContext : 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(e => + { + e.ToTable("val_sparse_clr_seg_col"); + e.HasNoKey(); + e.Property(x => x.TenantId).HasColumnName("tenant_id"); + e.IsHypertable(x => x.Ts) + .HasAnnotation(HypertableAnnotations.CompressionSegmentBy, "tenant_id") + .HasAnnotation(HypertableAnnotations.CompressionOrderBy, "Ts DESC NULLS LAST") + .HasAnnotation(HypertableAnnotations.EnableCompression, true) + .WithSparseIndex("bloom(TenantId)"); + }); + } + + [Fact] + public void Should_Resolve_Column_Names_When_SparseIndex_Uses_CLR_Name_And_SegmentBy_Uses_Column_Name() + { + // Arrange + using SparseClrSegmentByColNameContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("includes compress_segmentby column", exception.Message); + Assert.Contains("tenant_id", exception.Message); + } + + #endregion + + #region Should_Resolve_OrderBy_Direction_Suffix_When_Checking_Rule2 + + private class OrderByDirectionSuffixEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class OrderByDirectionSuffixContext : 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(e => + { + e.ToTable("val_orderby_direction_suffix"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .HasAnnotation(HypertableAnnotations.CompressionOrderBy, "Value DESC NULLS LAST") + .HasAnnotation(HypertableAnnotations.EnableCompression, true) + .WithSparseIndex("bloom(Value)"); + }); + } + + [Fact] + public void Should_Resolve_OrderBy_Direction_Suffix_When_Checking_Rule2() + { + // Arrange + using OrderByDirectionSuffixContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("is redundant because", exception.Message); + Assert.Contains("compress_orderby column", exception.Message); + } + + #endregion + + #region Should_Resolve_OrderBy_Column_With_HasColumnName_When_Checking_Rule2 + + private class OrderByColumnNameMappingEntity + { + public DateTime Ts { get; set; } + public double SensorValue { get; set; } + } + + private class OrderByColumnNameMappingContext : 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(e => + { + e.ToTable("val_orderby_col_mapping"); + e.HasNoKey(); + e.Property(x => x.SensorValue).HasColumnName("sensor_value"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.SensorValue)]) + .WithSparseIndex("bloom(sensor_value)"); + }); + } + + [Fact] + public void Should_Resolve_OrderBy_Column_With_HasColumnName_When_Checking_Rule2() + { + // Arrange + using OrderByColumnNameMappingContext context = new(); + + // Act + InvalidOperationException exception = Assert.Throws( + () => GetModel(context)); + + // Assert + Assert.Contains("is redundant because", exception.Message); + Assert.Contains("sensor_value", exception.Message); + } + + #endregion + + // ── Non-hypertable entities are skipped ── + + #region Should_Not_Validate_Non_Hypertable_Entity + + private class PlainTableEntity { public int Id { get; set; } } + + private class PlainTableContext : DbContext + { + public DbSet Items => 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(e => + { + e.ToTable("val_plain_table"); + e.HasKey(x => x.Id); + e.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, "invalid_no_parens"); + }); + } + + [Fact] + public void Should_Not_Validate_Non_Hypertable_Entity() + { + // Arrange / Act + using PlainTableContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(PlainTableEntity))!; + Assert.Null(entity.FindAnnotation(HypertableAnnotations.IsHypertable)); + } + + #endregion + + // ── Empty/whitespace entries in the raw value are silently skipped ──────── + + #region Should_Skip_Whitespace_Only_Entry_Between_Valid_Entries + + private class WhitespaceEntryEntity + { + public DateTime Ts { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class WhitespaceEntryContext : 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(e => + { + e.ToTable("val_whitespace_entry"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .HasAnnotation(HypertableAnnotations.CompressionSparseIndex, + "bloom(DeviceId), ,minmax(Value)"); + }); + } + + [Fact] + public void Should_Skip_Whitespace_Only_Entry_Between_Valid_Entries() + { + // Arrange / Act + using WhitespaceEntryContext context = new(); + IModel model = GetModel(context); + + // Assert + IEntityType entity = model.FindEntityType(typeof(WhitespaceEntryEntity))!; + Assert.NotNull(entity.FindAnnotation(HypertableAnnotations.CompressionSparseIndex)); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs b/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs index 516ad34..0af140e 100644 --- a/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/TimeColumnStoreTypeValidationConventionTests.cs @@ -22,8 +22,6 @@ private static IModel GetModel(DbContext context) #region Should_Allow_Custom_TimeColumn_Mapped_To_Timestamp - // Stands in for a custom time type (e.g. NodaTime Instant) mapped to a timestamp store type via a - // value converter. The .NET type is unknown to the library; validity comes from the store mapping. private readonly struct CustomInstant(DateTime utcDateTime) { public DateTime UtcDateTime { get; } = utcDateTime; @@ -272,7 +270,6 @@ public void Should_Throw_When_ContinuousAggregate_SourceColumn_Maps_To_Invalid_T #region Should_Allow_All_Relevant_NodaTime_Time_Column_Types - // NodaTime date/time types whose Npgsql store mapping is a valid TimescaleDB time dimension. private class NodaInstantEntity { public Instant Time { get; set; } @@ -364,7 +361,6 @@ public void Should_Allow_All_Relevant_NodaTime_Time_Column_Types(Type entityClrT { using NodaTimeValidContext context = new(); - // Building the model runs the validation convention; a non-time store type would throw here. IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(entityClrType)!; @@ -376,8 +372,6 @@ public void Should_Allow_All_Relevant_NodaTime_Time_Column_Types(Type entityClrT #region Should_Throw_For_NonDimension_NodaTime_Types - // NodaTime temporal types whose Npgsql store mapping is NOT a valid TimescaleDB time dimension - // (per the Npgsql NodaTime mapping table). Every one must be rejected by the validation convention. [Hypertable("Time")] private class NodaLocalTimeEntity { @@ -420,8 +414,6 @@ private class NodaDateIntervalEntity public double Value { get; set; } } - // A single generic context validates each type in isolation: the convention throws during model - // finalization, so each NodaTime type needs its own one-entity model. private class NodaHypertableContext : DbContext where TEntity : class { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -437,12 +429,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } [Theory] - [InlineData(typeof(NodaLocalTimeEntity))] // time without time zone - [InlineData(typeof(NodaOffsetTimeEntity))] // time with time zone - [InlineData(typeof(NodaPeriodEntity))] // interval - [InlineData(typeof(NodaDurationEntity))] // interval - [InlineData(typeof(NodaIntervalEntity))] // tstzrange - [InlineData(typeof(NodaDateIntervalEntity))] // daterange + [InlineData(typeof(NodaLocalTimeEntity))] + [InlineData(typeof(NodaOffsetTimeEntity))] + [InlineData(typeof(NodaPeriodEntity))] + [InlineData(typeof(NodaDurationEntity))] + [InlineData(typeof(NodaIntervalEntity))] + [InlineData(typeof(NodaDateIntervalEntity))] public void Should_Throw_For_NonDimension_NodaTime_Types(Type entityClrType) { Type contextType = typeof(NodaHypertableContext<>).MakeGenericType(entityClrType); diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs index 1e42009..268ed2b 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/AnnotationRendererHelperTests.cs @@ -163,7 +163,6 @@ public void Consume_Does_Not_Throw_For_Missing_Keys() { Dictionary annotations = Annotations(("key:A", "a")); - // Should not throw even if key is absent AnnotationRendererHelper.Consume(annotations, "key:A", "key:NotPresent"); Assert.Empty(annotations); diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRendererTests.cs new file mode 100644 index 0000000..4e8782b --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/CompressionPolicyAnnotationRendererTests.cs @@ -0,0 +1,847 @@ +#pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +/// +/// Tests for CompressionPolicyAnnotationRenderer exercised through the public +/// surface. +/// The renderer requires IsHypertable to have already been consumed by the hypertable renderer. +/// It emits 5 positional args: (after, createdBefore, scheduleInterval, timezone, ifNotExists). +/// +public class CompressionPolicyAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator + { + get + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + + generator.ScaffoldMode = true; + return generator; + } + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + private static List FlattenChain(MethodCallCodeFragment? fragment) + { + List all = []; + for (MethodCallCodeFragment? c = fragment; c != null; c = c.ChainedCall) all.Add(c); + return all; + } + + private class CompressionRendererEntity + { + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CompressionRendererContext : DbContext + { + public DbSet Items => 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(e => + { + e.HasNoKey(); + e.ToTable("compression_renderer_entity"); + e.Property(x => x.Time).HasColumnName("time"); + e.Property(x => x.Value).HasColumnName("value"); + }); + } + + // ── Guard tests ─────────────────────────────────────────────────────────── + + #region GenerateFluentApiCalls_Returns_Empty_When_HasCompressionPolicy_Absent + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_HasCompressionPolicy_Absent() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithCompressionPolicy")); + } + + #endregion + + #region GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithCompressionPolicy")); + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy), + "HasCompressionPolicy annotation must remain unconsumed when ShouldRender is false."); + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.After), + "After annotation must remain unconsumed when ShouldRender is false."); + } + + #endregion + + #region GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed + + [Fact] + public void GenerateFluentApiCalls_Renders_When_IsHypertable_Consumed() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.Contains(result, f => CollectMethodChain(f).Contains("WithCompressionPolicy")); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_HasCompressionPolicy_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_HasCompressionPolicy_Absent() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(CompressionPolicyAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present + + [Fact] + public void GenerateDataAnnotationAttributes_Returns_Empty_When_IsHypertable_Still_Present() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(CompressionPolicyAttribute)); + } + + #endregion + + // ── Fluent positional argument tests (always all five args) ────────────── + + #region GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_Only_After + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_Only_After() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Equal("7 days", withCompressionPolicy.Arguments[0]); + Assert.All(withCompressionPolicy.Arguments.Skip(1), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_Only_CreatedBefore + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_Only_CreatedBefore() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.CreatedBefore, "30 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Null(withCompressionPolicy.Arguments[0]); + Assert.Equal("30 days", withCompressionPolicy.Arguments[1]); + Assert.All(withCompressionPolicy.Arguments.Skip(2), Assert.Null); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_ScheduleInterval + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_ScheduleInterval() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Equal("7 days", withCompressionPolicy.Arguments[0]); + Assert.Null(withCompressionPolicy.Arguments[1]); + Assert.Equal("12 hours", withCompressionPolicy.Arguments[2]); + Assert.Null(withCompressionPolicy.Arguments[3]); + Assert.Null(withCompressionPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_Timezone + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_Timezone() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.Timezone, "Europe/Berlin")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Equal("7 days", withCompressionPolicy.Arguments[0]); + Assert.Null(withCompressionPolicy.Arguments[1]); + Assert.Null(withCompressionPolicy.Arguments[2]); + Assert.Equal("Europe/Berlin", withCompressionPolicy.Arguments[3]); + Assert.Null(withCompressionPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_IfNotExists + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_Emits_FiveArgs_When_After_And_IfNotExists() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.IfNotExists, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Equal("7 days", withCompressionPolicy.Arguments[0]); + Assert.Null(withCompressionPolicy.Arguments[1]); + Assert.Null(withCompressionPolicy.Arguments[2]); + Assert.Null(withCompressionPolicy.Arguments[3]); + Assert.Equal(true, withCompressionPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_FiveArgs_When_All_Five_Set + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_FiveArgs_When_All_Five_Set() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "14 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours"), + (CompressionPolicyAnnotations.Timezone, "UTC"), + (CompressionPolicyAnnotations.IfNotExists, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Equal("14 days", withCompressionPolicy.Arguments[0]); + Assert.Null(withCompressionPolicy.Arguments[1]); + Assert.Equal("12 hours", withCompressionPolicy.Arguments[2]); + Assert.Equal("UTC", withCompressionPolicy.Arguments[3]); + Assert.Equal(true, withCompressionPolicy.Arguments[4]); + } + + #endregion + + #region GenerateFluentApiCalls_WithCompressionPolicy_FiveArgs_When_CreatedBefore_And_Timezone + + [Fact] + public void GenerateFluentApiCalls_WithCompressionPolicy_FiveArgs_When_CreatedBefore_And_Timezone() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.CreatedBefore, "30 days"), + (CompressionPolicyAnnotations.Timezone, "America/New_York")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withCompressionPolicy = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithCompressionPolicy"); + + Assert.NotNull(withCompressionPolicy); + Assert.Equal(5, withCompressionPolicy.Arguments.Count); + Assert.Null(withCompressionPolicy.Arguments[0]); + Assert.Equal("30 days", withCompressionPolicy.Arguments[1]); + Assert.Null(withCompressionPolicy.Arguments[2]); + Assert.Equal("America/New_York", withCompressionPolicy.Arguments[3]); + Assert.Null(withCompressionPolicy.Arguments[4]); + } + + #endregion + + // ── InitialStart chaining tests ─────────────────────────────────────────── + + #region GenerateFluentApiCalls_Chains_WithInitialStart_When_Present + + [Fact] + public void GenerateFluentApiCalls_Chains_WithInitialStart_When_Present() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? withInitialStart = result + .SelectMany(f => FlattenChain(f)) + .FirstOrDefault(f => f.Method == "WithInitialStart"); + + Assert.NotNull(withInitialStart); + Assert.Equal(initialStart, withInitialStart.Arguments[0]); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Absent() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithInitialStart_When_Value_Is_Not_DateTime() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.InitialStart, "2025-06-01T00:00:00Z")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, f => CollectMethodChain(f).Contains("WithInitialStart")); + } + + #endregion + + #region GenerateFluentApiCalls_Chain_Order_Is_WithCompressionPolicy_Then_WithInitialStart + + [Fact] + public void GenerateFluentApiCalls_Chain_Order_Is_WithCompressionPolicy_Then_WithInitialStart() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc))); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => CollectMethodChain(f).Contains("WithCompressionPolicy")); + List chain = CollectMethodChain(root); + + int compressionIdx = chain.IndexOf("WithCompressionPolicy"); + int initialStartIdx = chain.IndexOf("WithInitialStart"); + + Assert.True(compressionIdx < initialStartIdx, + "WithCompressionPolicy must precede WithInitialStart in the chain."); + } + + #endregion + + // ── Annotation consumption tests ────────────────────────────────────────── + + #region GenerateFluentApiCalls_Consumes_All_Compression_Annotations_After_Render + + [Fact] + public void GenerateFluentApiCalls_Consumes_All_Compression_Annotations_After_Render() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours"), + (CompressionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + (CompressionPolicyAnnotations.Timezone, "UTC"), + (CompressionPolicyAnnotations.IfNotExists, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.After)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.InitialStart)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.Timezone)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.IfNotExists)); + } + + #endregion + + #region GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Consume_When_ShouldRender_False() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.After)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True + + [Fact] + public void ConsumeFeatureAnnotations_Removes_All_Keys_When_ShouldRender_True() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours")); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.After)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False + + [Fact] + public void ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (HypertableAnnotations.IsHypertable, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateFluentApiCalls(entityType, annotations); + + // Assert + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.True(annotations.ContainsKey(CompressionPolicyAnnotations.After)); + } + + #endregion + + // ── Data-annotation attribute generation tests ──────────────────────────── + + #region GenerateDataAnnotationAttributes_After_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_After_As_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("7 days", attr.NamedArguments[nameof(CompressionPolicyAttribute.After)]); + Assert.Empty(attr.Arguments); + } + + #endregion + + #region GenerateDataAnnotationAttributes_CreatedBefore_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_CreatedBefore_As_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.CreatedBefore, "30 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("30 days", attr.NamedArguments[nameof(CompressionPolicyAttribute.CreatedBefore)]); + Assert.False(attr.NamedArguments.ContainsKey(nameof(CompressionPolicyAttribute.After))); + Assert.Empty(attr.Arguments); + } + + #endregion + + #region GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_ScheduleInterval_As_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("12 hours", attr.NamedArguments[nameof(CompressionPolicyAttribute.ScheduleInterval)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Timezone_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_Timezone_As_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.Timezone, "Europe/Berlin")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal("Europe/Berlin", attr.NamedArguments[nameof(CompressionPolicyAttribute.Timezone)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_IfNotExists_As_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_IfNotExists_As_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.IfNotExists, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.Equal(true, attr.NamedArguments[nameof(CompressionPolicyAttribute.IfNotExists)]); + } + + #endregion + + #region GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg + + [Fact] + public void GenerateDataAnnotationAttributes_InitialStart_As_ISO8601_UTC_String_Named_Arg() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + DateTime initialStart = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.InitialStart, initialStart)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + string? initialStartStr = attr.NamedArguments[nameof(CompressionPolicyAttribute.InitialStart)] as string; + Assert.NotNull(initialStartStr); + Assert.EndsWith("Z", initialStartStr, StringComparison.Ordinal); + DateTime parsed = DateTime.Parse(initialStartStr, null, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(initialStart, parsed); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Annotations_Absent() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(CompressionPolicyAttribute)); + Assert.NotNull(attr); + Assert.False(attr.NamedArguments.ContainsKey(nameof(CompressionPolicyAttribute.ScheduleInterval))); + Assert.False(attr.NamedArguments.ContainsKey(nameof(CompressionPolicyAttribute.InitialStart))); + Assert.False(attr.NamedArguments.ContainsKey(nameof(CompressionPolicyAttribute.Timezone))); + Assert.False(attr.NamedArguments.ContainsKey(nameof(CompressionPolicyAttribute.IfNotExists))); + } + + #endregion + + #region GenerateDataAnnotationAttributes_Consumes_All_Compression_Annotations_After_Render + + [Fact] + public void GenerateDataAnnotationAttributes_Consumes_All_Compression_Annotations_After_Render() + { + // Arrange + using CompressionRendererContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (CompressionPolicyAnnotations.HasCompressionPolicy, true), + (CompressionPolicyAnnotations.After, "7 days"), + (CompressionPolicyAnnotations.ScheduleInterval, "12 hours"), + (CompressionPolicyAnnotations.InitialStart, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + (CompressionPolicyAnnotations.Timezone, "UTC"), + (CompressionPolicyAnnotations.IfNotExists, true)); + + // Act + CreateAnnotationCodeGenerator.GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.HasCompressionPolicy)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.After)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.ScheduleInterval)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.InitialStart)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.Timezone)); + Assert.False(annotations.ContainsKey(CompressionPolicyAnnotations.IfNotExists)); + } + + #endregion +} +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs index f2f8807..08e7b20 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregateAnnotationRendererTests.cs @@ -1,6 +1,7 @@ #pragma warning disable EF1001 // IOperationReporter and AnnotationCodeGeneratorDependencies are design-time internals. using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Design; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; using Microsoft.EntityFrameworkCore; @@ -1150,7 +1151,6 @@ public void ParentResolution_Matches_ClrName_WhenTableNameDiffers() // Assert MethodCallCodeFragment root = Assert.Single(result, f => f.Method == "IsContinuousAggregate"); - // The parent resolved by CLR name renders as nameof(ApiLogEntity), not a raw string. Assert.IsType(root.Arguments[1]); } @@ -2800,4 +2800,118 @@ public void GenerateDataAnnotationAttributes_Uses_MaterializedViewName_When_Pare } #endregion + + // ── EnableCompression bare fallback (no segmentBy/orderBy) ───────────────── + + #region GenerateFluentApiCalls_EnableCompression_WithNoSegmentByOrOrderBy_ChainsWithCompression + + private class EnableCompressionFallbackCaEntity19 + { + public DateTime Bucket { get; set; } + public double AvgVal { get; set; } + } + + private class EnableCompressionFallbackContext19 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("enable_comp_fallback19"); + e.Property(x => x.AvgVal).HasColumnName("avg_val"); + }); + } + } + + [Fact] + public void GenerateFluentApiCalls_EnableCompression_WithNoSegmentByOrOrderBy_ChainsWithCompression() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket, avg(s.avg_val) AS avg_val" + + " FROM enable_comp_src19 s GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + using EnableCompressionFallbackContext19 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "enable_comp_fallback19"), + (ContinuousAggregateAnnotations.ParentName, "enable_comp_src19"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment root = Assert.Single(result, f => f.Method == nameof(ContinuousAggregateTypeBuilder.IsContinuousAggregate)); + List chain = CollectMethodChain(root); + Assert.Contains("WithCompression", chain); + } + + #endregion + + #region GenerateDataAnnotationAttributes_EnableCompression_WithNoSegmentByOrOrderBy_SetsEnabledNamedArg + + private class EnableCompressionDaFallbackCaEntity20 + { + public DateTime Bucket { get; set; } + public double AvgVal { get; set; } + } + + private class EnableCompressionDaFallbackContext20 : DbContext + { + public DbSet Stats => 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(e => + { + e.HasNoKey(); + e.ToView("enable_comp_da_fallback20"); + e.Property(x => x.AvgVal).HasColumnName("avg_val"); + }); + } + } + + [Fact] + public void GenerateDataAnnotationAttributes_EnableCompression_WithNoSegmentByOrOrderBy_SetsEnabledNamedArg() + { + // Arrange + const string viewDef = + "SELECT time_bucket('01:00:00'::interval, s.\"time\") AS bucket, avg(s.avg_val) AS avg_val" + + " FROM enable_comp_da_src20 s GROUP BY time_bucket('01:00:00'::interval, s.\"time\")"; + + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)CreateAnnotationCodeGenerator(); + generator.ScaffoldDataAnnotationsMode = true; + + using EnableCompressionDaFallbackContext20 context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (ContinuousAggregateAnnotations.MaterializedViewName, "enable_comp_da_fallback20"), + (ContinuousAggregateAnnotations.ParentName, "enable_comp_da_src20"), + (ContinuousAggregateAnnotations.ViewDefinition, viewDef), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = generator + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? caAttr = result.FirstOrDefault(a => a.Type == typeof(ContinuousAggregateAttribute)); + Assert.NotNull(caAttr); + Assert.True(caAttr.NamedArguments.ContainsKey(nameof(ContinuousAggregateAttribute.EnableCompression))); + Assert.Equal(true, caAttr.NamedArguments[nameof(ContinuousAggregateAttribute.EnableCompression)]); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs index eead457..a9da215 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/ContinuousAggregatePolicyAnnotationRendererTests.cs @@ -125,7 +125,7 @@ public void GenerateFluentApiCalls_Returns_Empty_When_MaterializedViewName_Still Dictionary annotations = Annotations( (ContinuousAggregatePolicyAnnotations.HasRefreshPolicy, true), (ContinuousAggregatePolicyAnnotations.StartOffset, "1 month"), - (ContinuousAggregateAnnotations.MaterializedViewName, "policy_ca_view")); // still present + (ContinuousAggregateAnnotations.MaterializedViewName, "policy_ca_view")); // Act IReadOnlyList result = CreateAnnotationCodeGenerator() @@ -737,7 +737,7 @@ public void ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False() [Fact] public void GenerateFluentApiCalls_Does_Not_Chain_InitialStart_When_Value_Is_Not_DateTime() { - // Arrange. + // Arrange using PolicyTestContext context = new(); IEntityType entityType = GetEntityType(context); Dictionary annotations = Annotations( diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs index 633455d..eca40dc 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableAnnotationRendererTests.cs @@ -68,7 +68,7 @@ public void GenerateFluentApiCalls_Returns_Empty_When_IsHypertable_Not_Set() { using NotHypertableContext context = new(); IEntityType entityType = GetEntityType(context); - Dictionary annotations = Annotations(); // no IsHypertable + Dictionary annotations = Annotations(); IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateFluentApiCalls(entityType, annotations); @@ -724,7 +724,7 @@ public void GenerateDataAnnotationAttributes_Includes_ChunkTimeInterval_When_Non Dictionary annotations = Annotations( (HypertableAnnotations.IsHypertable, true), (HypertableAnnotations.HypertableTimeColumn, "Ts"), - (HypertableAnnotations.ChunkTimeInterval, "1 hour")); // not the default "7 days" + (HypertableAnnotations.ChunkTimeInterval, "1 hour")); IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); @@ -756,7 +756,7 @@ public void GenerateDataAnnotationAttributes_Omits_ChunkTimeInterval_When_Equal_ Dictionary annotations = Annotations( (HypertableAnnotations.IsHypertable, true), (HypertableAnnotations.HypertableTimeColumn, "Ts"), - (HypertableAnnotations.ChunkTimeInterval, DefaultValues.ChunkTimeInterval)); // "7 days" == default + (HypertableAnnotations.ChunkTimeInterval, DefaultValues.ChunkTimeInterval)); IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); @@ -1509,6 +1509,106 @@ public void GenerateDataAnnotationAttributes_CompressionOrderBy_Unmapped_Column_ #endregion + #region GenerateDataAnnotationAttributes_SparseIndex_EmptyStringEntry_IsSkipped + + private class DaSparseIndexEmptyEntryEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class DaSparseIndexEmptyEntryContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_sparse_empty_entry"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_SparseIndex_EmptyStringEntry_IsSkipped() + { + // Arrange + using DaSparseIndexEmptyEntryContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "bloom(DeviceId), ,minmax(Ts)")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + IEnumerable sparseIndexAttrs = result.Where(a => a.Type == typeof(SparseIndexAttribute)); + Assert.Equal(2, sparseIndexAttrs.Count()); + } + + #endregion + + #region GenerateDataAnnotationAttributes_SparseIndex_MalformedEntry_NoParen_IsSkipped + + private class DaSparseIndexNoParenEntity { public DateTime Ts { get; set; } } + + private class DaSparseIndexNoParenContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_sparse_no_paren"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_SparseIndex_MalformedEntry_NoParen_IsSkipped() + { + // Arrange + using DaSparseIndexNoParenContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "not_a_function_call")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(SparseIndexAttribute)); + } + + #endregion + + #region GenerateDataAnnotationAttributes_SparseIndex_EmptyColumns_IsSkipped + + private class DaSparseIndexEmptyColsEntity { public DateTime Ts { get; set; } } + + private class DaSparseIndexEmptyColsContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("da_sparse_empty_cols"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_SparseIndex_EmptyColumns_IsSkipped() + { + // Arrange + using DaSparseIndexEmptyColsContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "bloom()")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator().GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + Assert.DoesNotContain(result, a => a.Type == typeof(SparseIndexAttribute)); + } + + #endregion + #region GenerateDataAnnotationAttributes_Dimension_Unmapped_Column_Returns_RawString_And_Range_With_Null_Interval private class DaDimUnmappedEntity { public DateTime Ts { get; set; } } diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableColumnstoreAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableColumnstoreAnnotationRendererTests.cs new file mode 100644 index 0000000..342813c --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/HypertableColumnstoreAnnotationRendererTests.cs @@ -0,0 +1,546 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators.AnnotationRenderers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators.AnnotationRenderers; + +public class HypertableColumnstoreAnnotationRendererTests +{ + private sealed record StubAnnotation(string Name, object? Value) : IAnnotation; + + private static Dictionary Annotations(params (string Key, object? Value)[] pairs) + => pairs.ToDictionary(p => p.Key, p => (IAnnotation)new StubAnnotation(p.Key, p.Value)); + + private static IAnnotationCodeGenerator CreateAnnotationCodeGenerator() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + TimescaleDbAnnotationCodeGenerator generator = (TimescaleDbAnnotationCodeGenerator)services + .BuildServiceProvider().GetRequiredService(); + generator.ScaffoldMode = true; + return generator; + } + + private static IEntityType GetEntityType(DbContext context) + => context.GetService().Model.FindEntityType(typeof(T))!; + + private static List CollectMethodChain(MethodCallCodeFragment? fragment) + { + List methods = []; + while (fragment != null) { methods.Add(fragment.Method); fragment = fragment.ChainedCall; } + return methods; + } + + // ── Fluent: WithSparseIndex chained when annotation present with value ── + + #region GenerateFluentApiCalls_Chains_WithSparseIndex_When_Annotation_Has_Value + + private class SparseIndexFluentEntity { public DateTime Ts { get; set; } } + + private class SparseIndexFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_si_value"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithSparseIndex_When_Annotation_Has_Value() + { + // Arrange + using SparseIndexFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "bloom(device_id)")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.Contains(nameof(HypertableTypeBuilder.WithSparseIndex), CollectMethodChain(hypertableCall)); + } + + #endregion + + // ── Fluent: WithoutAutoSparseIndexes chained when annotation is empty string ── + + #region GenerateFluentApiCalls_Chains_WithoutAutoSparseIndexes_When_Annotation_Is_Empty_String + + private class SparseIndexEmptyFluentEntity { public DateTime Ts { get; set; } } + + private class SparseIndexEmptyFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_si_empty"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithoutAutoSparseIndexes_When_Annotation_Is_Empty_String() + { + // Arrange + using SparseIndexEmptyFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, string.Empty)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithoutAutoSparseIndexes), chain); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.WithSparseIndex), chain); + } + + #endregion + + // ── Fluent: no WithSparseIndex when annotation absent ── + + #region GenerateFluentApiCalls_Does_Not_Chain_WithSparseIndex_When_Annotation_Absent + + private class NoSparseIndexFluentEntity { public DateTime Ts { get; set; } } + + private class NoSparseIndexFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_si_absent"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithSparseIndex_When_Annotation_Absent() + { + // Arrange + using NoSparseIndexFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.WithSparseIndex), CollectMethodChain(hypertableCall)); + } + + #endregion + + // ── Fluent: WithCompressChunkTimeInterval chained when annotation present ── + + #region GenerateFluentApiCalls_Chains_WithCompressChunkTimeInterval + + private class CctiFluentEntity { public DateTime Ts { get; set; } } + + private class CctiFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ccti"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Chains_WithCompressChunkTimeInterval() + { + // Arrange + using CctiFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressChunkTimeInterval, "24 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.Contains(nameof(HypertableTypeBuilder.WithCompressChunkTimeInterval), CollectMethodChain(hypertableCall)); + } + + #endregion + + // ── Fluent: no WithCompressChunkTimeInterval when annotation absent ── + + #region GenerateFluentApiCalls_Does_Not_Chain_WithCompressChunkTimeInterval_When_Absent + + private class NoCctiFluentEntity { public DateTime Ts { get; set; } } + + private class NoCctiFluentContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ccti_absent"); }); + } + + [Fact] + public void GenerateFluentApiCalls_Does_Not_Chain_WithCompressChunkTimeInterval_When_Absent() + { + // Arrange + using NoCctiFluentContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.WithCompressChunkTimeInterval), CollectMethodChain(hypertableCall)); + } + + #endregion + + // ── Fluent: SparseIndex suppresses standalone EnableCompression ── + + #region GenerateFluentApiCalls_SparseIndex_Suppresses_EnableCompression + + private class SparseIndexSuppressesEnableCompressionEntity { public DateTime Ts { get; set; } } + + private class SparseIndexSuppressesEnableCompressionContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_si_suppress"); }); + } + + [Fact] + public void GenerateFluentApiCalls_SparseIndex_Suppresses_EnableCompression() + { + // Arrange + using SparseIndexSuppressesEnableCompressionContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true), + (HypertableAnnotations.CompressionSparseIndex, "bloom(device_id)")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithSparseIndex), chain); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.EnableCompression), chain); + } + + #endregion + + // ── Fluent: CompressChunkTimeInterval suppresses standalone EnableCompression ── + + #region GenerateFluentApiCalls_CompressChunkTimeInterval_Suppresses_EnableCompression + + private class CctiSuppressesEnableCompressionEntity { public DateTime Ts { get; set; } } + + private class CctiSuppressesEnableCompressionContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_ccti_suppress"); }); + } + + [Fact] + public void GenerateFluentApiCalls_CompressChunkTimeInterval_Suppresses_EnableCompression() + { + // Arrange + using CctiSuppressesEnableCompressionContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true), + (HypertableAnnotations.CompressChunkTimeInterval, "24 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateFluentApiCalls(entityType, annotations); + + // Assert + MethodCallCodeFragment? hypertableCall = result.First(f => CollectMethodChain(f).Contains(nameof(HypertableTypeBuilder.IsHypertable))); + List chain = CollectMethodChain(hypertableCall); + Assert.Contains(nameof(HypertableTypeBuilder.WithCompressChunkTimeInterval), chain); + Assert.DoesNotContain(nameof(HypertableTypeBuilder.EnableCompression), chain); + } + + #endregion + + // ── ConsumeFeatureAnnotations removes both keys ── + + #region ConsumeFeatureAnnotations_Removes_SparseIndex_And_CompressChunkTimeInterval + + private class ConsumeColumnstoreEntity { public DateTime Ts { get; set; } } + + private class ConsumeColumnstoreContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("fluent_consume_cs"); }); + } + + [Fact] + public void ConsumeFeatureAnnotations_Removes_SparseIndex_And_CompressChunkTimeInterval() + { + // Arrange + using ConsumeColumnstoreContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "bloom(device_id)"), + (HypertableAnnotations.CompressChunkTimeInterval, "24 hours")); + + HypertableAnnotationRenderer renderer = new(); + + // Act + renderer.ConsumeFeatureAnnotations(entityType, annotations); + + // Assert + Assert.DoesNotContain(HypertableAnnotations.CompressionSparseIndex, annotations.Keys); + Assert.DoesNotContain(HypertableAnnotations.CompressChunkTimeInterval, annotations.Keys); + } + + #endregion + + // ── Attribute: non-empty annotation emits a separate [SparseIndex] attribute fragment ── + + #region GenerateDataAnnotationAttributes_Emits_SparseIndexAttribute_Fragment_For_NonEmpty_Annotation + + private class AttrSparseIndexEntity { public DateTime Ts { get; set; } } + + private class AttrSparseIndexContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("attr_si_value"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Emits_SparseIndexAttribute_Fragment_For_NonEmpty_Annotation() + { + // Arrange + using AttrSparseIndexContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, "bloom(device_id)")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? siAttr = result.FirstOrDefault(a => a.Type == typeof(SparseIndexAttribute)); + Assert.NotNull(siAttr); + Assert.Equal(ESparseIndexType.Bloom, siAttr.Arguments[0]); + Assert.Equal("device_id", siAttr.Arguments[1]); + + AttributeCodeFragment? htAttr = result.FirstOrDefault(a => a.Type == typeof(HypertableAttribute)); + Assert.NotNull(htAttr); + Assert.DoesNotContain(nameof(HypertableAttribute.DisableAutoSparseIndexes), htAttr.NamedArguments.Keys); + } + + #endregion + + // ── Attribute: empty annotation emits DisableAutoSparseIndexes = true on [Hypertable] ── + + #region GenerateDataAnnotationAttributes_Emits_DisableAutoSparseIndexes_For_Empty_Annotation + + private class AttrSparseIndexEmptyEntity { public DateTime Ts { get; set; } } + + private class AttrSparseIndexEmptyContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("attr_si_empty"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Emits_DisableAutoSparseIndexes_For_Empty_Annotation() + { + // Arrange + using AttrSparseIndexEmptyContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressionSparseIndex, string.Empty)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? htAttr = result.FirstOrDefault(a => a.Type == typeof(HypertableAttribute)); + Assert.NotNull(htAttr); + Assert.True(htAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.DisableAutoSparseIndexes))); + Assert.Equal(true, htAttr.NamedArguments[nameof(HypertableAttribute.DisableAutoSparseIndexes)]); + Assert.DoesNotContain(result, a => a.Type == typeof(SparseIndexAttribute)); + } + + #endregion + + // ── Attribute: absent annotation emits no SparseIndex or DisableAutoSparseIndexes ── + + #region GenerateDataAnnotationAttributes_Omits_SparseIndex_And_DisableAutoSparseIndexes_When_Annotation_Absent + + private class AttrNoSparseIndexEntity { public DateTime Ts { get; set; } } + + private class AttrNoSparseIndexContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("attr_si_absent"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_SparseIndex_And_DisableAutoSparseIndexes_When_Annotation_Absent() + { + // Arrange + using AttrNoSparseIndexContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? htAttr = result.FirstOrDefault(a => a.Type == typeof(HypertableAttribute)); + Assert.NotNull(htAttr); + Assert.DoesNotContain(nameof(HypertableAttribute.DisableAutoSparseIndexes), htAttr.NamedArguments.Keys); + Assert.DoesNotContain(result, a => a.Type == typeof(SparseIndexAttribute)); + } + + #endregion + + // ── Attribute: CompressChunkTimeInterval named arg emitted ── + + #region GenerateDataAnnotationAttributes_Emits_CompressChunkTimeInterval_Named_Arg + + private class AttrCctiEntity { public DateTime Ts { get; set; } } + + private class AttrCctiContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("attr_ccti"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Emits_CompressChunkTimeInterval_Named_Arg() + { + // Arrange + using AttrCctiContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.CompressChunkTimeInterval, "24 hours")); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? htAttr = result.FirstOrDefault(a => a.Type == typeof(HypertableAttribute)); + Assert.NotNull(htAttr); + Assert.True(htAttr.NamedArguments.ContainsKey(nameof(HypertableAttribute.CompressChunkTimeInterval))); + Assert.Equal("24 hours", htAttr.NamedArguments[nameof(HypertableAttribute.CompressChunkTimeInterval)]); + } + + #endregion + + // ── Attribute: CompressChunkTimeInterval absent when annotation not set ── + + #region GenerateDataAnnotationAttributes_Omits_CompressChunkTimeInterval_Named_Arg_When_Absent + + private class AttrNoCctiEntity { public DateTime Ts { get; set; } } + + private class AttrNoCctiContext : DbContext + { + public DbSet Items => 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(e => { e.HasNoKey(); e.ToTable("attr_ccti_absent"); }); + } + + [Fact] + public void GenerateDataAnnotationAttributes_Omits_CompressChunkTimeInterval_Named_Arg_When_Absent() + { + // Arrange + using AttrNoCctiContext context = new(); + IEntityType entityType = GetEntityType(context); + Dictionary annotations = Annotations( + (HypertableAnnotations.IsHypertable, true), + (HypertableAnnotations.HypertableTimeColumn, "Ts"), + (HypertableAnnotations.EnableCompression, true)); + + // Act + IReadOnlyList result = CreateAnnotationCodeGenerator() + .GenerateDataAnnotationAttributes(entityType, annotations); + + // Assert + AttributeCodeFragment? htAttr = result.FirstOrDefault(a => a.Type == typeof(HypertableAttribute)); + Assert.NotNull(htAttr); + Assert.DoesNotContain(nameof(HypertableAttribute.CompressChunkTimeInterval), htAttr.NamedArguments.Keys); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs index d473c4d..f4fe4f2 100644 --- a/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/AnnotationRenderers/RetentionPolicyAnnotationRendererTests.cs @@ -710,10 +710,10 @@ public void ConsumeFeatureAnnotations_Leaves_Keys_When_ShouldRender_False() // ── Data-annotation attribute generation tests ──────────────────────────── - #region GenerateDataAnnotationAttributes_DropAfter_As_Positional_Arg_0 + #region GenerateDataAnnotationAttributes_DropAfter_As_Named_Arg [Fact] - public void GenerateDataAnnotationAttributes_DropAfter_As_Positional_Arg_0() + public void GenerateDataAnnotationAttributes_DropAfter_As_Named_Arg() { // Arrange using RetentionRendererContext context = new(); @@ -729,16 +729,16 @@ public void GenerateDataAnnotationAttributes_DropAfter_As_Positional_Arg_0() // Assert AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); Assert.NotNull(attr); - object? positionalArg = Assert.Single(attr.Arguments); - Assert.Equal("7 days", positionalArg); + Assert.Equal("7 days", attr.NamedArguments[nameof(RetentionPolicyAttribute.DropAfter)]); + Assert.Empty(attr.Arguments); } #endregion - #region GenerateDataAnnotationAttributes_DropCreatedBefore_Only_Has_Two_Positional_Args + #region GenerateDataAnnotationAttributes_DropCreatedBefore_As_Named_Arg [Fact] - public void GenerateDataAnnotationAttributes_DropCreatedBefore_Only_Has_Two_Positional_Args() + public void GenerateDataAnnotationAttributes_DropCreatedBefore_As_Named_Arg() { // Arrange using RetentionRendererContext context = new(); @@ -754,9 +754,9 @@ public void GenerateDataAnnotationAttributes_DropCreatedBefore_Only_Has_Two_Posi // Assert AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); Assert.NotNull(attr); - Assert.Equal(2, attr.Arguments.Count); - Assert.Null(attr.Arguments[0]); - Assert.Equal("30 days", attr.Arguments[1]); + Assert.Equal("30 days", attr.NamedArguments[nameof(RetentionPolicyAttribute.DropCreatedBefore)]); + Assert.False(attr.NamedArguments.ContainsKey(nameof(RetentionPolicyAttribute.DropAfter))); + Assert.Empty(attr.Arguments); } #endregion @@ -940,7 +940,8 @@ public void GenerateDataAnnotationAttributes_Omits_Named_Args_When_Optional_Anno // Assert AttributeCodeFragment? attr = result.FirstOrDefault(a => a.Type == typeof(RetentionPolicyAttribute)); Assert.NotNull(attr); - Assert.Empty(attr.NamedArguments); + string namedArg = Assert.Single(attr.NamedArguments).Key; + Assert.Equal(nameof(RetentionPolicyAttribute.DropAfter), namedArg); } #endregion diff --git a/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs b/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs index 37e60bd..0f4487b 100644 --- a/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/CSharpGeneratorHelperTests.cs @@ -44,7 +44,7 @@ public void LiteralStringList_RendersBracketedQuotedCommaSeparated() // Act string result = Generate(op); - // Assert — collection expression ["a", "b"]. + // Assert Assert.Contains("[\"a\", \"b\"]", result); } @@ -77,7 +77,7 @@ public void LiteralStringList_SingleElement() [Fact] public void StaticCall_RendersIntArgUnquoted() { - // Arrange — a hash dimension forces StaticCall(..., columnName, numberOfPartitions:int). + // Arrange CreateHypertableOperation op = new() { TableName = "t", @@ -88,7 +88,7 @@ public void StaticCall_RendersIntArgUnquoted() // Act string result = Generate(op); - // Assert — string arg is quoted, int arg is unquoted. + // Assert Assert.Contains(".CreateHash(\"hash_col\", 4)", result); Assert.DoesNotContain("CreateHash(\"hash_col\", \"4\")", result); } @@ -111,7 +111,7 @@ public void StaticCall_RendersStringArgQuoted() // Act string result = Generate(op); - // Assert — both args are strings and quoted. + // Assert Assert.Contains(".CreateRange(\"range_col\", \"1 day\")", result); } diff --git a/tests/Eftdb.Tests/Design/Generators/CompressionPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/CompressionPolicyCSharpGeneratorTests.cs new file mode 100644 index 0000000..26dd97f --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/CompressionPolicyCSharpGeneratorTests.cs @@ -0,0 +1,622 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators +{ + /// + /// Tests the actual C# text emitted by using a + /// real . + /// + public class CompressionPolicyCSharpGeneratorTests + { + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(AddCompressionPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new CompressionPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterCompressionPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new CompressionPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(DropCompressionPolicyOperation operation) + { + IndentedStringBuilder builder = new(); + new CompressionPolicyCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + // ── AddCompressionPolicy ─────────────────────────────────────────────── + + #region AddCompressionPolicy_MinimalArgs_EmitsTableNameOnly + + [Fact] + public void AddCompressionPolicy_MinimalArgs_EmitsTableNameOnly() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("AddCompressionPolicy", result); + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.DoesNotContain("schema:", result); + Assert.DoesNotContain("after:", result); + Assert.DoesNotContain("createdBefore:", result); + Assert.DoesNotContain("scheduleInterval:", result); + Assert.DoesNotContain("initialStart:", result); + Assert.DoesNotContain("timezone:", result); + Assert.DoesNotContain("ifNotExists:", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsAfter_WhenSet + + [Fact] + public void AddCompressionPolicy_EmitsAfter_WhenSet() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("after: \"7 days\"", result); + Assert.DoesNotContain("createdBefore:", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsCreatedBefore_WhenSet + + [Fact] + public void AddCompressionPolicy_EmitsCreatedBefore_WhenSet() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + CreatedBefore = "30 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("createdBefore: \"30 days\"", result); + Assert.DoesNotContain("after:", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsAllOptionalArgs + + [Fact] + public void AddCompressionPolicy_EmitsAllOptionalArgs() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + After = "7 days", + CreatedBefore = "30 days", + ScheduleInterval = "12 hours", + InitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Timezone = "Europe/Berlin", + IfNotExists = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("after: \"7 days\"", result); + Assert.Contains("createdBefore: \"30 days\"", result); + Assert.Contains("scheduleInterval: \"12 hours\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("timezone: \"Europe/Berlin\"", result); + Assert.Contains("ifNotExists: true", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsSchema_WhenSet + + [Fact] + public void AddCompressionPolicy_EmitsSchema_WhenSet() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "analytics", + After = "14 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("schema: \"analytics\"", result); + } + + #endregion + + #region AddCompressionPolicy_OmitsSchema_WhenEmpty + + [Fact] + public void AddCompressionPolicy_OmitsSchema_WhenEmpty() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "", + After = "7 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("schema:", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsScheduleInterval_WhenSet + + [Fact] + public void AddCompressionPolicy_EmitsScheduleInterval_WhenSet() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + ScheduleInterval = "6 hours", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("scheduleInterval: \"6 hours\"", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsTimezone_WhenSet + + [Fact] + public void AddCompressionPolicy_EmitsTimezone_WhenSet() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Timezone = "UTC", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("timezone: \"UTC\"", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsIfNotExists_WhenTrue + + [Fact] + public void AddCompressionPolicy_EmitsIfNotExists_WhenTrue() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + IfNotExists = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("ifNotExists: true", result); + } + + #endregion + + #region AddCompressionPolicy_EmitsIfNotExists_WhenFalse + + [Fact] + public void AddCompressionPolicy_EmitsIfNotExists_WhenFalse() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + IfNotExists = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("ifNotExists: false", result); + } + + #endregion + + #region AddCompressionPolicy_OmitsIfNotExists_WhenNull + + [Fact] + public void AddCompressionPolicy_OmitsIfNotExists_WhenNull() + { + // Arrange + AddCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + IfNotExists = null, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("ifNotExists:", result); + } + + #endregion + + // ── AlterCompressionPolicy ───────────────────────────────────────────── + + #region AlterCompressionPolicy_MinimalArgs_EmitsTableNameOnly + + [Fact] + public void AlterCompressionPolicy_MinimalArgs_EmitsTableNameOnly() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("AlterCompressionPolicy", result); + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.DoesNotContain("after:", result); + Assert.DoesNotContain("oldAfter:", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsNewAndOldAfter + + [Fact] + public void AlterCompressionPolicy_EmitsNewAndOldAfter() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "14 days", + OldAfter = "7 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("after: \"14 days\"", result); + Assert.Contains("oldAfter: \"7 days\"", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsOldCreatedBefore + + [Fact] + public void AlterCompressionPolicy_EmitsOldCreatedBefore() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + CreatedBefore = "60 days", + OldCreatedBefore = "30 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("createdBefore: \"60 days\"", result); + Assert.Contains("oldCreatedBefore: \"30 days\"", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsOldScheduleInterval + + [Fact] + public void AlterCompressionPolicy_EmitsOldScheduleInterval() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + ScheduleInterval = "1 day", + OldScheduleInterval = "4 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("scheduleInterval: \"1 day\"", result); + Assert.Contains("oldScheduleInterval: \"4 days\"", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsOldInitialStart + + [Fact] + public void AlterCompressionPolicy_EmitsOldInitialStart() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + InitialStart = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + OldInitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("initialStart:", result); + Assert.Contains("oldInitialStart:", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsOldTimezone + + [Fact] + public void AlterCompressionPolicy_EmitsOldTimezone() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + Timezone = "America/New_York", + OldTimezone = "Europe/Berlin", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("timezone: \"America/New_York\"", result); + Assert.Contains("oldTimezone: \"Europe/Berlin\"", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsOldIfNotExists + + [Fact] + public void AlterCompressionPolicy_EmitsOldIfNotExists() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + IfNotExists = false, + OldIfNotExists = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("ifNotExists: false", result); + Assert.Contains("oldIfNotExists: true", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsAllNewAndOldArgs + + [Fact] + public void AlterCompressionPolicy_EmitsAllNewAndOldArgs() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + After = "14 days", + CreatedBefore = "60 days", + ScheduleInterval = "1 day", + InitialStart = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + Timezone = "UTC", + IfNotExists = true, + OldAfter = "7 days", + OldCreatedBefore = "30 days", + OldScheduleInterval = "12 hours", + OldInitialStart = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + OldTimezone = "Europe/Berlin", + OldIfNotExists = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("schema: \"metrics\"", result); + Assert.Contains("after: \"14 days\"", result); + Assert.Contains("createdBefore: \"60 days\"", result); + Assert.Contains("scheduleInterval: \"1 day\"", result); + Assert.Contains("initialStart:", result); + Assert.Contains("timezone: \"UTC\"", result); + Assert.Contains("ifNotExists: true", result); + Assert.Contains("oldAfter: \"7 days\"", result); + Assert.Contains("oldCreatedBefore: \"30 days\"", result); + Assert.Contains("oldScheduleInterval: \"12 hours\"", result); + Assert.Contains("oldInitialStart:", result); + Assert.Contains("oldTimezone: \"Europe/Berlin\"", result); + Assert.Contains("oldIfNotExists: false", result); + } + + #endregion + + #region AlterCompressionPolicy_OmitsOldArgs_WhenAllNull + + [Fact] + public void AlterCompressionPolicy_OmitsOldArgs_WhenAllNull() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + After = "7 days", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldAfter:", result); + Assert.DoesNotContain("oldCreatedBefore:", result); + Assert.DoesNotContain("oldScheduleInterval:", result); + Assert.DoesNotContain("oldInitialStart:", result); + Assert.DoesNotContain("oldTimezone:", result); + Assert.DoesNotContain("oldIfNotExists:", result); + } + + #endregion + + #region AlterCompressionPolicy_EmitsSchema_WhenSet + + [Fact] + public void AlterCompressionPolicy_EmitsSchema_WhenSet() + { + // Arrange + AlterCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "analytics", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("schema: \"analytics\"", result); + } + + #endregion + + // ── DropCompressionPolicy ────────────────────────────────────────────── + + #region DropCompressionPolicy_MinimalArgs_EmitsTableNameOnly + + [Fact] + public void DropCompressionPolicy_MinimalArgs_EmitsTableNameOnly() + { + // Arrange + DropCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("DropCompressionPolicy", result); + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.DoesNotContain("schema:", result); + } + + #endregion + + #region DropCompressionPolicy_EmitsSchema_WhenSet + + [Fact] + public void DropCompressionPolicy_EmitsSchema_WhenSet() + { + // Arrange + DropCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "metrics", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("tableName: \"sensor_data\"", result); + Assert.Contains("schema: \"metrics\"", result); + } + + #endregion + + #region DropCompressionPolicy_OmitsSchema_WhenEmpty + + [Fact] + public void DropCompressionPolicy_OmitsSchema_WhenEmpty() + { + // Arrange + DropCompressionPolicyOperation op = new() + { + TableName = "sensor_data", + Schema = "", + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("schema:", result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCompressionCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCompressionCSharpGeneratorTests.cs new file mode 100644 index 0000000..8a74652 --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregateCompressionCSharpGeneratorTests.cs @@ -0,0 +1,374 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +public class ContinuousAggregateCompressionCSharpGeneratorTests +{ + private readonly ICSharpHelper code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(CreateContinuousAggregateOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregateCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterContinuousAggregateOperation operation) + { + IndentedStringBuilder builder = new(); + new ContinuousAggregateCSharpGenerator(code).Generate(operation, builder); + return builder.ToString(); + } + + // ── Create operations ───────────────────────────────────────────────────── + + #region Create_EnableCompression_True_EmitsParameter + + [Fact] + public void Create_EnableCompression_True_EmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "compressed_cagg", + ParentName = "metrics", + EnableCompression = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("enableCompression: true", result); + } + + #endregion + + #region Create_EnableCompression_False_OmitsParameter + + [Fact] + public void Create_EnableCompression_False_OmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "uncompressed_cagg", + ParentName = "metrics", + EnableCompression = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("enableCompression", result); + } + + #endregion + + #region Create_CompressionSegmentBy_EmitsParameter + + [Fact] + public void Create_CompressionSegmentBy_EmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "seg_cagg", + ParentName = "metrics", + EnableCompression = true, + CompressionSegmentBy = ["device_id", "region"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSegmentBy:", result); + Assert.Contains("device_id", result); + Assert.Contains("region", result); + } + + #endregion + + #region Create_CompressionSegmentBy_Empty_OmitsParameter + + [Fact] + public void Create_CompressionSegmentBy_Empty_OmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "no_seg_cagg", + ParentName = "metrics", + EnableCompression = true, + CompressionSegmentBy = [], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressionSegmentBy", result); + } + + #endregion + + #region Create_CompressionOrderBy_EmitsParameter + + [Fact] + public void Create_CompressionOrderBy_EmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "ord_cagg", + ParentName = "metrics", + EnableCompression = true, + CompressionOrderBy = ["time DESC"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionOrderBy:", result); + Assert.Contains("time DESC", result); + } + + #endregion + + #region Create_CompressionOrderBy_Null_OmitsParameter + + [Fact] + public void Create_CompressionOrderBy_Null_OmitsParameter() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "no_ord_cagg", + ParentName = "metrics", + EnableCompression = true, + CompressionOrderBy = null, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressionOrderBy", result); + } + + #endregion + + // ── Alter operations ────────────────────────────────────────────────────── + + #region Alter_EnableCompression_True_EmitsParameter + + [Fact] + public void Alter_EnableCompression_True_EmitsParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_comp_cagg", + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("enableCompression: true", result); + } + + #endregion + + #region Alter_EnableCompression_False_OmitsParameter + + [Fact] + public void Alter_EnableCompression_False_OmitsParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_no_comp_cagg", + EnableCompression = false, + OldEnableCompression = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("enableCompression", result); + } + + #endregion + + #region Alter_OldEnableCompression_True_EmitsOldParameter + + [Fact] + public void Alter_OldEnableCompression_True_EmitsOldParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_old_comp_cagg", + EnableCompression = false, + OldEnableCompression = true, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldEnableCompression: true", result); + } + + #endregion + + #region Alter_OldEnableCompression_False_OmitsOldParameter + + [Fact] + public void Alter_OldEnableCompression_False_OmitsOldParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_old_no_comp", + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldEnableCompression", result); + } + + #endregion + + #region Alter_CompressionSegmentBy_EmitsParameter + + [Fact] + public void Alter_CompressionSegmentBy_EmitsParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_seg", + EnableCompression = true, + CompressionSegmentBy = ["device_id"], + OldCompressionSegmentBy = [], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSegmentBy:", result); + Assert.Contains("device_id", result); + } + + #endregion + + #region Alter_OldCompressionSegmentBy_EmitsOldParameter + + [Fact] + public void Alter_OldCompressionSegmentBy_EmitsOldParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_old_seg", + OldCompressionSegmentBy = ["region"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldCompressionSegmentBy:", result); + Assert.Contains("region", result); + } + + #endregion + + #region Alter_CompressionOrderBy_EmitsParameter + + [Fact] + public void Alter_CompressionOrderBy_EmitsParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_ord", + EnableCompression = true, + CompressionOrderBy = ["bucket DESC"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionOrderBy:", result); + Assert.Contains("bucket DESC", result); + } + + #endregion + + #region Alter_OldCompressionOrderBy_EmitsOldParameter + + [Fact] + public void Alter_OldCompressionOrderBy_EmitsOldParameter() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_old_ord", + OldCompressionOrderBy = ["bucket ASC"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldCompressionOrderBy:", result); + Assert.Contains("bucket ASC", result); + } + + #endregion + + #region Create_FullCompressionParams_GeneratesCompleteCall + + [Fact] + public void Create_FullCompressionParams_GeneratesCompleteCall() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "full_comp", + ParentName = "metrics", + EnableCompression = true, + CompressionSegmentBy = ["region"], + CompressionOrderBy = ["bucket DESC"], + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("enableCompression: true", result); + Assert.Contains("compressionSegmentBy:", result); + Assert.Contains("compressionOrderBy:", result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs index 7799adb..f2abe5a 100644 --- a/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ContinuousAggregatePolicyCSharpGeneratorTests.cs @@ -33,7 +33,7 @@ private string Generate(RemoveContinuousAggregatePolicyOperation operation) [Fact] public void AddPolicy_DefaultValues_OmitInvertedAndDefaultArgs() { - // Arrange — all defaults (BucketsPerBatch=1, MaxBatchesPerExecution=0, RefreshNewestFirst=true). + // Arrange AddContinuousAggregatePolicyOperation op = new() { MaterializedViewName = "hourly", diff --git a/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs index 3e8371d..cf8642a 100644 --- a/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/HypertableCSharpGeneratorTests.cs @@ -77,7 +77,7 @@ public void CreateHypertable_OneNamedArgPerLine() string result = Generate(op); string[] lines = result.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray(); - // Assert — each named argument appears on its own line. + // Assert Assert.Contains(lines, l => l.StartsWith("tableName: ")); Assert.Contains(lines, l => l.StartsWith("timeColumnName: ")); Assert.Contains(lines, l => l.StartsWith("schema: ")); @@ -127,10 +127,9 @@ public void CreateHypertable_RangeAndHashDimensions_FullyQualified() // Act string result = Generate(op); - // Assert — fully qualified factory calls. + // Assert Assert.Contains("CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.Dimension.CreateRange(\"range_col\", \"1 day\")", result); Assert.Contains("CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions.Dimension.CreateHash(\"hash_col\", 4)", result); - // Multi-line list with trailing comma on all but last entry. Assert.Contains("CreateRange(\"range_col\", \"1 day\"),", result); } @@ -141,7 +140,7 @@ public void CreateHypertable_RangeAndHashDimensions_FullyQualified() [Fact] public void CreateHypertable_DimensionWithNullIntervalAndPartitions_DefaultsEmitted() { - // Arrange — dimensions constructed directly so Interval/NumberOfPartitions stay null. + // Arrange CreateHypertableOperation op = new() { TableName = "sensor_data", @@ -156,7 +155,7 @@ public void CreateHypertable_DimensionWithNullIntervalAndPartitions_DefaultsEmit // Act string result = Generate(op); - // Assert — null Interval renders as "" and null NumberOfPartitions renders as 0. + // Assert Assert.Contains("Dimension.CreateRange(\"range_col\", \"\")", result); Assert.Contains("Dimension.CreateHash(\"hash_col\", 0)", result); } @@ -260,7 +259,6 @@ public void AlterHypertable_FullyPopulated_EmitsAllNewAndOldLists() Assert.Contains("compressionOrderBy: [\"ts DESC\"]", result); Assert.Contains("additionalDimensions:", result); - // Assert Assert.Contains("oldChunkTimeInterval: \"1 day\"", result); Assert.Contains("oldEnableCompression: true", result); Assert.Contains("oldChunkSkipColumns: [\"a\"]", result); diff --git a/tests/Eftdb.Tests/Design/Generators/HypertableColumnstoreCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/HypertableColumnstoreCSharpGeneratorTests.cs new file mode 100644 index 0000000..d3bb7eb --- /dev/null +++ b/tests/Eftdb.Tests/Design/Generators/HypertableColumnstoreCSharpGeneratorTests.cs @@ -0,0 +1,383 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Design.Generators; + +public class HypertableColumnstoreCSharpGeneratorTests +{ + private readonly ICSharpHelper _code = DesignTimeHelper.CreateRealCSharpHelper(); + + private string Generate(CreateHypertableOperation operation) + { + IndentedStringBuilder builder = new(); + new HypertableCSharpGenerator(_code).Generate(operation, builder); + return builder.ToString(); + } + + private string Generate(AlterHypertableOperation operation) + { + IndentedStringBuilder builder = new(); + new HypertableCSharpGenerator(_code).Generate(operation, builder); + return builder.ToString(); + } + + // ── Create: SparseIndex with value ── + + #region Create_Emits_CompressionSparseIndex_When_Non_Null + + [Fact] + public void Create_Emits_CompressionSparseIndex_When_Non_Null() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressionSparseIndex = "bloom(device_id)" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSparseIndex: \"bloom(device_id)\"", result); + } + + #endregion + + // ── Create: SparseIndex empty string (disable auto-created indexes) ── + + #region Create_Emits_CompressionSparseIndex_When_Empty_String + + [Fact] + public void Create_Emits_CompressionSparseIndex_When_Empty_String() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressionSparseIndex = string.Empty + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSparseIndex: \"\"", result); + } + + #endregion + + // ── Create: SparseIndex null → omitted ── + + #region Create_Omits_CompressionSparseIndex_When_Null + + [Fact] + public void Create_Omits_CompressionSparseIndex_When_Null() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressionSparseIndex = null + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressionSparseIndex:", result); + } + + #endregion + + // ── Create: CompressChunkTimeInterval with value ── + + #region Create_Emits_CompressChunkTimeInterval_When_Non_Empty + + [Fact] + public void Create_Emits_CompressChunkTimeInterval_When_Non_Empty() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressChunkTimeInterval = "24 hours" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressChunkTimeInterval: \"24 hours\"", result); + } + + #endregion + + // ── Create: CompressChunkTimeInterval null → omitted ── + + #region Create_Omits_CompressChunkTimeInterval_When_Null + + [Fact] + public void Create_Omits_CompressChunkTimeInterval_When_Null() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressChunkTimeInterval = null + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressChunkTimeInterval:", result); + } + + #endregion + + // ── Create: CompressChunkTimeInterval empty string → omitted ── + + #region Create_Omits_CompressChunkTimeInterval_When_Empty_String + + [Fact] + public void Create_Omits_CompressChunkTimeInterval_When_Empty_String() + { + // Arrange + CreateHypertableOperation op = new() + { + TableName = "sensor_data", + TimeColumnName = "ts", + CompressChunkTimeInterval = string.Empty + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressChunkTimeInterval:", result); + } + + #endregion + + // ── Alter: new SparseIndex value emitted ── + + #region Alter_Emits_CompressionSparseIndex_When_Non_Null + + [Fact] + public void Alter_Emits_CompressionSparseIndex_When_Non_Null() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressionSparseIndex = "bloom(device_id)" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressionSparseIndex: \"bloom(device_id)\"", result); + } + + #endregion + + // ── Alter: old SparseIndex value emitted when non-null ── + + #region Alter_Emits_OldCompressionSparseIndex_When_Non_Null + + [Fact] + public void Alter_Emits_OldCompressionSparseIndex_When_Non_Null() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressionSparseIndex = "bloom(device_id), minmax(temp)", + OldCompressionSparseIndex = "bloom(device_id)" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldCompressionSparseIndex: \"bloom(device_id)\"", result); + } + + #endregion + + // ── Alter: old SparseIndex null → omitted ── + + #region Alter_Omits_OldCompressionSparseIndex_When_Null + + [Fact] + public void Alter_Omits_OldCompressionSparseIndex_When_Null() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressionSparseIndex = "bloom(device_id)", + OldCompressionSparseIndex = null + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldCompressionSparseIndex:", result); + } + + #endregion + + // ── Alter: old SparseIndex empty string emitted ── + + #region Alter_Emits_OldCompressionSparseIndex_When_Empty_String + + [Fact] + public void Alter_Emits_OldCompressionSparseIndex_When_Empty_String() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressionSparseIndex = "bloom(device_id)", + OldCompressionSparseIndex = string.Empty + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldCompressionSparseIndex: \"\"", result); + } + + #endregion + + // ── Alter: new CompressChunkTimeInterval emitted ── + + #region Alter_Emits_CompressChunkTimeInterval_When_Non_Empty + + [Fact] + public void Alter_Emits_CompressChunkTimeInterval_When_Non_Empty() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressChunkTimeInterval = "7 days" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("compressChunkTimeInterval: \"7 days\"", result); + } + + #endregion + + // ── Alter: new CompressChunkTimeInterval null → omitted ── + + #region Alter_Omits_CompressChunkTimeInterval_When_Null + + [Fact] + public void Alter_Omits_CompressChunkTimeInterval_When_Null() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressChunkTimeInterval = null + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("compressChunkTimeInterval:", result); + } + + #endregion + + // ── Alter: old CompressChunkTimeInterval emitted ── + + #region Alter_Emits_OldCompressChunkTimeInterval_When_Non_Empty + + [Fact] + public void Alter_Emits_OldCompressChunkTimeInterval_When_Non_Empty() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressChunkTimeInterval = "7 days", + OldCompressChunkTimeInterval = "24 hours" + }; + + // Act + string result = Generate(op); + + // Assert + Assert.Contains("oldCompressChunkTimeInterval: \"24 hours\"", result); + } + + #endregion + + // ── Alter: old CompressChunkTimeInterval null → omitted ── + + #region Alter_Omits_OldCompressChunkTimeInterval_When_Null + + [Fact] + public void Alter_Omits_OldCompressChunkTimeInterval_When_Null() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressChunkTimeInterval = "7 days", + OldCompressChunkTimeInterval = null + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldCompressChunkTimeInterval:", result); + } + + #endregion + + // ── Alter: old CompressChunkTimeInterval empty string → omitted ── + + #region Alter_Omits_OldCompressChunkTimeInterval_When_Empty_String + + [Fact] + public void Alter_Omits_OldCompressChunkTimeInterval_When_Empty_String() + { + // Arrange + AlterHypertableOperation op = new() + { + TableName = "sensor_data", + CompressChunkTimeInterval = "7 days", + OldCompressChunkTimeInterval = string.Empty + }; + + // Act + string result = Generate(op); + + // Assert + Assert.DoesNotContain("oldCompressChunkTimeInterval:", result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs b/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs index 9859fd7..0f6cdda 100644 --- a/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/MigrationCallWriterTests.cs @@ -40,16 +40,14 @@ public void FirstArg_HasNoLeadingComma_LaterArgsCommaSeparatedOnNewLines() string result = GenerateAddReorder(op); string[] lines = result.Split('\n').Select(l => l.TrimEnd('\r')).ToArray(); - // Assert — opens with ".AddReorderPolicy(". + // Assert Assert.Contains(lines, l => l.TrimEnd().EndsWith(".AddReorderPolicy(")); - // First argument line (tableName) must not be preceded by a trailing comma. string trimmed = result.Replace("\r", string.Empty); int tableNameIndex = trimmed.IndexOf("tableName:"); string beforeTableName = trimmed[..tableNameIndex]; Assert.DoesNotContain(",", beforeTableName); - // Subsequent argument lines end the previous line with a comma. Assert.Contains(",\n", trimmed); } @@ -70,7 +68,7 @@ public void Dispose_AppendsClosingParen() // Act string result = GenerateAddReorder(op); - // Assert — the call is closed with a trailing ). + // Assert Assert.EndsWith(")", result.TrimEnd()); } @@ -91,7 +89,7 @@ public void NamedArgFormat_NameColonSpaceValue() // Act string result = GenerateAddReorder(op); - // Assert — name followed by ": " then the value. + // Assert Assert.Contains("tableName: \"sensor_data\"", result); Assert.Contains("indexName: \"ix_ts\"", result); } diff --git a/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs b/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs index 06e9bb8..d472913 100644 --- a/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/MigrationSnapshotGenerationTests.cs @@ -85,8 +85,6 @@ public void Should_Preserve_Feature_Annotations_In_CodeFirst_Snapshot() Assert.Contains($"HasAnnotation(\"{ContinuousAggregateAnnotations.AggregateFunctions}\"", snapshot); Assert.Contains($"HasAnnotation(\"{ContinuousAggregateAnnotations.GroupByColumns}\"", snapshot); - // Snapshots use the non-generic EntityTypeBuilder; the library's fluent extensions would not compile - // there and must never be emitted outside scaffolding. Assert.DoesNotContain(".IsHypertable(", snapshot); Assert.DoesNotContain(".IsContinuousAggregate(", snapshot); Assert.DoesNotContain(".AddAggregateFunction(", snapshot); diff --git a/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs index 6415966..27edee1 100644 --- a/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ReorderPolicyCSharpGeneratorTests.cs @@ -159,7 +159,6 @@ public void AlterReorderPolicy_FullyPopulated_EmitsAllNewAndOldArgs() Assert.Contains("maxRetries: 5", result); Assert.Contains("retryPeriod: \"15 minutes\"", result); - // Assert. Assert.Contains("oldIndexName: \"old_ix\"", result); Assert.Contains("oldInitialStart:", result); Assert.Contains("oldScheduleInterval: \"1 day\"", result); diff --git a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs index bf0da97..f360390 100644 --- a/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/ScaffoldedModelRenderingTests.cs @@ -462,7 +462,6 @@ public void Should_Not_Render_WithRefreshPolicy_When_CA_Renderer_Did_Not_Consume ScaffoldedModel result = Generate(context, useDataAnnotations: false); string code = result.ContextFile.Code; - // Policy renderer must not emit .WithRefreshPolicy() because the CA renderer failed. Assert.DoesNotContain(".WithRefreshPolicy(", code); } diff --git a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs index d5143e8..eb55562 100644 --- a/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/Generators/TimescaleCSharpModelGeneratorTests.cs @@ -447,7 +447,7 @@ public void AddNamespace_Does_Not_Duplicate_Namespace_When_Already_Present() .GetMethod("AddNamespace", BindingFlags.NonPublic | BindingFlags.Static)!; string ns = typeof(EAggregateFunction).Namespace!; - List namespaces = [ns]; // pre-populated + List namespaces = [ns]; // Act method.Invoke(null, [namespaces, typeof(EAggregateFunction)]); diff --git a/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs b/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs index b3f84a1..2777417 100644 --- a/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs +++ b/tests/Eftdb.Tests/Design/TimescaleDbCodeGeneratorTests.cs @@ -21,9 +21,7 @@ public class TimescaleDbCodeGeneratorTests [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. + // Arrange ServiceCollection services = new(); new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); @@ -33,7 +31,7 @@ public void Should_Chain_UseTimescaleDb_After_UseNpgsql() // Act MethodCallCodeFragment fragment = generator.GenerateUseProvider(ConnectionString, providerOptions: null); - // Assert — head call must remain UseNpgsql; chained call must be UseTimescaleDb. + // Assert Assert.Equal("UseNpgsql", fragment.Method); Assert.NotNull(fragment.ChainedCall); Assert.Equal("UseTimescaleDb", fragment.ChainedCall!.Method); @@ -54,7 +52,7 @@ public void Should_Resolve_TimescaleDbCodeGenerator_From_Service_Provider() using ServiceProvider provider = services.BuildServiceProvider(); IProviderConfigurationCodeGenerator generator = provider.GetRequiredService(); - // Assert — the Eftdb.Design registration must replace Npgsql's default. + // Assert Assert.IsType(generator); } @@ -65,8 +63,7 @@ public void Should_Resolve_TimescaleDbCodeGenerator_From_Service_Provider() [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. + // Arrange ServiceCollection services = new(); #pragma warning disable EF1001 new NpgsqlDesignTimeServices().ConfigureDesignTimeServices(services); diff --git a/tests/Eftdb.Tests/Differs/CompressionPolicyDifferTests.cs b/tests/Eftdb.Tests/Differs/CompressionPolicyDifferTests.cs new file mode 100644 index 0000000..06ace96 --- /dev/null +++ b/tests/Eftdb.Tests/Differs/CompressionPolicyDifferTests.cs @@ -0,0 +1,1370 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class CompressionPolicyDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region Should_Detect_New_CompressionPolicy + + private class MetricEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext1 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class CompressionPolicyContext1 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Detect_New_CompressionPolicy() + { + // Arrange + using HypertableWithoutPolicyContext1 sourceContext = new(); + using CompressionPolicyContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AddCompressionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("Metrics", addOp.TableName); + Assert.Equal("7 days", addOp.After); + Assert.Null(addOp.CreatedBefore); + } + + #endregion + + #region Should_Detect_New_CompressionPolicy_With_CreatedBefore + + private class MetricEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext2 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class CreatedBeforePolicyContext2 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public void Should_Detect_New_CompressionPolicy_With_CreatedBefore() + { + // Arrange + using HypertableWithoutPolicyContext2 sourceContext = new(); + using CreatedBeforePolicyContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AddCompressionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("Metrics", addOp.TableName); + Assert.Equal("30 days", addOp.CreatedBefore); + Assert.Null(addOp.After); + } + + #endregion + + #region Should_Detect_Multiple_New_Policies + + private class MetricEntity3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity3 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleHypertablesContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class MultipleCompressionPoliciesContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.WithCompressionPolicy(after: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "30 days"); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_New_Policies() + { + // Arrange + using MultipleHypertablesContext3 sourceContext = new(); + using MultipleCompressionPoliciesContext3 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + List addOps = [.. operations.OfType()]; + Assert.Equal(2, addOps.Count); + Assert.Contains(addOps, op => op.TableName == "Metrics"); + Assert.Contains(addOps, op => op.TableName == "Logs"); + } + + #endregion + + #region Should_Detect_Policy_With_All_Optional_Parameters + + private class MetricEntity4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext4 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class FullyConfiguredPolicyContext4 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy( + after: "14 days", + scheduleInterval: "12 hours", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + timezone: "UTC", + ifNotExists: true); + }); + } + } + + [Fact] + public void Should_Detect_Policy_With_All_Optional_Parameters() + { + // Arrange + using HypertableWithoutPolicyContext4 sourceContext = new(); + using FullyConfiguredPolicyContext4 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AddCompressionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("14 days", addOp.After); + Assert.Equal("12 hours", addOp.ScheduleInterval); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), addOp.InitialStart); + Assert.Equal("UTC", addOp.Timezone); + Assert.Equal(true, addOp.IfNotExists); + } + + #endregion + + #region Should_Detect_After_Change + + private class MetricEntity5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext5 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class ModifiedAfterContext5 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "14 days"); + }); + } + } + + [Fact] + public void Should_Detect_After_Change() + { + // Arrange + using CompressionPolicyContext5 sourceContext = new(); + using ModifiedAfterContext5 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("7 days", alterOp.OldAfter); + Assert.Equal("14 days", alterOp.After); + } + + #endregion + + #region Should_Detect_Switch_From_After_To_CreatedBefore + + private class MetricEntity6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AfterPolicyContext6 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class CreatedBeforePolicyContext6 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public void Should_Detect_Switch_From_After_To_CreatedBefore() + { + // Arrange + using AfterPolicyContext6 sourceContext = new(); + using CreatedBeforePolicyContext6 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("7 days", alterOp.OldAfter); + Assert.Null(alterOp.After); + Assert.Null(alterOp.OldCreatedBefore); + Assert.Equal("30 days", alterOp.CreatedBefore); + } + + #endregion + + #region Should_Detect_ScheduleInterval_Change + + private class MetricEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext7 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", scheduleInterval: "1 day"); + }); + } + } + + private class ModifiedScheduleContext7 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", scheduleInterval: "12 hours"); + }); + } + } + + [Fact] + public void Should_Detect_ScheduleInterval_Change() + { + // Arrange + using CompressionPolicyContext7 sourceContext = new(); + using ModifiedScheduleContext7 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("1 day", alterOp.OldScheduleInterval); + Assert.Equal("12 hours", alterOp.ScheduleInterval); + } + + #endregion + + #region Should_Detect_InitialStart_Change + + private class MetricEntity8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext8 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + private class ModifiedInitialStartContext8 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Detect_InitialStart_Change() + { + // Arrange + using CompressionPolicyContext8 sourceContext = new(); + using ModifiedInitialStartContext8 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), alterOp.OldInitialStart); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), alterOp.InitialStart); + } + + #endregion + + #region Should_Detect_Timezone_Change + + private class MetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext9 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", timezone: "UTC"); + }); + } + } + + private class ModifiedTimezoneContext9 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", timezone: "Europe/Berlin"); + }); + } + } + + [Fact] + public void Should_Detect_Timezone_Change() + { + // Arrange + using CompressionPolicyContext9 sourceContext = new(); + using ModifiedTimezoneContext9 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("UTC", alterOp.OldTimezone); + Assert.Equal("Europe/Berlin", alterOp.Timezone); + } + + #endregion + + #region Should_Detect_Dropped_CompressionPolicy + + private class MetricEntity10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext10 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class HypertableWithoutPolicyContext10 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Dropped_CompressionPolicy() + { + // Arrange + using CompressionPolicyContext10 sourceContext = new(); + using HypertableWithoutPolicyContext10 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + DropCompressionPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + Assert.Equal("Metrics", dropOp.TableName); + } + + #endregion + + #region Should_Detect_Multiple_Dropped_Policies + + private class MetricEntity11 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity11 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleCompressionPoliciesContext11 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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); + entity.WithCompressionPolicy(after: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "30 days"); + }); + } + } + + private class MultipleHypertablesContext11 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => 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.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Dropped_Policies() + { + // Arrange + using MultipleCompressionPoliciesContext11 sourceContext = new(); + using MultipleHypertablesContext11 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + List dropOps = [.. operations.OfType()]; + Assert.Equal(2, dropOps.Count); + } + + #endregion + + #region Should_Not_Generate_Operations_When_No_Changes + + private class MetricEntity12 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext12 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_No_Changes() + { + // Arrange + using CompressionPolicyContext12 sourceContext = new(); + using CompressionPolicyContext12 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + #region Should_Handle_Null_Source_Model + + private class MetricEntity13 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext13 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Source_Model() + { + // Arrange + using CompressionPolicyContext13 targetContext = new(); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(null, targetModel); + + // Assert + AddCompressionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + } + + #endregion + + #region Should_Handle_Null_Target_Model + + private class MetricEntity14 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyContext14 : 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.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Target_Model() + { + // Arrange + using CompressionPolicyContext14 sourceContext = new(); + IRelationalModel sourceModel = GetModel(sourceContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, null); + + // Assert + DropCompressionPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + } + + #endregion + + #region Should_Handle_Both_Null_Models + + [Fact] + public void Should_Handle_Both_Null_Models() + { + // Arrange + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(null, null); + + // Assert + Assert.Empty(operations); + } + + #endregion + + #region Should_Suppress_Alter_When_Null_Becomes_Computed_Default_On_Sub_Day_Chunk_Interval + + private class MetricEntity16 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SubDayNullScheduleContext16 : 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.ToTable("Metrics16"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("4 hours"); + entity.WithCompressionPolicy(after: "1 day"); + }); + } + } + + private class SubDayComputedDefaultScheduleContext16 : 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.ToTable("Metrics16"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("4 hours"); + entity.WithCompressionPolicy(after: "1 day", scheduleInterval: "2 hours"); + }); + } + } + + [Fact] + public void Should_Suppress_Alter_When_Null_Becomes_Computed_Default_On_Sub_Day_Chunk_Interval() + { + // Arrange + using SubDayNullScheduleContext16 sourceContext = new(); + using SubDayComputedDefaultScheduleContext16 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Should_Emit_Alter_When_Schedule_Interval_Is_Explicit_Non_Default_On_Sub_Day_Chunk_Interval + + private class MetricEntity17 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SubDayNullScheduleContext17 : 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.ToTable("Metrics17"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("4 hours"); + entity.WithCompressionPolicy(after: "1 day"); + }); + } + } + + private class SubDayExplicitScheduleContext17 : 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.ToTable("Metrics17"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("4 hours"); + entity.WithCompressionPolicy(after: "1 day", scheduleInterval: "1 hour"); + }); + } + } + + [Fact] + public void Should_Emit_Alter_When_Schedule_Interval_Is_Explicit_Non_Default_On_Sub_Day_Chunk_Interval() + { + // Arrange + using SubDayNullScheduleContext17 sourceContext = new(); + using SubDayExplicitScheduleContext17 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("1 hour", alterOp.ScheduleInterval); + } + + #endregion + + #region Should_Handle_Table_Rename_As_No_Op + + private class MetricEntity15 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OldNameContext15 : 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.ToTable("OldTableName"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class NewNameContext15 : 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.ToTable("NewTableName"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Handle_Table_Rename_As_No_Op() + { + // Arrange + using OldNameContext15 sourceContext = new(); + using NewNameContext15 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + FeatureDiffContext context = new() + { + TableRenames = new Dictionary<(string, string), (string, string)> + { + [("public", "OldTableName")] = ("public", "NewTableName") + } + }; + + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); + + // Assert + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + // ── B5: null scheduleInterval equals computed default for day-or-longer chunk interval ── + + #region Should_Suppress_Alter_When_Null_Becomes_Computed_Default_On_Day_Chunk_Interval + + private class MetricEntity18 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DayChunkNullScheduleContext18 : 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.ToTable("Metrics18"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class DayChunkExplicitDefaultScheduleContext18 : 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.ToTable("Metrics18"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", scheduleInterval: "12 hours"); + }); + } + } + + [Fact] + public void Should_Suppress_Alter_When_Null_Becomes_Computed_Default_On_Day_Chunk_Interval() + { + // Arrange + using DayChunkNullScheduleContext18 sourceContext = new(); + using DayChunkExplicitDefaultScheduleContext18 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + // ── B5: adding a policy produces exactly one Add and no Drop ── + + #region Should_Produce_Exactly_One_Add_And_No_Drop_When_Policy_Added + + private class MetricEntity19 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoPolicyContext19 : 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.ToTable("Metrics19"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class WithPolicyContext19 : 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.ToTable("Metrics19"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Produce_Exactly_One_Add_And_No_Drop_When_Policy_Added() + { + // Arrange + using NoPolicyContext19 sourceContext = new(); + using WithPolicyContext19 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + List addOps = [.. operations.OfType()]; + List dropOps = [.. operations.OfType()]; + + Assert.Single(addOps); + Assert.Empty(dropOps); + Assert.Equal("Metrics19", addOps[0].TableName); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregateCompressionDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregateCompressionDifferTests.cs new file mode 100644 index 0000000..bdc9880 --- /dev/null +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregateCompressionDifferTests.cs @@ -0,0 +1,592 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class ContinuousAggregateCompressionDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + => context.GetService().Model.GetRelationalModel(); + + // ── Helpers ────────────────────────────────────────────────────────────── + + private const string ConnStr = "Host=localhost;Database=test;Username=test;Password=test"; + + private static DbContextOptionsBuilder BaseOptions(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + #region Should_Detect_Compression_Enabled_On_New_CAgg + + private class CompNewSource1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompNewAgg1 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoCompressionContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_new_source1"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_new_cagg1", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class WithCompressionContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_new_source1"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_new_cagg1", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(); + }); + } + } + + [Fact] + public void Should_Detect_Compression_Enabled_On_New_CAgg() + { + // Arrange + using NoCompressionContext1 source = new(); + using WithCompressionContext1 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + AlterContinuousAggregateOperation? alter = ops.OfType().SingleOrDefault(); + Assert.NotNull(alter); + Assert.True(alter.EnableCompression); + Assert.False(alter.OldEnableCompression); + Assert.Equal("comp_new_cagg1", alter.MaterializedViewName); + } + + #endregion + + #region Should_Detect_Compression_Disabled + + private class CompDisableSource2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompDisableAgg2 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CompressionEnabledContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_disable_source2"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_disable_cagg2", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(); + }); + } + } + + private class CompressionRemovedContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_disable_source2"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_disable_cagg2", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Detect_Compression_Disabled() + { + // Arrange + using CompressionEnabledContext2 source = new(); + using CompressionRemovedContext2 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + AlterContinuousAggregateOperation? alter = ops.OfType().SingleOrDefault(); + Assert.NotNull(alter); + Assert.False(alter.EnableCompression); + Assert.True(alter.OldEnableCompression); + } + + #endregion + + #region Should_Detect_SegmentBy_Change + + private class CompSegSource3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string Region { get; set; } = string.Empty; + } + + private class CompSegAgg3 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = string.Empty; + } + + private class SegmentByRegionContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_seg_source3"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_seg_cagg3", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.Region); + }); + } + } + + private class SegmentByChangedContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_seg_source3"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_seg_cagg3", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.TimeBucket); + }); + } + } + + [Fact] + public void Should_Detect_SegmentBy_Change() + { + // Arrange + using SegmentByRegionContext3 source = new(); + using SegmentByChangedContext3 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + AlterContinuousAggregateOperation? alter = ops.OfType().SingleOrDefault(); + Assert.NotNull(alter); + Assert.NotEqual(alter.CompressionSegmentBy, alter.OldCompressionSegmentBy); + } + + #endregion + + #region Should_Detect_OrderBy_Change + + private class CompOrdSource4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompOrdAgg4 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class OrderByAscContext4 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_ord_source4"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_ord_cagg4", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => s.By(x => x.TimeBucket)); + }); + } + } + + private class OrderByDescContext4 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_ord_source4"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_ord_cagg4", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => s.ByDescending(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void Should_Detect_OrderBy_Change() + { + // Arrange + using OrderByAscContext4 source = new(); + using OrderByDescContext4 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + AlterContinuousAggregateOperation? alter = ops.OfType().SingleOrDefault(); + Assert.NotNull(alter); + Assert.NotNull(alter.CompressionOrderBy); + Assert.NotNull(alter.OldCompressionOrderBy); + } + + #endregion + + #region Should_Not_Generate_Operation_When_Compression_Unchanged + + private class CompNoopSource5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompNoopAgg5 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CompUnchangedContext5 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_noop_source5"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_noop_cagg5", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operation_When_Compression_Unchanged() + { + // Arrange + using CompUnchangedContext5 source = new(); + using CompUnchangedContext5 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + Assert.Empty(ops); + } + + #endregion + + #region Should_Detect_Compression_On_Newly_Created_CAgg + + private class CompCreateSource6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompCreateAgg6 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class NoCAggContext6 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_create_source6"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + } + } + + private class CompressedCAggContext6 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_create_source6"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_create_cagg6", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.TimeBucket) + .WithCompressionOrderBy(s => s.By(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void Should_Detect_Compression_On_Newly_Created_CAgg() + { + // Arrange + using NoCAggContext6 source = new(); + using CompressedCAggContext6 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + CreateContinuousAggregateOperation? create = ops.OfType().SingleOrDefault(); + Assert.NotNull(create); + Assert.True(create.EnableCompression); + Assert.NotNull(create.CompressionSegmentBy); + Assert.NotEmpty(create.CompressionSegmentBy); + Assert.NotNull(create.CompressionOrderBy); + Assert.NotEmpty(create.CompressionOrderBy); + } + + #endregion + + #region Should_Populate_OldCompression_Values_In_Alter + + private class CompOldValSource7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + public string Region { get; set; } = string.Empty; + } + + private class CompOldValAgg7 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + public string Region { get; set; } = string.Empty; + } + + private class OldValInitialContext7 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_oldval_source7"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_oldval_cagg7", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.Region) + .WithCompressionOrderBy(s => s.By(x => x.TimeBucket)); + }); + } + } + + private class OldValModifiedContext7 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_oldval_source7"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_oldval_cagg7", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.TimeBucket) + .WithCompressionOrderBy(s => s.ByDescending(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void Should_Populate_OldCompression_Values_In_Alter() + { + // Arrange + using OldValInitialContext7 source = new(); + using OldValModifiedContext7 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + AlterContinuousAggregateOperation? alter = ops.OfType().SingleOrDefault(); + Assert.NotNull(alter); + Assert.NotNull(alter.OldCompressionSegmentBy); + Assert.NotEmpty(alter.OldCompressionSegmentBy); + Assert.NotNull(alter.OldCompressionOrderBy); + Assert.NotEmpty(alter.OldCompressionOrderBy); + } + + #endregion + + #region Should_Treat_Implicit_And_Explicit_ASC_As_Equal + + private class CompOrderNormSource8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompOrderNormAgg8 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class ImplicitAscContext8 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_ordnorm_source8"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_ordnorm_cagg8", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => s.By(x => x.TimeBucket)); + }); + } + } + + private class ExplicitAscContext8 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) => BaseOptions(b); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.ToTable("comp_ordnorm_source8"); e.HasNoKey(); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("comp_ordnorm_cagg8", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(new OrderBy("TimeBucket", isAscending: true)); + }); + } + } + + [Fact] + public void Should_Treat_Implicit_And_Explicit_ASC_As_Equal() + { + // Arrange + using ImplicitAscContext8 source = new(); + using ExplicitAscContext8 target = new(); + + // Act + IReadOnlyList ops = new ContinuousAggregateDiffer() + .GetDifferences(GetModel(source), GetModel(target)); + + // Assert + Assert.DoesNotContain(ops, op => op is AlterContinuousAggregateOperation); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/ContinuousAggregatePolicyDifferTests.cs b/tests/Eftdb.Tests/Differs/ContinuousAggregatePolicyDifferTests.cs index 7f04bde..662a5f5 100644 --- a/tests/Eftdb.Tests/Differs/ContinuousAggregatePolicyDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/ContinuousAggregatePolicyDifferTests.cs @@ -289,7 +289,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "1 hour"); // <-- Changed from "1 month" + .WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "1 hour"); }); } } @@ -391,7 +391,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "1 month", endOffset: "30 minutes", scheduleInterval: "1 hour"); // <-- Changed from "1 hour" + .WithRefreshPolicy(startOffset: "1 month", endOffset: "30 minutes", scheduleInterval: "1 hour"); }); } } @@ -491,7 +491,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "30 minutes"); // <-- Changed from "1 hour" + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "30 minutes"); }); } } @@ -593,7 +593,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - .WithInitialStart(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); // <-- Changed from 2025-01-01 + .WithInitialStart(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); }); } } @@ -696,7 +696,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - .WithIncludeTieredData(true); // <-- Changed from false + .WithIncludeTieredData(true); }); } } @@ -798,7 +798,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - .WithBucketsPerBatch(10); // <-- Changed from 5 + .WithBucketsPerBatch(10); }); } } @@ -900,7 +900,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - .WithMaxBatchesPerExecution(10); // <-- Changed from 5 + .WithMaxBatchesPerExecution(10); }); } } @@ -1002,7 +1002,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") - .WithRefreshNewestFirst(false); // <-- Changed from true + .WithRefreshNewestFirst(false); }); } } @@ -1330,7 +1330,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp) - .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); // <-- Policy removed + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); }); modelBuilder.Entity(entity => @@ -1348,7 +1348,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgTemperature, x => x.Temperature, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "7 days", endOffset: "30 minutes", scheduleInterval: "30 minutes"); // <-- Policy added + .WithRefreshPolicy(startOffset: "7 days", endOffset: "30 minutes", scheduleInterval: "30 minutes"); }); } } diff --git a/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs b/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs index 920bccc..7a2c084 100644 --- a/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs +++ b/tests/Eftdb.Tests/Differs/FeatureDifferContextTests.cs @@ -75,7 +75,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { - entity.ToTable("ht_rename_new"); // <-- Renamed from ht_rename_old + entity.ToTable("ht_rename_new"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); @@ -105,7 +105,7 @@ public void HypertableDiffer_Treats_Table_Rename_As_Rename_Not_Recreate() // Act IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); - // Assert - a pure rename should produce no hypertable operations at all + // Assert Assert.DoesNotContain(operations, op => op is CreateHypertableOperation); Assert.DoesNotContain(operations, op => op is AlterHypertableOperation); } @@ -122,7 +122,7 @@ private class HtOrderByMetricSource private class HtOrderByMetricTarget { - public DateTime Moment { get; set; } // <-- Renamed from Timestamp + public DateTime Moment { get; set; } public double Value { get; set; } } @@ -169,10 +169,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void HypertableDiffer_Rewrites_OrderBy_Column_On_Rename_And_Preserves_Direction() { - // The time column AND the compression order-by reference the same renamed column. With a column-rename - // context, the source order-by ("Timestamp DESC") rewrites to ("Moment DESC") and compares equal to the - // target, so no AlterHypertableOperation is emitted. - // Arrange using HtOrderBySourceContext sourceContext = new(); using HtOrderByTargetContext targetContext = new(); @@ -184,7 +180,6 @@ public void HypertableDiffer_Rewrites_OrderBy_Column_On_Rename_And_Preserves_Dir { ColumnRenames = new Dictionary<(string, string, string), string> { - // Keyed on the post-rename table name (table name unchanged here). [(DefaultValues.DefaultSchema, "ht_orderby_rename", "Timestamp")] = "Moment", }, }; @@ -194,7 +189,7 @@ public void HypertableDiffer_Rewrites_OrderBy_Column_On_Rename_And_Preserves_Dir // Act IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); - // Assert - the order-by column rename is resolved (direction suffix preserved), so no alter is produced. + // Assert Assert.DoesNotContain(operations, op => op is AlterHypertableOperation); Assert.DoesNotContain(operations, op => op is CreateHypertableOperation); } @@ -245,7 +240,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("rp_rename_metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); - entity.WithReorderPolicy("rp_idx_new"); // <-- Index renamed from rp_idx_old + entity.WithReorderPolicy("rp_idx_new"); entity.HasIndex(x => x.Timestamp).HasDatabaseName("rp_idx_new"); }); } @@ -274,7 +269,7 @@ public void ReorderPolicyDiffer_Treats_Index_Rename_As_Rename_Not_Alter() // Act IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel, context); - // Assert - only the index changed, and that change is a known rename, so no alter is needed. + // Assert Assert.DoesNotContain(operations, op => op is AlterReorderPolicyOperation); Assert.DoesNotContain(operations, op => op is AddReorderPolicyOperation); Assert.DoesNotContain(operations, op => op is DropReorderPolicyOperation); @@ -330,10 +325,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void RetentionPolicyDiffer_Readds_Policy_When_Aggregate_Is_Recreated() { - // Source and target are identical, so a normal diff would produce nothing. But the orchestrator has - // decided to recreate the "ret_cascade_view" aggregate, which cascades to drop its retention policy. - // The differ must therefore re-add the retention policy. - // Arrange using RetCascadeContext sourceContext = new(); using RetCascadeContext targetContext = new(); @@ -360,7 +351,6 @@ public void RetentionPolicyDiffer_Readds_Policy_When_Aggregate_Is_Recreated() Assert.NotNull(addOp); Assert.Equal("30 days", addOp.DropAfter); - // It must NOT also drop the policy for the recreated view. Assert.DoesNotContain(operations, op => op is DropRetentionPolicyOperation); } @@ -414,9 +404,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void ContinuousAggregatePolicyDiffer_Readds_Refresh_Policy_When_Aggregate_Is_Recreated() { - // Identical source/target: a normal diff yields nothing. With the view marked as recreated, the refresh - // policy is dropped by the recreate cascade and must be re-added without a matching remove. - // Arrange using CapCascadeContext sourceContext = new(); using CapCascadeContext targetContext = new(); @@ -442,7 +429,6 @@ public void ContinuousAggregatePolicyDiffer_Readds_Refresh_Policy_When_Aggregate .FirstOrDefault(op => op.MaterializedViewName == "cap_cascade_view"); Assert.NotNull(addOp); - // No remove for the recreated view. Assert.DoesNotContain(operations, op => op is RemoveContinuousAggregatePolicyOperation remove && remove.MaterializedViewName == "cap_cascade_view"); } @@ -512,7 +498,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { - entity.ToTable("ca_parent_new"); // <-- Parent table renamed from ca_parent_old + entity.ToTable("ca_parent_new"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); }); @@ -532,10 +518,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void ContinuousAggregateDiffer_Treats_Parent_Table_Rename_As_Rename_Not_Recreate() { - // Only the CA's parent hypertable was renamed. With the rename in the context, the differ should NOT - // drop and recreate the aggregate. (CA column/where-clause rewriting is explicitly out of scope; only - // the parent-table rename is asserted here.) - // Arrange using CaParentRenameSourceContext sourceContext = new(); using CaParentRenameTargetContext targetContext = new(); diff --git a/tests/Eftdb.Tests/Differs/HypertableColumnstoreDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableColumnstoreDifferTests.cs new file mode 100644 index 0000000..1814fb4 --- /dev/null +++ b/tests/Eftdb.Tests/Differs/HypertableColumnstoreDifferTests.cs @@ -0,0 +1,813 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class HypertableColumnstoreDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + => context.GetService().Model.GetRelationalModel(); + + // ── SparseIndex: null → value ── + + #region Should_Detect_SparseIndex_Added + + private class SparseIndexAddedEntity { public DateTime Ts { get; set; } } + + private class SparseIndexAddedSourceContext : 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(e => + { + e.ToTable("diff_sparse_added"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + private class SparseIndexAddedTargetContext : 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(e => + { + e.ToTable("diff_sparse_added"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + [Fact] + public void Should_Detect_SparseIndex_Added() + { + // Arrange + using SparseIndexAddedSourceContext sourceContext = new(); + using SparseIndexAddedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Null(alterOp.OldCompressionSparseIndex); + Assert.Equal("bloom(device_id)", alterOp.CompressionSparseIndex); + } + + #endregion + + // ── SparseIndex: value → value2 ── + + #region Should_Detect_SparseIndex_Changed + + private class SparseIndexChangedEntity { public DateTime Ts { get; set; } } + + private class SparseIndexChangedSourceContext : 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(e => + { + e.ToTable("diff_sparse_changed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + private class SparseIndexChangedTargetContext : 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(e => + { + e.ToTable("diff_sparse_changed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id), minmax(temperature)"); + }); + } + + [Fact] + public void Should_Detect_SparseIndex_Changed() + { + // Arrange + using SparseIndexChangedSourceContext sourceContext = new(); + using SparseIndexChangedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("bloom(device_id)", alterOp.OldCompressionSparseIndex); + Assert.Equal("bloom(device_id), minmax(temperature)", alterOp.CompressionSparseIndex); + } + + #endregion + + // ── SparseIndex: value → null ── + + #region Should_Detect_SparseIndex_Removed + + private class SparseIndexRemovedEntity { public DateTime Ts { get; set; } } + + private class SparseIndexRemovedSourceContext : 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(e => + { + e.ToTable("diff_sparse_removed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + private class SparseIndexRemovedTargetContext : 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(e => + { + e.ToTable("diff_sparse_removed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public void Should_Detect_SparseIndex_Removed() + { + // Arrange + using SparseIndexRemovedSourceContext sourceContext = new(); + using SparseIndexRemovedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("bloom(device_id)", alterOp.OldCompressionSparseIndex); + Assert.Null(alterOp.CompressionSparseIndex); + } + + #endregion + + // ── SparseIndex: value → "" (disable auto-created) ── + + #region Should_Detect_SparseIndex_Set_To_Empty_String + + private class SparseIndexToEmptyEntity { public DateTime Ts { get; set; } } + + private class SparseIndexToEmptySourceContext : 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(e => + { + e.ToTable("diff_sparse_to_empty"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + private class SparseIndexToEmptyTargetContext : 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(e => + { + e.ToTable("diff_sparse_to_empty"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex(string.Empty); + }); + } + + [Fact] + public void Should_Detect_SparseIndex_Set_To_Empty_String() + { + // Arrange + using SparseIndexToEmptySourceContext sourceContext = new(); + using SparseIndexToEmptyTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("bloom(device_id)", alterOp.OldCompressionSparseIndex); + Assert.Equal(string.Empty, alterOp.CompressionSparseIndex); + } + + #endregion + + // ── SparseIndex: no change (both null) ── + + #region Should_Not_Emit_Alter_When_SparseIndex_Both_Null + + private class SparseIndexBothNullEntity { public DateTime Ts { get; set; } } + + private class SparseIndexBothNullContext : 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(e => + { + e.ToTable("diff_sparse_both_null"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_SparseIndex_Both_Null() + { + // Arrange + using SparseIndexBothNullContext sourceContext = new(); + using SparseIndexBothNullContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + // ── SparseIndex: no change (both same value) ── + + #region Should_Not_Emit_Alter_When_SparseIndex_Unchanged + + private class SparseIndexUnchangedEntity { public DateTime Ts { get; set; } } + + private class SparseIndexUnchangedContext : 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(e => + { + e.ToTable("diff_sparse_unchanged"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_SparseIndex_Unchanged() + { + // Arrange + using SparseIndexUnchangedContext sourceContext = new(); + using SparseIndexUnchangedContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + // ── CompressChunkTimeInterval: null → value ── + + #region Should_Detect_CompressChunkTimeInterval_Added + + private class CctiAddedEntity { public DateTime Ts { get; set; } } + + private class CctiAddedSourceContext : 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(e => + { + e.ToTable("diff_ccti_added"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + private class CctiAddedTargetContext : 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(e => + { + e.ToTable("diff_ccti_added"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + [Fact] + public void Should_Detect_CompressChunkTimeInterval_Added() + { + // Arrange + using CctiAddedSourceContext sourceContext = new(); + using CctiAddedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Null(alterOp.OldCompressChunkTimeInterval); + Assert.Equal("24 hours", alterOp.CompressChunkTimeInterval); + } + + #endregion + + // ── CompressChunkTimeInterval: value → value2 ── + + #region Should_Detect_CompressChunkTimeInterval_Changed + + private class CctiChangedEntity { public DateTime Ts { get; set; } } + + private class CctiChangedSourceContext : 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(e => + { + e.ToTable("diff_ccti_changed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + private class CctiChangedTargetContext : 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(e => + { + e.ToTable("diff_ccti_changed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("7 days"); + }); + } + + [Fact] + public void Should_Detect_CompressChunkTimeInterval_Changed() + { + // Arrange + using CctiChangedSourceContext sourceContext = new(); + using CctiChangedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("24 hours", alterOp.OldCompressChunkTimeInterval); + Assert.Equal("7 days", alterOp.CompressChunkTimeInterval); + } + + #endregion + + // ── CompressChunkTimeInterval: value → null ── + + #region Should_Detect_CompressChunkTimeInterval_Removed + + private class CctiRemovedEntity { public DateTime Ts { get; set; } } + + private class CctiRemovedSourceContext : 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(e => + { + e.ToTable("diff_ccti_removed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + private class CctiRemovedTargetContext : 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(e => + { + e.ToTable("diff_ccti_removed"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public void Should_Detect_CompressChunkTimeInterval_Removed() + { + // Arrange + using CctiRemovedSourceContext sourceContext = new(); + using CctiRemovedTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("24 hours", alterOp.OldCompressChunkTimeInterval); + Assert.Null(alterOp.CompressChunkTimeInterval); + } + + #endregion + + // ── CompressChunkTimeInterval: no change (both null) ── + + #region Should_Not_Emit_Alter_When_CompressChunkTimeInterval_Both_Null + + private class CctiBothNullEntity { public DateTime Ts { get; set; } } + + private class CctiBothNullContext : 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(e => + { + e.ToTable("diff_ccti_both_null"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_CompressChunkTimeInterval_Both_Null() + { + // Arrange + using CctiBothNullContext sourceContext = new(); + using CctiBothNullContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + // ── CompressChunkTimeInterval: no change (both same value) ── + + #region Should_Not_Emit_Alter_When_CompressChunkTimeInterval_Unchanged + + private class CctiUnchangedEntity { public DateTime Ts { get; set; } } + + private class CctiUnchangedContext : 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(e => + { + e.ToTable("diff_ccti_unchanged"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_CompressChunkTimeInterval_Unchanged() + { + // Arrange + using CctiUnchangedContext sourceContext = new(); + using CctiUnchangedContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + // ── CreateHypertableOperation carries both settings ── + + #region Should_Populate_Both_Fields_On_CreateHypertableOperation + + private class BothSettingsCreateEntity { public DateTime Ts { get; set; } } + + private class BothSettingsCreateSourceContext : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { } + } + + private class BothSettingsCreateTargetContext : 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(e => + { + e.ToTable("diff_both_create"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)") + .WithCompressChunkTimeInterval("24 hours"); + }); + } + + [Fact] + public void Should_Populate_Both_Fields_On_CreateHypertableOperation() + { + // Arrange + using BothSettingsCreateSourceContext sourceContext = new(); + using BothSettingsCreateTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + CreateHypertableOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + Assert.Equal("bloom(device_id)", createOp.CompressionSparseIndex); + Assert.Equal("24 hours", createOp.CompressChunkTimeInterval); + } + + #endregion + + // ── SparseIndex: column name resolution via HasColumnName ── + + #region Should_Resolve_HasColumnName_When_Building_SparseIndex_Operation_Value + + private class ColNameResolutionEntity + { + public DateTime Ts { get; set; } + public int DeviceId { get; set; } + } + + private class ColNameResolutionSourceContext : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test").UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { } + } + + private class ColNameResolutionTargetContext : 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(e => + { + e.ToTable("diff_col_name_resolution"); + e.HasNoKey(); + e.Property(x => x.DeviceId).HasColumnName("device_id"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(DeviceId)"); + }); + } + + [Fact] + public void Should_Resolve_HasColumnName_When_Building_SparseIndex_Operation_Value() + { + // Arrange + using ColNameResolutionSourceContext sourceContext = new(); + using ColNameResolutionTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + CreateHypertableOperation? createOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(createOp); + Assert.Equal("bloom(device_id)", createOp.CompressionSparseIndex); + } + + #endregion + + // ── SparseIndex: canonicalization of whitespace inside args ── + + #region Should_Not_Emit_Alter_When_SparseIndex_Differs_Only_In_Whitespace + + private class SpaceCanonEntity { public DateTime Ts { get; set; } } + + private class SpaceCanonSourceContext : 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(e => + { + e.ToTable("diff_space_canon"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(a, b)"); + }); + } + + private class SpaceCanonTargetContext : 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(e => + { + e.ToTable("diff_space_canon"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(a,b)"); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_SparseIndex_Differs_Only_In_Whitespace() + { + // Arrange + using SpaceCanonSourceContext sourceContext = new(); + using SpaceCanonTargetContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion + + // ── RewriteSource passes both settings through unchanged ── + + #region Should_Not_Emit_Alter_When_Both_Settings_Identical_After_Rewrite + + private class RewritePassthroughEntity { public DateTime Ts { get; set; } } + + private class RewritePassthroughContext : 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(e => + { + e.ToTable("diff_rewrite_passthrough"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(col)") + .WithCompressChunkTimeInterval("7 days"); + }); + } + + [Fact] + public void Should_Not_Emit_Alter_When_Both_Settings_Identical_After_Rewrite() + { + // Arrange + using RewritePassthroughContext sourceContext = new(); + using RewritePassthroughContext targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs index 1493313..ecdf175 100644 --- a/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs @@ -299,7 +299,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("Metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); - entity.WithRetentionPolicy(dropAfter: "14 days"); // <-- Changed from "7 days" + entity.WithRetentionPolicy(dropAfter: "14 days"); }); } } @@ -368,7 +368,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("Metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); - entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); // <-- Changed from dropAfter: "7 days" + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); }); } } @@ -441,7 +441,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( dropAfter: "7 days", - scheduleInterval: "12:00:00" // <-- Changed from default "1 day" + scheduleInterval: "12:00:00" ); }); } @@ -516,7 +516,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( dropAfter: "7 days", - maxRetries: 10 // <-- Changed from 5 + maxRetries: 10 ); }); } @@ -591,7 +591,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( dropAfter: "7 days", - initialStart: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc) // <-- Changed from 2025-01-01 + initialStart: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc) ); }); } @@ -662,12 +662,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( - dropAfter: "30 days", // <-- Changed from "7 days" + dropAfter: "30 days", initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), - scheduleInterval: "06:00:00", // <-- Changed from default "1 day" - maxRuntime: "02:00:00", // <-- Changed from default "00:00:00" - maxRetries: 3, // <-- Changed from default -1 - retryPeriod: "00:15:00" // <-- Changed from default "1 day" + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" ); }); } @@ -1108,7 +1108,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( dropAfter: "7 days", - maxRuntime: "02:00:00" // <-- Changed from "00:30:00" + maxRuntime: "02:00:00" ); }); } @@ -1183,7 +1183,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(x => x.Timestamp); entity.WithRetentionPolicy( dropAfter: "7 days", - retryPeriod: "00:30:00" // <-- Changed from "00:05:00" + retryPeriod: "00:30:00" ); }); } diff --git a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs index 2b42909..6df407f 100644 --- a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs +++ b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs @@ -413,7 +413,6 @@ public async Task Should_BulkCopy_To_Hypertable() int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(3, count); - // Verify data integrity List inserted = await context.Set() .OrderBy(e => e.Timestamp) .ThenBy(e => e.SensorId) @@ -641,14 +640,12 @@ public async Task Should_BulkCopy_With_Nullable_Types() List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(TestContext.Current.CancellationToken); - // First record with values Assert.Equal(42, inserted[0].NullableInt); Assert.NotNull(inserted[0].NullableDateTime); Assert.True(inserted[0].NullableBool); Assert.Equal(3.14, inserted[0].NullableDouble); Assert.Equal("Test", inserted[0].NullableString); - // Second record with nulls Assert.Null(inserted[1].NullableInt); Assert.Null(inserted[1].NullableDateTime); Assert.Null(inserted[1].NullableBool); @@ -747,7 +744,7 @@ public async Task Should_BulkCopy_With_TimeSpan() using TimeSpanContext context = new(_connectionString!); await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); - TimeSpan testDuration = new(2, 30, 45); // 2 hours, 30 minutes, 45 seconds + TimeSpan testDuration = new(2, 30, 45); List data = [ @@ -854,7 +851,6 @@ public async Task Should_BulkCopy_With_Multiple_Workers_Small_Dataset() using MultiWorkerSmallContext context = new(_connectionString!); await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); - // Only 3 items but 10 workers - should handle gracefully List data = [ new() { Id = 1, Data = "Item1" }, @@ -972,7 +968,6 @@ public async Task Should_BulkCopy_Respecting_Column_Order() new() { Column1 = 100, Column2 = testTime, Column3 = "Test" } ]; - // Map columns in specific order to match database TimescaleCopyConfig config = new TimescaleCopyConfig() .ToTable("ColumnOrderEntity") .MapColumn("Column1", e => e.Column1, NpgsqlDbType.Integer) diff --git a/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs index b15f425..a915816 100644 --- a/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs +++ b/tests/Eftdb.Tests/Extensions/TimescaleDbContextOptionsBuilderExtensionsTests.cs @@ -260,4 +260,171 @@ public void Should_Support_Multiple_UseTimescaleDb_Calls_Idempotently() } #endregion + + // ── TimescaleDbOptions ──────────────────────────────────────────────────── + + #region Should_Default_UseLegacyCompressionNames_To_False + + [Fact] + public void Should_Default_UseLegacyCompressionNames_To_False() + { + // Arrange & Act + TimescaleDbOptions options = new(); + + // Assert + Assert.False(options.UseLegacyCompressionNames); + } + + #endregion + + #region Should_Set_UseLegacyCompressionNames_True_Via_UseLegacyCompressionSql + + [Fact] + public void Should_Set_UseLegacyCompressionNames_True_Via_UseLegacyCompressionSql() + { + // Arrange + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + builder.UseTimescaleDb(o => o.UseLegacyCompressionSql()); + + // Assert + using ServiceProvider sp = builder.Options.Extensions + .OfType() + .Aggregate(new ServiceCollection(), (sc, ext) => + { + ext.ApplyServices(sc); + return sc; + }) + .BuildServiceProvider(); + + TimescaleDbOptions registeredOptions = sp.GetRequiredService(); + Assert.True(registeredOptions.UseLegacyCompressionNames); + } + + #endregion + + // ── Generic overload with Action ───────────────────── + + #region Should_Return_Typed_Builder_From_Generic_UseTimescaleDb_With_Configure + + private class GenericConfigureEntity + { + public DateTime Timestamp { get; set; } + } + + private class GenericConfigureContext : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { } + } + + [Fact] + public void Should_Return_Typed_Builder_From_Generic_UseTimescaleDb_With_Configure() + { + // Arrange + DbContextOptionsBuilder typedBuilder = new(); + typedBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + DbContextOptionsBuilder returned = + typedBuilder.UseTimescaleDb(o => o.UseLegacyCompressionSql()); + + // Assert + Assert.Same(typedBuilder, returned); + } + + #endregion + + #region Should_Apply_Configure_Action_Via_Generic_UseTimescaleDb_With_Configure + + private class GenericConfigureApplyEntity + { + public DateTime Timestamp { get; set; } + } + + private class GenericConfigureApplyContext : DbContext + { + public DbSet Items => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { } + } + + [Fact] + public void Should_Apply_Configure_Action_Via_Generic_UseTimescaleDb_With_Configure() + { + // Arrange + DbContextOptionsBuilder typedBuilder = new(); + typedBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + + // Act + typedBuilder.UseTimescaleDb(o => o.UseLegacyCompressionSql()); + + // Assert + using ServiceProvider sp = typedBuilder.Options.Extensions + .OfType() + .Aggregate(new ServiceCollection(), (sc, ext) => + { + ext.ApplyServices(sc); + return sc; + }) + .BuildServiceProvider(); + + TimescaleDbOptions registeredOptions = sp.GetRequiredService(); + Assert.True(registeredOptions.UseLegacyCompressionNames); + } + + #endregion + + // ── ExtensionInfo.LogFragment and GetServiceProviderHashCode ───────────── + + #region Should_Return_LogFragment_Using_TimescaleDB_Extensions + + [Fact] + public void Should_Return_LogFragment_Using_TimescaleDB_Extensions() + { + // Arrange + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + builder.UseTimescaleDb(); + + // Act + string? logFragment = builder.Options.Extensions + .Select(ext => ext.Info.LogFragment) + .FirstOrDefault(f => f.Contains("TimescaleDB")); + + // Assert + Assert.Equal("using TimescaleDB extensions", logFragment); + } + + #endregion + + #region Should_Return_Consistent_GetServiceProviderHashCode + + [Fact] + public void Should_Return_Consistent_GetServiceProviderHashCode() + { + // Arrange + DbContextOptionsBuilder builder1 = new(); + builder1.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + builder1.UseTimescaleDb(); + + DbContextOptionsBuilder builder2 = new(); + builder2.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test"); + builder2.UseTimescaleDb(); + + // Act + int hash1 = builder1.Options.Extensions + .Select(ext => ext.Info.GetServiceProviderHashCode()) + .Last(); + + int hash2 = builder2.Options.Extensions + .Select(ext => ext.Info.GetServiceProviderHashCode()) + .Last(); + + // Assert + Assert.Equal(hash1, hash2); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Extractors/CompressionPolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/CompressionPolicyModelExtractorTests.cs new file mode 100644 index 0000000..1322c5d --- /dev/null +++ b/tests/Eftdb.Tests/Extractors/CompressionPolicyModelExtractorTests.cs @@ -0,0 +1,865 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; + +/// +/// Tests that verify CompressionPolicyModelExtractor correctly extracts compression policy +/// configurations from EF Core models and pairs them with the owning hypertable's chunk time interval. +/// +public class CompressionPolicyModelExtractorTests +{ + private static IRelationalModel GetRelationalModel(DbContext context) + { + IModel model = context.GetService().Model; + return model.GetRelationalModel(); + } + + #region Should_Return_Empty_When_RelationalModel_Is_Null + + [Fact] + public void Should_Return_Empty_When_RelationalModel_Is_Null() + { + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(null)]; + + // Assert + Assert.Empty(entries); + } + + #endregion + + #region Should_Return_Empty_When_No_CompressionPolicy + + private class NoCompressionPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoCompressionPolicyContext : 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("no_compression_policy_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_No_CompressionPolicy() + { + // Arrange + using NoCompressionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Empty(entries); + } + + #endregion + + #region Should_Skip_When_HasCompressionPolicy_But_Neither_After_Nor_CreatedBefore + + private class HasPolicyNoIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HasPolicyNoIntervalContext : 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("has_policy_no_interval_metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + }); + } + } + + [Fact] + public void Should_Skip_When_HasCompressionPolicy_But_Neither_After_Nor_CreatedBefore() + { + // Arrange + using HasPolicyNoIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Empty(entries); + } + + #endregion + + #region Should_Extract_Entry_With_After_Only + + private class AfterOnlyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AfterOnlyContext : 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("after_only_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_Entry_With_After_Only() + { + // Arrange + using AfterOnlyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + AddCompressionPolicyOperation operation = entries[0].Operation; + Assert.Equal("after_only_metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Equal("7 days", operation.After); + Assert.Null(operation.CreatedBefore); + } + + #endregion + + #region Should_Extract_Entry_With_CreatedBefore_Only + + private class CreatedBeforeOnlyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreatedBeforeOnlyContext : 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("created_before_only_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public void Should_Extract_Entry_With_CreatedBefore_Only() + { + // Arrange + using CreatedBeforeOnlyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + AddCompressionPolicyOperation operation = entries[0].Operation; + Assert.Equal("created_before_only_metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Null(operation.After); + Assert.Equal("30 days", operation.CreatedBefore); + } + + #endregion + + #region Should_Extract_ScheduleInterval + + private class ScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : 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("schedule_interval_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days", scheduleInterval: "12 hours"); + }); + } + } + + [Fact] + public void Should_Extract_ScheduleInterval() + { + // Arrange + using ScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Equal("12 hours", entries[0].Operation.ScheduleInterval); + } + + #endregion + + #region Should_Have_Null_ScheduleInterval_When_Not_Specified + + private class NullScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("null_schedule_interval_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Have_Null_ScheduleInterval_When_Not_Specified() + { + // Arrange + using NullScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Null(entries[0].Operation.ScheduleInterval); + } + + #endregion + + #region Should_Extract_Timezone + + private class TimezoneMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimezoneContext : 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("timezone_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days", timezone: "Europe/Berlin"); + }); + } + } + + [Fact] + public void Should_Extract_Timezone() + { + // Arrange + using TimezoneContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Equal("Europe/Berlin", entries[0].Operation.Timezone); + } + + #endregion + + #region Should_Extract_IfNotExists + + private class IfNotExistsMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IfNotExistsContext : 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("if_not_exists_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days", ifNotExists: true); + }); + } + } + + [Fact] + public void Should_Extract_IfNotExists() + { + // Arrange + using IfNotExistsContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.True(entries[0].Operation.IfNotExists); + } + + #endregion + + #region Should_Extract_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : 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("initial_start_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void Should_Extract_InitialStart() + { + // Arrange + using InitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + DateTime? initialStart = entries[0].Operation.InitialStart; + Assert.NotNull(initialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, initialStart.Value); + } + + #endregion + + #region Should_Have_Null_InitialStart_When_Not_Specified + + private class NullInitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullInitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("null_initial_start_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Have_Null_InitialStart_When_Not_Specified() + { + // Arrange + using NullInitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Null(entries[0].Operation.InitialStart); + } + + #endregion + + #region Should_Pair_Entry_With_Explicit_ChunkTimeInterval + + private class ExplicitChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ExplicitChunkIntervalContext : 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("explicit_chunk_interval_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("14 days") + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Pair_Entry_With_Explicit_ChunkTimeInterval() + { + // Arrange + using ExplicitChunkIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Equal("14 days", entries[0].ChunkTimeInterval); + } + + #endregion + + #region Should_Pair_Entry_With_DefaultChunkTimeInterval_When_Not_Specified + + private class DefaultChunkIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultChunkIntervalContext : 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("default_chunk_interval_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Pair_Entry_With_DefaultChunkTimeInterval_When_Not_Specified() + { + // Arrange + using DefaultChunkIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Equal(DefaultValues.ChunkTimeInterval, entries[0].ChunkTimeInterval); + } + + #endregion + + #region Should_Extract_Custom_Schema + + private class CustomSchemaMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaContext : 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("custom_schema_metrics", "analytics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_Custom_Schema() + { + // Arrange + using CustomSchemaContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + Assert.Equal("analytics", entries[0].Operation.Schema); + } + + #endregion + + #region Should_Skip_Entity_With_No_Table_Or_View_Name + + private class NoTableNameMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoTableNameContext : 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((string?)null); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + entity.HasAnnotation(CompressionPolicyAnnotations.After, "7 days"); + }); + } + } + + [Fact] + public void Should_Skip_Entity_With_No_Table_Or_View_Name() + { + // Arrange + using NoTableNameContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Empty(entries); + } + + #endregion + + #region Should_Extract_Entry_From_Attribute + + [Hypertable("Timestamp")] + [CompressionPolicy("7 days")] + private class CompressionPolicyAttributeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionPolicyAttributeContext : 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("compression_policy_attribute_metrics"); + }); + } + } + + [Fact] + public void Should_Extract_Entry_From_Attribute() + { + // Arrange + using CompressionPolicyAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + AddCompressionPolicyOperation operation = entries[0].Operation; + Assert.Equal("compression_policy_attribute_metrics", operation.TableName); + Assert.Equal("7 days", operation.After); + } + + #endregion + + #region Should_Extract_Multiple_Entries + + private class MultipleFirstMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleSecondMetric + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultiplePoliciesContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => 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("multiple_first_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("multiple_second_metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionPolicy(after: "14 days"); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_Entries() + { + // Arrange + using MultiplePoliciesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Equal(2, entries.Count); + Assert.Contains(entries, e => e.Operation.TableName == "multiple_first_metrics"); + Assert.Contains(entries, e => e.Operation.TableName == "multiple_second_metrics"); + } + + #endregion + + #region Should_Extract_Fully_Configured_Entry + + private class FullyConfiguredMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext : 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("fully_configured_metrics", "timeseries"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("14 days") + .WithCompressionPolicy( + after: "7 days", + scheduleInterval: "6 hours", + initialStart: new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + timezone: "UTC", + ifNotExists: true); + }); + } + } + + [Fact] + public void Should_Extract_Fully_Configured_Entry() + { + // Arrange + using FullyConfiguredContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + // Act + List entries = + [.. CompressionPolicyModelExtractor.GetCompressionPolicyEntries(relationalModel)]; + + // Assert + Assert.Single(entries); + CompressionPolicyModelExtractor.CompressionPolicyEntry entry = entries[0]; + AddCompressionPolicyOperation operation = entry.Operation; + + Assert.Equal("fully_configured_metrics", operation.TableName); + Assert.Equal("timeseries", operation.Schema); + Assert.Equal("7 days", operation.After); + Assert.Null(operation.CreatedBefore); + Assert.Equal("6 hours", operation.ScheduleInterval); + Assert.NotNull(operation.InitialStart); + Assert.Equal(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), operation.InitialStart.Value); + Assert.Equal("UTC", operation.Timezone); + Assert.True(operation.IfNotExists); + Assert.Equal("14 days", entry.ChunkTimeInterval); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs index 2c03d7e..10d614c 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregateModelExtractorTests.cs @@ -1706,7 +1706,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Manually add incomplete annotations - missing MaterializedViewName entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MissingViewNameSourceMetric)); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, nameof(MissingViewNameSourceMetric.Timestamp)); @@ -1763,7 +1762,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Missing ParentName annotation entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, nameof(MissingParentNameSourceMetric.Timestamp)); @@ -1820,7 +1818,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Reference non-existent parent entity entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.ParentName, "NonExistentEntity"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour"); @@ -1871,7 +1868,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Map to view instead of table - GetTableName() will return null entity.ToView("metrics_view"); entity.IsHypertable(x => x.Timestamp); }); @@ -1937,7 +1933,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Missing TimeBucketWidth entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MissingTimeBucketWidthSourceMetric)); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketSourceColumn, nameof(MissingTimeBucketWidthSourceMetric.Timestamp)); @@ -1994,7 +1989,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Missing TimeBucketSourceColumn annotation entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MissingTimeBucketSourceAnnotationSourceMetric)); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour"); @@ -2051,7 +2045,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasNoKey(); - // Reference non-existent property in parent entity entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.MaterializedViewName, "hourly_metrics"); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.ParentName, nameof(MissingTimeBucketPropertySourceMetric)); entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.TimeBucketWidth, "1 hour"); @@ -2116,7 +2109,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp ); - // Add malformed aggregate function string (missing parts) List malformedList = ["AvgValue:Avg", "GoodValue:Sum:Value"]; entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, malformedList); }); @@ -2135,7 +2127,6 @@ public void Should_Skip_Malformed_AggregateFunction_String() // Assert Assert.Single(operations); - // Should only include the well-formed function, skipping the malformed one Assert.Single(operations[0].AggregateFunctions); Assert.Equal("GoodValue:Sum:Value", operations[0].AggregateFunctions[0]); } @@ -2183,10 +2174,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp ); - // Add aggregate functions - one with valid source, one with invalid List aggregateFunctions = [ - "AvgValue:Avg:NonExistentColumn", // Invalid - source column doesn't exist - "MinValue:Min:Value" // Valid + "AvgValue:Avg:NonExistentColumn", + "MinValue:Min:Value" ]; entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions); }); @@ -2205,7 +2195,6 @@ public void Should_Skip_AggregateFunction_When_SourceColumn_Not_Found() // Assert Assert.Single(operations); - // Should only include the valid function, skipping the one with non-existent source column Assert.Single(operations[0].AggregateFunctions); Assert.Equal("MinValue:Min:Value", operations[0].AggregateFunctions[0]); } @@ -2223,7 +2212,6 @@ private class FallbackAliasSourceMetric private class FallbackAliasHourlyMetric { public DateTime Bucket { get; set; } - // Note: AvgValue property is NOT defined here to test the fallback } private class FallbackAliasContext : DbContext @@ -2252,7 +2240,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp ); - // Manually add aggregate function where alias property doesn't exist in aggregate entity List aggregateFunctions = ["AvgValue:Avg:Value"]; entity.Metadata.SetAnnotation(ContinuousAggregateAnnotations.AggregateFunctions, aggregateFunctions); }); @@ -2272,7 +2259,6 @@ public void Should_Use_Model_Name_As_Alias_When_Property_Not_Found_In_Aggregate_ // Assert Assert.Single(operations); Assert.Single(operations[0].AggregateFunctions); - // Should use the model name directly since the property doesn't exist in the aggregate entity Assert.Equal("AvgValue:Avg:Value", operations[0].AggregateFunctions[0]); } @@ -2315,9 +2301,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -2381,8 +2364,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -2407,7 +2388,6 @@ public void Should_Extract_AggregateFunction_When_SourceColumnName_Annotation_Ho // 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]); } @@ -2467,7 +2447,7 @@ public void Should_Extract_ContinuousAggregate_When_ViewDefinition_Is_Set_Withou // Act List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; - // Assert - exactly one operation emitted with raw ViewDefinition populated, structured fields empty + // Assert CreateContinuousAggregateOperation operation = Assert.Single(operations); Assert.NotNull(operation.ViewDefinition); Assert.Contains("time_bucket('1 hour'", operation.ViewDefinition); @@ -2550,7 +2530,6 @@ private class ApiRequestLogHourlyAggregate private class TableNameParentLookupContext : DbContext { - // Note: CLR class is "ApiRequestLog" but the table is "ApiRequestLogs". public DbSet Logs => Set(); public DbSet HourlyLogs => Set(); @@ -2563,7 +2542,6 @@ 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); }); @@ -2626,7 +2604,6 @@ 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); }); @@ -2634,7 +2611,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -2656,7 +2632,7 @@ public void Should_Use_View_Schema_When_ToView_Specifies_Custom_Schema() // Act List operations = [.. ContinuousAggregateModelExtractor.GetContinuousAggregates(relationalModel)]; - // Assert - schema resolution prefers GetViewSchema() over the parent's schema and the default + // Assert CreateContinuousAggregateOperation operation = Assert.Single(operations); Assert.Equal("custom_schema", operation.Schema); } diff --git a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs index 4eab68b..73c7e30 100644 --- a/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/ContinuousAggregatePolicyModelExtractorTests.cs @@ -243,7 +243,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsContinuousAggregate( "hourly_metrics", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); - // Note: No WithRefreshPolicy() call }); } } @@ -372,7 +371,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); - // No optional policy methods called }); } } @@ -559,7 +557,6 @@ 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); }); @@ -567,7 +564,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -614,7 +610,6 @@ private class TableNameParentLookupPolicyAggregate private class TableNameParentLookupPolicyContext : DbContext { - // Note: CLR name "TableNameParentLookupPolicyMetric" but the table is "ApiRequestLogs". public DbSet Logs => Set(); public DbSet HourlyLogs => Set(); @@ -636,7 +631,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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); @@ -702,7 +696,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -768,9 +761,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -885,7 +875,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Parent has no explicit schema. modelBuilder.Entity(entity => { entity.HasNoKey(); @@ -896,9 +885,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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); diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index 0e25aa1..0914ef1 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -368,7 +368,6 @@ public void Should_Extract_CompressionSegmentBy() Assert.NotNull(operation.CompressionSegmentBy); Assert.Single(operation.CompressionSegmentBy); Assert.Equal("TenantId", operation.CompressionSegmentBy[0]); - // Compression should be enabled implicitly Assert.True(operation.EnableCompression); } @@ -419,7 +418,6 @@ public void Should_Extract_CompressionOrderBy() Assert.NotNull(operation.CompressionOrderBy); Assert.Equal(2, operation.CompressionOrderBy.Count); - // Verify formatted strings Assert.Equal("Timestamp DESC", operation.CompressionOrderBy[0]); Assert.Equal("Value NULLS FIRST", operation.CompressionOrderBy[1]); @@ -472,12 +470,9 @@ public void Should_Resolve_Compression_Columns_With_Naming_Convention() Assert.Single(operations); CreateHypertableOperation operation = operations[0]; - // Verify SegmentBy (TenantId -> tenant_id) Assert.NotNull(operation.CompressionSegmentBy); Assert.Equal("tenant_id", operation.CompressionSegmentBy[0]); - // Verify OrderBy (SensorValue -> sensor_value) - // This confirms the complex parsing logic in Extractor works (Split -> Resolve -> Rebuild) Assert.NotNull(operation.CompressionOrderBy); Assert.Equal("sensor_value DESC", operation.CompressionOrderBy[0]); } @@ -508,7 +503,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasNoKey(); entity.ToTable("Metrics"); - // Explicitly map properties to different column names entity.Property(x => x.TenantId).HasColumnName("tid"); entity.Property(x => x.SensorValue).HasColumnName("val"); @@ -532,11 +526,9 @@ public void Should_Resolve_Compression_Columns_With_Explicit_Names() Assert.Single(operations); CreateHypertableOperation operation = operations[0]; - // Verify SegmentBy used explicit name "tid" Assert.NotNull(operation.CompressionSegmentBy); Assert.Equal("tid", operation.CompressionSegmentBy[0]); - // Verify OrderBy used explicit name "val" Assert.NotNull(operation.CompressionOrderBy); Assert.Equal("val DESC", operation.CompressionOrderBy[0]); } @@ -1403,7 +1395,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); @@ -1457,7 +1448,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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") diff --git a/tests/Eftdb.Tests/Generators/CompressionPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/CompressionPolicyOperationGeneratorTests.cs new file mode 100644 index 0000000..7fde032 --- /dev/null +++ b/tests/Eftdb.Tests/Generators/CompressionPolicyOperationGeneratorTests.cs @@ -0,0 +1,672 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators; + +public class CompressionPolicyOperationGeneratorTests +{ + private static string GetGeneratedCode(dynamic operation) + { + List statements = CompressionPolicySqlGenerator.Generate(operation); + return string.Join("\n", statements); + } + + #region Generate_Add_After_with_minimal_config_creates_add_policy_sql + + [Fact] + public void Generate_Add_After_with_minimal_config_creates_add_policy_sql() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days" + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '7 days'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_Numeric_After_emits_bigint_for_integer_time_columns + + [Fact] + public void Generate_Add_Numeric_After_emits_bigint_for_integer_time_columns() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "604800000000" + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => 604800000000::bigint); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_CreatedBefore_creates_created_before_sql + + [Fact] + public void Generate_Add_CreatedBefore_creates_created_before_sql() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + CreatedBefore = "30 days" + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', created_before => INTERVAL '30 days'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("after =>", result); + } + + #endregion + + #region Generate_Add_After_with_ScheduleInterval_includes_schedule_interval_arg + + [Fact] + public void Generate_Add_After_with_ScheduleInterval_includes_schedule_interval_arg() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days", + ScheduleInterval = "12 hours" + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '7 days', schedule_interval => INTERVAL '12 hours'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_InitialStart_includes_iso_8601_timestamp + + [Fact] + public void Generate_Add_with_InitialStart_includes_iso_8601_timestamp() + { + // Arrange + DateTime testDate = new(2025, 10, 20, 12, 30, 0, DateTimeKind.Utc); + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days", + InitialStart = testDate + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '7 days', initial_start => '2025-10-20T12:30:00.0000000Z'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_Timezone_includes_timezone_arg + + [Fact] + public void Generate_Add_with_Timezone_includes_timezone_arg() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days", + Timezone = "Europe/Berlin" + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '7 days', timezone => 'Europe/Berlin'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_IfNotExists_includes_if_not_exists_arg + + [Fact] + public void Generate_Add_with_IfNotExists_includes_if_not_exists_arg() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days", + IfNotExists = true + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '7 days', if_not_exists => true); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_all_optional_args_creates_fully_qualified_sql + + [Fact] + public void Generate_Add_with_all_optional_args_creates_fully_qualified_sql() + { + // Arrange + DateTime testDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + ScheduleInterval = "12 hours", + InitialStart = testDate, + Timezone = "UTC", + IfNotExists = true + }; + + string expected = @" + CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '14 days', schedule_interval => INTERVAL '12 hours', initial_start => '2025-01-01T00:00:00.0000000Z', timezone => 'UTC', if_not_exists => true); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_custom_schema_uses_correct_regclass_quoting + + [Fact] + public void Generate_Add_with_custom_schema_uses_correct_regclass_quoting() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "analytics", + TableName = "EventLogs", + After = "7 days" + }; + + string expected = @" + CALL add_columnstore_policy('analytics.""EventLogs""', after => INTERVAL '7 days'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_creates_remove_then_add_sql + + [Fact] + public void Generate_Alter_creates_remove_then_add_sql() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + OldAfter = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("remove_columnstore_policy", statements[0]); + Assert.Contains("add_columnstore_policy", statements[1]); + } + + #endregion + + #region Generate_Alter_remove_uses_if_exists_true + + [Fact] + public void Generate_Alter_remove_uses_if_exists_true() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + OldAfter = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Contains("if_exists => true", statements[0]); + } + + #endregion + + #region Generate_Alter_After_creates_correct_add_sql + + [Fact] + public void Generate_Alter_After_creates_correct_add_sql() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + OldAfter = "7 days" + }; + + string expectedRemove = @"CALL remove_columnstore_policy('public.""TestTable""', if_exists => true);"; + string expectedAdd = @"CALL add_columnstore_policy('public.""TestTable""', after => INTERVAL '14 days');"; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Equal(SqlHelper.NormalizeSql(expectedRemove), SqlHelper.NormalizeSql(statements[0])); + Assert.Equal(SqlHelper.NormalizeSql(expectedAdd), SqlHelper.NormalizeSql(statements[1])); + } + + #endregion + + #region Generate_Alter_switches_to_CreatedBefore + + [Fact] + public void Generate_Alter_switches_to_CreatedBefore() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = null, + OldAfter = "7 days", + CreatedBefore = "30 days", + OldCreatedBefore = null + }; + + string expectedAdd = @"CALL add_columnstore_policy('public.""TestTable""', created_before => INTERVAL '30 days');"; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Equal(SqlHelper.NormalizeSql(expectedAdd), SqlHelper.NormalizeSql(statements[1])); + Assert.DoesNotContain("after =>", statements[1]); + } + + #endregion + + #region Generate_Alter_with_all_optional_args + + [Fact] + public void Generate_Alter_with_all_optional_args() + { + // Arrange + DateTime testDate = new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + OldAfter = "7 days", + ScheduleInterval = "6 hours", + InitialStart = testDate, + Timezone = "Europe/Berlin", + IfNotExists = true + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("after => INTERVAL '14 days'", statements[1]); + Assert.Contains("schedule_interval => INTERVAL '6 hours'", statements[1]); + Assert.Contains("2026-06-15T00:00:00.0000000Z", statements[1]); + Assert.Contains("timezone => 'Europe/Berlin'", statements[1]); + Assert.Contains("if_not_exists => true", statements[1]); + } + + #endregion + + #region Generate_Alter_with_custom_schema_uses_correct_regclass_quoting + + [Fact] + public void Generate_Alter_with_custom_schema_uses_correct_regclass_quoting() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "analytics", + TableName = "EventLogs", + After = "14 days", + OldAfter = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.All(statements, s => Assert.Contains("'analytics.\"EventLogs\"'", s)); + } + + #endregion + + #region Generate_Drop_creates_correct_remove_policy_sql + + [Fact] + public void Generate_Drop_creates_correct_remove_policy_sql() + { + // Arrange + DropCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable" + }; + + string expected = @" + CALL remove_columnstore_policy('public.""TestTable""', if_exists => true); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Drop_with_custom_schema_uses_correct_regclass_quoting + + [Fact] + public void Generate_Drop_with_custom_schema_uses_correct_regclass_quoting() + { + // Arrange + DropCompressionPolicyOperation operation = new() + { + Schema = "analytics", + TableName = "EventLogs" + }; + + string expected = @" + CALL remove_columnstore_policy('analytics.""EventLogs""', if_exists => true); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_After_does_not_emit_created_before + + [Fact] + public void Generate_Add_After_does_not_emit_created_before() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("after => INTERVAL '7 days'", statements[0]); + Assert.DoesNotContain("created_before =>", statements[0]); + } + + #endregion + + #region Generate_Add_CreatedBefore_does_not_emit_after + + [Fact] + public void Generate_Add_CreatedBefore_does_not_emit_after() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + CreatedBefore = "30 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("created_before => INTERVAL '30 days'", statements[0]); + Assert.DoesNotContain("after => INTERVAL", statements[0]); + } + + #endregion + + #region Generate_Add_uses_single_quote_regclass + + [Fact] + public void Generate_Add_uses_single_quote_regclass() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("'public.\"TestTable\"'", statements[0]); + } + + #endregion + + #region Generate_Drop_uses_single_quote_regclass + + [Fact] + public void Generate_Drop_uses_single_quote_regclass() + { + // Arrange + DropCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("'public.\"TestTable\"'", statements[0]); + } + + #endregion + + // ── Legacy mode ─────────────────────────────────────────────────────────── + + #region Legacy_Generate_Add_After_emits_select_add_compression_policy + + [Fact] + public void Legacy_Generate_Add_After_emits_select_add_compression_policy() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "7 days" + }; + + string expected = @" + SELECT add_compression_policy('public.""TestTable""', compress_after => INTERVAL '7 days'); + "; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Legacy_Generate_Add_CreatedBefore_emits_compress_created_before + + [Fact] + public void Legacy_Generate_Add_CreatedBefore_emits_compress_created_before() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + CreatedBefore = "30 days" + }; + + string expected = @" + SELECT add_compression_policy('public.""TestTable""', compress_created_before => INTERVAL '30 days'); + "; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + Assert.DoesNotContain("compress_after", result); + } + + #endregion + + #region Legacy_Generate_Drop_emits_select_remove_compression_policy + + [Fact] + public void Legacy_Generate_Drop_emits_select_remove_compression_policy() + { + // Arrange + DropCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable" + }; + + string expected = @" + SELECT remove_compression_policy('public.""TestTable""', if_exists => true); + "; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Legacy_Generate_Alter_emits_legacy_remove_then_add + + [Fact] + public void Legacy_Generate_Alter_emits_legacy_remove_then_add() + { + // Arrange + AlterCompressionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + After = "14 days", + OldAfter = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, useLegacyCompressionNames: true); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("SELECT remove_compression_policy", statements[0]); + Assert.Contains("SELECT add_compression_policy", statements[1]); + Assert.Contains("compress_after => INTERVAL '14 days'", statements[1]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs new file mode 100644 index 0000000..01e8e37 --- /dev/null +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs @@ -0,0 +1,553 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators; + +public class ContinuousAggregateCompressionSqlGeneratorTests +{ + private static List Generate(CreateContinuousAggregateOperation op) + => ContinuousAggregateSqlGenerator.Generate(op); + + private static List Generate(AlterContinuousAggregateOperation op) + => ContinuousAggregateSqlGenerator.Generate(op); + + // ── Create with compression ─────────────────────────────────────────────── + + #region Create_WithCompression_Only_Emits_License_Guard_Block + + [Fact] + public void Create_WithCompression_Only_Emits_License_Guard_Block() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "hourly_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_val:Avg:value"], + EnableCompression = true, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + string compressionStmt = statements[1]; + Assert.Contains("DO $$", compressionStmt); + Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); + Assert.DoesNotContain("compress_segmentby", compressionStmt); + Assert.DoesNotContain("compress_orderby", compressionStmt); + } + + #endregion + + #region Create_WithCompression_And_SegmentBy_Emits_SegmentBy + + [Fact] + public void Create_WithCompression_And_SegmentBy_Emits_SegmentBy() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "seg_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + EnableCompression = true, + CompressionSegmentBy = ["region"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + string compressionStmt = statements[1]; + Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); + Assert.Contains("segmentby = ''\"region\"''", compressionStmt); + } + + #endregion + + #region Create_WithCompression_And_OrderBy_Emits_OrderBy + + [Fact] + public void Create_WithCompression_And_OrderBy_Emits_OrderBy() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "ord_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_v:Avg:value"], + EnableCompression = true, + CompressionOrderBy = ["time DESC"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + string compressionStmt = statements[1]; + Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); + Assert.Contains("orderby = ''\"time\" DESC''", compressionStmt); + } + + #endregion + + #region Create_WithSegmentByAndOrderBy_Emits_Both + + [Fact] + public void Create_WithSegmentByAndOrderBy_Emits_Both() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "full_comp_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_v:Avg:value"], + EnableCompression = true, + CompressionSegmentBy = ["device_id"], + CompressionOrderBy = ["time DESC"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + string compressionStmt = statements[1]; + Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); + Assert.Contains("segmentby", compressionStmt); + Assert.Contains("orderby", compressionStmt); + } + + #endregion + + #region Create_WithNoCompression_Emits_SingleStatement + + [Fact] + public void Create_WithNoCompression_Emits_SingleStatement() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "no_comp_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_v:Avg:value"], + EnableCompression = false, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + Assert.DoesNotContain("compress", statements[0]); + } + + #endregion + + #region Create_WithViewDefinition_And_Compression_Emits_CreateThenGuard + + [Fact] + public void Create_WithViewDefinition_And_Compression_Emits_CreateThenGuard() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "raw_comp_cagg", + Schema = "public", + ParentName = "metrics", + ViewDefinition = "SELECT time_bucket('1 hour', time) AS bucket FROM metrics GROUP BY bucket", + EnableCompression = true, + CompressionSegmentBy = ["region"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains("CREATE MATERIALIZED VIEW", statements[0]); + Assert.Contains("DO $$", statements[1]); + Assert.Contains("timescaledb.enable_columnstore = true", statements[1]); + } + + #endregion + + #region Create_SegmentBy_QuotesColumnIdentifiers + + [Fact] + public void Create_SegmentBy_QuotesColumnIdentifiers() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "quote_check_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["cnt:Count:id"], + EnableCompression = true, + CompressionSegmentBy = ["device_id", "region"], + }; + + // Act + List statements = Generate(op); + + // Assert + string compressionStmt = statements[1]; + Assert.Contains("\"device_id\", \"region\"", compressionStmt); + } + + #endregion + + // ── Alter with compression ──────────────────────────────────────────────── + + #region Alter_EnableCompression_EmitsLicenseGuardWithTrue + + [Fact] + public void Alter_EnableCompression_EmitsLicenseGuardWithTrue() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_comp_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + string stmt = statements[0]; + Assert.Contains("DO $$", stmt); + Assert.Contains("timescaledb.enable_columnstore = true", stmt); + } + + #endregion + + #region Alter_DisableCompression_EmitsLicenseGuardWithFalse + + [Fact] + public void Alter_DisableCompression_EmitsLicenseGuardWithFalse() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_disable_comp", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + Assert.Contains("timescaledb.enable_columnstore = false", statements[0]); + } + + #endregion + + #region Alter_ChangeSegmentBy_EmitsNewSegmentBy + + [Fact] + public void Alter_ChangeSegmentBy_EmitsNewSegmentBy() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_seg_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + CompressionSegmentBy = ["device_id"], + OldCompressionSegmentBy = ["region"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + string stmt = statements[0]; + Assert.Contains("segmentby = ''\"device_id\"''", stmt); + Assert.DoesNotContain("\"region\"", stmt); + } + + #endregion + + #region Alter_RemoveSegmentBy_EmitsEmptyString + + [Fact] + public void Alter_RemoveSegmentBy_EmitsEmptyString() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_rem_seg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + CompressionSegmentBy = null, + OldCompressionSegmentBy = ["region"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + Assert.Contains("segmentby = ''", statements[0]); + } + + #endregion + + #region Alter_ChangeOrderBy_EmitsNewOrderBy + + [Fact] + public void Alter_ChangeOrderBy_EmitsNewOrderBy() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_ord_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + CompressionOrderBy = ["time DESC"], + OldCompressionOrderBy = ["time ASC"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + Assert.Contains("orderby = ''\"time\" DESC''", statements[0]); + } + + #endregion + + #region Alter_NoCompressionChange_EmitsNoCompressionStatement + + [Fact] + public void Alter_NoCompressionChange_EmitsNoCompressionStatement() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_no_comp_change", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + CompressionSegmentBy = ["region"], + OldCompressionSegmentBy = ["region"], + CompressionOrderBy = ["time ASC"], + OldCompressionOrderBy = ["time ASC"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.DoesNotContain(statements, s => s.Contains("timescaledb.enable_columnstore")); + } + + #endregion + + #region Alter_CompressionWithOtherChanges_EmitsBothStatements + + [Fact] + public void Alter_CompressionWithOtherChanges_EmitsBothStatements() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "alter_mixed_cagg", + Schema = "public", + CreateGroupIndexes = true, + OldCreateGroupIndexes = false, + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Equal(2, statements.Count); + Assert.Contains(statements, s => s.Contains("create_group_indexes")); + Assert.Contains(statements, s => s.Contains("timescaledb.enable_columnstore")); + } + + #endregion + + #region Alter_WrapsCommunityFeaturesInLicenseGuard + + [Fact] + public void Alter_WrapsCommunityFeaturesInLicenseGuard() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "guard_check_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + string stmt = statements[0]; + Assert.Contains("DO $$", stmt); + Assert.Contains("DECLARE", stmt); + Assert.Contains("timescaledb.license", stmt); + Assert.Contains("IF license IS NULL OR license != 'apache' THEN", stmt); + Assert.Contains("RAISE WARNING", stmt); + } + + #endregion + + #region Alter_OrderBy_QuotesColumnName_And_PreservesDirection + + [Fact] + public void Alter_OrderBy_QuotesColumnName_And_PreservesDirection() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "ord_quote_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + CompressionOrderBy = ["bucket_time DESC NULLS LAST"], + OldCompressionOrderBy = ["bucket_time ASC"], + }; + + // Act + List statements = Generate(op); + + // Assert + Assert.Single(statements); + Assert.Contains("\"bucket_time\" DESC NULLS LAST", statements[0]); + } + + #endregion + + // ── Legacy mode ─────────────────────────────────────────────────────────── + + #region Legacy_Create_WithCompression_EmitsLegacyCompressionNames + + [Fact] + public void Legacy_Create_WithCompression_EmitsLegacyCompressionNames() + { + // Arrange + CreateContinuousAggregateOperation op = new() + { + MaterializedViewName = "legacy_comp_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_v:Avg:value"], + EnableCompression = true, + CompressionSegmentBy = ["region"], + CompressionOrderBy = ["time DESC"], + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(op, useLegacyCompressionNames: true); + string compressionStmt = statements[1]; + + // Assert + Assert.Contains("timescaledb.compress = true", compressionStmt); + Assert.Contains("timescaledb.compress_segmentby", compressionStmt); + Assert.Contains("timescaledb.compress_orderby", compressionStmt); + Assert.DoesNotContain("enable_columnstore", compressionStmt); + } + + #endregion + + #region Legacy_Alter_EnableCompression_EmitsLegacyCompressName + + [Fact] + public void Legacy_Alter_EnableCompression_EmitsLegacyCompressName() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "legacy_alter_cagg", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false, + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(op, useLegacyCompressionNames: true); + + // Assert + Assert.Single(statements); + Assert.Contains("timescaledb.compress = true", statements[0]); + Assert.DoesNotContain("enable_columnstore", statements[0]); + } + + #endregion + + #region Legacy_Alter_DisableCompression_EmitsLegacyCompressFalse + + [Fact] + public void Legacy_Alter_DisableCompression_EmitsLegacyCompressFalse() + { + // Arrange + AlterContinuousAggregateOperation op = new() + { + MaterializedViewName = "legacy_disable_cagg", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true, + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(op, useLegacyCompressionNames: true); + + // Assert + Assert.Single(statements); + Assert.Contains("timescaledb.compress = false", statements[0]); + Assert.DoesNotContain("enable_columnstore", statements[0]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs index 878348d..563219b 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregatePolicyOperationGeneratorTests.cs @@ -112,7 +112,31 @@ public void Generate_Add_With_Integer_Offsets() }; string expected = @" - SELECT add_continuous_aggregate_policy('public.""sensor_data_hourly""', start_offset => 100000, end_offset => 1000, schedule_interval => INTERVAL '1 hour'); + SELECT add_continuous_aggregate_policy('public.""sensor_data_hourly""', start_offset => 100000::bigint, end_offset => 1000::bigint, schedule_interval => INTERVAL '1 hour'); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Add_With_LongRange_Integer_Offsets() + { + // Arrange + AddContinuousAggregatePolicyOperation operation = new() + { + Schema = "public", + MaterializedViewName = "sensor_data_hourly", + StartOffset = "604800000000", + EndOffset = "3600000000", + ScheduleInterval = "1 hour" + }; + + string expected = @" + SELECT add_continuous_aggregate_policy('public.""sensor_data_hourly""', start_offset => 604800000000::bigint, end_offset => 3600000000::bigint, schedule_interval => INTERVAL '1 hour'); "; // Act @@ -244,10 +268,10 @@ public void Omit_Default_Values() StartOffset = "1 month", EndOffset = "1 hour", ScheduleInterval = "1 hour", - IfNotExists = false, // Default value - BucketsPerBatch = 1, // Default value - MaxBatchesPerExecution = 0, // Default value - RefreshNewestFirst = true // Default value + IfNotExists = false, + BucketsPerBatch = 1, + MaxBatchesPerExecution = 0, + RefreshNewestFirst = true }; // Act diff --git a/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs new file mode 100644 index 0000000..d391e5d --- /dev/null +++ b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs @@ -0,0 +1,489 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators; + +public class HypertableColumnstoreSqlGeneratorTests +{ + private static string GetGeneratedSql(CreateHypertableOperation operation) + { + List statements = HypertableSqlGenerator.Generate(operation); + return string.Join("\n", statements); + } + + private static string GetGeneratedSql(AlterHypertableOperation operation) + { + List statements = HypertableSqlGenerator.Generate(operation); + return string.Join("\n", statements); + } + + // ── Create: SparseIndex only ── + + #region Should_Generate_Create_With_SparseIndex + + [Fact] + public void Should_Generate_Create_With_SparseIndex() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + CompressionSparseIndex = "bloom(device_id)" + }; + + string expected = @" + SELECT create_hypertable('public.""sensor_data""', 'ts'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id)'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Create: CompressChunkTimeInterval only ── + + #region Should_Generate_Create_With_CompressChunkTimeInterval + + [Fact] + public void Should_Generate_Create_With_CompressChunkTimeInterval() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + CompressChunkTimeInterval = "24 hours" + }; + + string expected = @" + SELECT create_hypertable('public.""sensor_data""', 'ts'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''24 hours'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Create: both settings ── + + #region Should_Generate_Create_With_SparseIndex_And_CompressChunkTimeInterval + + [Fact] + public void Should_Generate_Create_With_SparseIndex_And_CompressChunkTimeInterval() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + CompressionSparseIndex = "bloom(device_id)", + CompressChunkTimeInterval = "7 days" + }; + + string expected = @" + SELECT create_hypertable('public.""sensor_data""', 'ts'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id)'', timescaledb.compress_chunk_time_interval = ''7 days'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Create: empty-string SparseIndex (disable auto-created sparse indexes) ── + + #region Should_Generate_Create_With_Empty_SparseIndex + + [Fact] + public void Should_Generate_Create_With_Empty_SparseIndex() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + CompressionSparseIndex = string.Empty + }; + + string expected = @" + SELECT create_hypertable('public.""sensor_data""', 'ts'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = '''')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Create: null SparseIndex → no sparse_index in SET ── + + #region Should_Not_Include_SparseIndex_When_Null_On_Create + + [Fact] + public void Should_Not_Include_SparseIndex_When_Null_On_Create() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + EnableCompression = true, + CompressionSparseIndex = null + }; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.DoesNotContain("sparse_index", result); + } + + #endregion + + // ── Create: null CompressChunkTimeInterval → not included ── + + #region Should_Not_Include_CompressChunkTimeInterval_When_Null_On_Create + + [Fact] + public void Should_Not_Include_CompressChunkTimeInterval_When_Null_On_Create() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + EnableCompression = true, + CompressChunkTimeInterval = null + }; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.DoesNotContain("compress_chunk_time_interval", result); + } + + #endregion + + // ── Create: settings appear inside the community guard block ── + + #region Should_Include_SparseIndex_Inside_Community_Guard_On_Create + + [Fact] + public void Should_Include_SparseIndex_Inside_Community_Guard_On_Create() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + TimeColumnName = "ts", + CompressionSparseIndex = "bloom(device_id)" + }; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Contains("DO $$", result); + Assert.Contains("timescaledb.license", result); + Assert.Contains("sparse_index", result); + int guardStart = result.IndexOf("DO $$", StringComparison.Ordinal); + int sparseIdx = result.IndexOf("sparse_index", StringComparison.Ordinal); + Assert.True(sparseIdx > guardStart, "sparse_index must appear inside the community guard block"); + } + + #endregion + + // ── Alter: SparseIndex changed ── + + #region Should_Generate_Alter_Set_When_SparseIndex_Changed + + [Fact] + public void Should_Generate_Alter_Set_When_SparseIndex_Changed() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressionSparseIndex = "bloom(device_id), minmax(temperature)", + OldCompressionSparseIndex = "bloom(device_id)" + }; + + string expected = @" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id), minmax(temperature)'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Alter: SparseIndex removed → RESET ── + + #region Should_Generate_Alter_Reset_When_SparseIndex_Removed + + [Fact] + public void Should_Generate_Alter_Reset_When_SparseIndex_Removed() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressionSparseIndex = null, + OldCompressionSparseIndex = "bloom(device_id)" + }; + + string expected = @" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" RESET (timescaledb.sparse_index)'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Alter: CompressChunkTimeInterval changed ── + + #region Should_Generate_Alter_Set_When_CompressChunkTimeInterval_Changed + + [Fact] + public void Should_Generate_Alter_Set_When_CompressChunkTimeInterval_Changed() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressChunkTimeInterval = "7 days", + OldCompressChunkTimeInterval = "24 hours" + }; + + string expected = @" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''7 days'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Alter: CompressChunkTimeInterval removed → SET '0' ── + + #region Should_Generate_Alter_Set_Zero_When_CompressChunkTimeInterval_Removed + + [Fact] + public void Should_Generate_Alter_Set_Zero_When_CompressChunkTimeInterval_Removed() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressChunkTimeInterval = null, + OldCompressChunkTimeInterval = "24 hours" + }; + + string expected = @" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''0'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // ── Alter: RESET statements appear inside community guard ── + + #region Should_Include_Reset_Statements_Inside_Community_Guard + + [Fact] + public void Should_Include_Reset_Statements_Inside_Community_Guard() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressionSparseIndex = null, + OldCompressionSparseIndex = "bloom(device_id)", + CompressChunkTimeInterval = null, + OldCompressChunkTimeInterval = "24 hours" + }; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Contains("DO $$", result); + Assert.Contains("timescaledb.license", result); + int guardStart = result.IndexOf("DO $$", StringComparison.Ordinal); + int sparseReset = result.IndexOf("RESET (timescaledb.sparse_index)", StringComparison.Ordinal); + int cctiClear = result.IndexOf("timescaledb.compress_chunk_time_interval = ''0''", StringComparison.Ordinal); + Assert.True(sparseReset > guardStart, "RESET (timescaledb.sparse_index) must appear inside community guard"); + Assert.True(cctiClear > guardStart, "SET (timescaledb.compress_chunk_time_interval = '0') must appear inside community guard"); + } + + #endregion + + // ── Alter: both removed → RESET and SET '0' inside one guard ── + + #region Should_Generate_Both_Removal_Statements_When_Both_Removed + + [Fact] + public void Should_Generate_Both_Removal_Statements_When_Both_Removed() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + CompressionSparseIndex = null, + OldCompressionSparseIndex = "bloom(device_id)", + CompressChunkTimeInterval = null, + OldCompressChunkTimeInterval = "24 hours" + }; + + // Act + string result = GetGeneratedSql(operation); + + // Assert + Assert.Contains("RESET (timescaledb.sparse_index)", result); + Assert.Contains("timescaledb.compress_chunk_time_interval = ''0''", result); + Assert.Equal(1, result.Split("DO $$").Length - 1); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs index 9601050..15f7d45 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs @@ -19,9 +19,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators /// public class HypertableSqlGeneratorComprehensiveTests { - /// - /// Helper to run the generator and capture the SQL output. - /// private static string GetDesignTimeCode(dynamic operation) => GetRuntimeSql(operation); private static string GetRuntimeSql(dynamic operation) @@ -35,7 +32,7 @@ private static string GetRuntimeSql(dynamic operation) [Fact] public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() { - // Arrange - Test by_range() dimension syntax + // Arrange CreateHypertableOperation operation = new() { TableName = "events", @@ -63,7 +60,7 @@ public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() [Fact] public void DesignTime_Create_WithMultipleDimensions_GeneratesCorrectOrder() { - // Arrange - Test multiple dimensions (hash + range) + // Arrange CreateHypertableOperation operation = new() { TableName = "distributed_events", @@ -92,13 +89,13 @@ public void DesignTime_Create_WithMultipleDimensions_GeneratesCorrectOrder() [Fact] public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorrectCode() { - // Arrange - Test bigint interval (microseconds) + // Arrange CreateHypertableOperation operation = new() { TableName = "high_freq_data", Schema = "public", TimeColumnName = "ts", - ChunkTimeInterval = "86400000000" // 1 day in microseconds + ChunkTimeInterval = "86400000000" }; string expected = @" @@ -115,7 +112,7 @@ public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorre [Fact] public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCode() { - // Arrange - Compression enabled but no chunk skipping + // Arrange CreateHypertableOperation operation = new() { TableName = "compressed_data", @@ -133,7 +130,7 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.enable_columnstore = true)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -150,21 +147,21 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo [Fact] public void DesignTime_Create_ChunkSkippingAutoEnablesCompression_GeneratesCorrectCode() { - // Arrange - Chunk skipping automatically enables compression (TimescaleDB requirement) + // Arrange CreateHypertableOperation operation = new() { TableName = "skippable_chunks", Schema = "public", TimeColumnName = "timestamp", - EnableCompression = false, // Explicitly false + EnableCompression = false, ChunkSkipColumns = ["device_id", "sensor_type"] }; // Act string result = GetDesignTimeCode(operation); - // Assert - Compression should be auto-enabled - Assert.Contains("timescaledb.compress = true", result); + // Assert + Assert.Contains("timescaledb.enable_columnstore = true", result); } #endregion @@ -185,7 +182,7 @@ public void Runtime_Create_Minimal_GeneratesCorrectSQL() // Act string result = GetRuntimeSql(operation); - // Assert - Runtime uses single quotes (not doubled) + // Assert Assert.Contains("SELECT create_hypertable('public.\"simple_table\"', 'time')", result); Assert.EndsWith(";", result.Trim()); } @@ -193,7 +190,7 @@ public void Runtime_Create_Minimal_GeneratesCorrectSQL() [Fact] public void Runtime_Create_WithIntervalString_UsesIntervalKeyword() { - // Arrange - String intervals should use INTERVAL keyword + // Arrange CreateHypertableOperation operation = new() { TableName = "timed_data", @@ -213,7 +210,7 @@ public void Runtime_Create_WithIntervalString_UsesIntervalKeyword() [Fact] public void Runtime_Create_WithNumericInterval_UsesBigintCast() { - // Arrange - Numeric intervals should use ::bigint cast + // Arrange CreateHypertableOperation operation = new() { TableName = "numeric_interval", @@ -248,7 +245,7 @@ public void Runtime_Create_WithHashDimension_GeneratesByHashSyntax() // Act string result = GetRuntimeSql(operation); - // Assert - Must use by_hash() with partition count + // Assert Assert.Contains("add_dimension('public.\"partitioned\"', by_hash('location_id', 8))", result); } @@ -270,14 +267,14 @@ public void Runtime_Create_WithRangeDimension_GeneratesByRangeSyntax() // Act string result = GetRuntimeSql(operation); - // Assert - Must use by_range() with interval + // Assert Assert.Contains("add_dimension('public.\"ranged\"', by_range('secondary_time', INTERVAL '30 days'))", result); } [Fact] public void Runtime_Create_WithRangeDimension_IntegerInterval_GeneratesNumericByRangeSyntax() { - // Arrange - Range dimension with integer interval (no INTERVAL keyword) + // Arrange CreateHypertableOperation operation = new() { TableName = "integer_ranged", @@ -292,15 +289,15 @@ public void Runtime_Create_WithRangeDimension_IntegerInterval_GeneratesNumericBy // Act string result = GetRuntimeSql(operation); - // Assert - Integer intervals should NOT use INTERVAL keyword - Assert.Contains("add_dimension('public.\"integer_ranged\"', by_range('sensor_id', 10000))", result); + // Assert + Assert.Contains("add_dimension('public.\"integer_ranged\"', by_range('sensor_id', 10000::bigint))", result); Assert.DoesNotContain("INTERVAL", result); } [Fact] public void Runtime_Create_WithRangeDimension_TimeInterval_GeneratesIntervalByRangeSyntax() { - // Arrange - Range dimension with time-based interval + // Arrange CreateHypertableOperation operation = new() { TableName = "time_ranged", @@ -315,14 +312,14 @@ public void Runtime_Create_WithRangeDimension_TimeInterval_GeneratesIntervalByRa // Act string result = GetRuntimeSql(operation); - // Assert - Time-based intervals should use INTERVAL keyword + // Assert Assert.Contains("add_dimension('public.\"time_ranged\"', by_range('processed_time', INTERVAL '1 hour'))", result); } [Fact] public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrectCode() { - // Arrange - Design-time code for integer range dimension + // Arrange CreateHypertableOperation operation = new() { TableName = "integer_partitions", @@ -336,7 +333,7 @@ public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrec string expected = @" SELECT create_hypertable('analytics.""integer_partitions""', 'timestamp'); - SELECT add_dimension('analytics.""integer_partitions""', by_range('partition_key', 5000)); + SELECT add_dimension('analytics.""integer_partitions""', by_range('partition_key', 5000::bigint)); "; // Act @@ -371,7 +368,7 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""tenant_id"", ""device_id""'')'; + EXECUTE 'ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""tenant_id"", ""device_id""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -406,7 +403,7 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')'; + EXECUTE 'ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.enable_columnstore = true, timescaledb.orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -429,7 +426,6 @@ public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter() TableName = "full_compression", Schema = "public", TimeColumnName = "time", - // Explicit enable + segment + order EnableCompression = true, CompressionSegmentBy = ["tenant_id"], CompressionOrderBy = ["time DESC"] @@ -440,9 +436,9 @@ public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter() // Assert Assert.Contains("ALTER TABLE \"public\".\"full_compression\" SET", result); - Assert.Contains("timescaledb.compress = true", result); - Assert.Contains("timescaledb.compress_segmentby = ''\"tenant_id\"''", result); - Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); + Assert.Contains("timescaledb.enable_columnstore = true", result); + Assert.Contains("timescaledb.segmentby = ''\"tenant_id\"''", result); + Assert.Contains("timescaledb.orderby = ''\"time\" DESC''", result); } #endregion @@ -480,8 +476,8 @@ public void DesignTime_Alter_ChangingChunkInterval_FromStringToNumeric_Generates { TableName = "metrics", Schema = "public", - ChunkTimeInterval = "86400000000", // Numeric (microseconds) - OldChunkTimeInterval = "1 day" // String interval + ChunkTimeInterval = "86400000000", + OldChunkTimeInterval = "1 day" }; string expected = @" @@ -498,7 +494,7 @@ public void DesignTime_Alter_ChangingChunkInterval_FromStringToNumeric_Generates [Fact] public void DesignTime_Alter_AddingDimension_GeneratesCorrectCode() { - // Arrange - Adding a new dimension to existing hypertable + // Arrange AlterHypertableOperation operation = new() { TableName = "expandable", @@ -524,7 +520,7 @@ public void DesignTime_Alter_AddingDimension_GeneratesCorrectCode() [Fact] public void DesignTime_Alter_RemovingDimension_GeneratesWarningComment() { - // Arrange - TimescaleDB does NOT support removing dimensions + // Arrange AlterHypertableOperation operation = new() { TableName = "cannot_remove", @@ -539,7 +535,7 @@ public void DesignTime_Alter_RemovingDimension_GeneratesWarningComment() // Act string result = GetDesignTimeCode(operation); - // Assert - Should include warning comment + // Assert Assert.Contains("WARNING", result); Assert.Contains("does not support removing dimensions", result); Assert.Contains("old_column", result); @@ -548,7 +544,7 @@ public void DesignTime_Alter_RemovingDimension_GeneratesWarningComment() [Fact] public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() { - // Arrange - removing a dimension is unsupported; the generator emits a SQL comment + // Arrange AlterHypertableOperation operation = new() { TableName = "cannot_remove", @@ -563,7 +559,7 @@ public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() // Act string result = GetDesignTimeCode(operation); - // Assert - the comment must start with the "-- WARNING:" prefix and list the dimension + // Assert Assert.Contains( "-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: 'old_column'", result); @@ -572,14 +568,14 @@ public void DesignTime_Alter_RemovingDimension_EmitsExactWarningCommentLine() [Fact] public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() { - // Arrange - Changing dimension parameters (adds new, warns about old) + // Arrange AlterHypertableOperation operation = new() { TableName = "modified_dims", Schema = "public", AdditionalDimensions = [ - Dimension.CreateHash("location", 8) // Changed from 4 to 8 partitions + Dimension.CreateHash("location", 8) ], OldAdditionalDimensions = [ @@ -590,7 +586,7 @@ public void DesignTime_Alter_ModifyingDimension_GeneratesAddForNew() // Act string result = GetDesignTimeCode(operation); - // Assert - New dimension added (old one cannot be removed) + // Assert Assert.Contains("by_hash('location', 8)", result); } @@ -614,7 +610,7 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""decompress"" SET (timescaledb.compress = false)'; + EXECUTE 'ALTER TABLE ""public"".""decompress"" SET (timescaledb.enable_columnstore = false)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -631,7 +627,7 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() [Fact] public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() { - // Arrange - Adding new chunk skip columns + // Arrange AlterHypertableOperation operation = new() { TableName = "add_skip", @@ -737,7 +733,7 @@ public void Runtime_Alter_EnableCompression_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("ALTER TABLE \"public\".\"enable_compress\" SET (timescaledb.compress = true)", result); + Assert.Contains("ALTER TABLE \"public\".\"enable_compress\" SET (timescaledb.enable_columnstore = true)", result); } [Fact] @@ -756,7 +752,7 @@ public void Runtime_Alter_DisableCompression_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("ALTER TABLE \"public\".\"disable_compress\" SET (timescaledb.compress = false)", result); + Assert.Contains("ALTER TABLE \"public\".\"disable_compress\" SET (timescaledb.enable_columnstore = false)", result); } [Fact] @@ -774,7 +770,7 @@ public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() // Act string result = GetRuntimeSql(operation); - // Assert - Must SET enable_chunk_skipping = 'ON' before enable_chunk_skipping() + // Assert Assert.Contains("SET timescaledb.enable_chunk_skipping = ''ON''", result); Assert.Contains("enable_chunk_skipping(''public.\"skip_test\"'', ''new_col'')", result); } @@ -782,7 +778,7 @@ public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() [Fact] public void Runtime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorrectSQL() { - // Arrange - Adding range dimension with integer interval + // Arrange AlterHypertableOperation operation = new() { TableName = "events", @@ -797,15 +793,15 @@ public void Runtime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorr // Act string result = GetRuntimeSql(operation); - // Assert - Integer range should not use INTERVAL keyword - Assert.Contains("add_dimension('public.\"events\"', by_range('event_id', 1000))", result); + // Assert + Assert.Contains("add_dimension('public.\"events\"', by_range('event_id', 1000::bigint))", result); Assert.DoesNotContain("INTERVAL", result); } [Fact] public void Runtime_Alter_AddingRangeDimension_WithTimeInterval_GeneratesCorrectSQL() { - // Arrange - Adding range dimension with time-based interval + // Arrange AlterHypertableOperation operation = new() { TableName = "logs", @@ -820,14 +816,14 @@ public void Runtime_Alter_AddingRangeDimension_WithTimeInterval_GeneratesCorrect // Act string result = GetRuntimeSql(operation); - // Assert - Time-based range should use INTERVAL keyword + // Assert Assert.Contains("add_dimension('public.\"logs\"', by_range('ingestion_time', INTERVAL '2 hours'))", result); } [Fact] public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesCorrectCode() { - // Arrange - Design-time code for adding integer range dimension + // Arrange AlterHypertableOperation operation = new() { TableName = "metrics", @@ -840,7 +836,7 @@ public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesC }; string expected = @" - SELECT add_dimension('analytics.""metrics""', by_range('metric_id', 50000)); + SELECT add_dimension('analytics.""metrics""', by_range('metric_id', 50000::bigint)); "; // Act @@ -874,7 +870,7 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""metrics"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""device_id""'')'; + EXECUTE 'ALTER TABLE ""public"".""metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""device_id""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -896,7 +892,6 @@ public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL() { TableName = "metrics", Schema = "public", - // Changing from ASC to DESC CompressionOrderBy = ["time DESC"], OldCompressionOrderBy = ["time ASC"] }; @@ -905,9 +900,7 @@ public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - // Note: EnableCompression=true is NOT generated if it hasn't changed state (implicit false->false or true->true) - // But we do expect the update to the specific setting. - Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); + Assert.Contains("timescaledb.orderby = ''\"time\" DESC''", result); } [Fact] @@ -918,7 +911,6 @@ public void Runtime_Alter_RemovingCompressionSegmentBy_GeneratesEmptyStringSetti { TableName = "metrics", Schema = "public", - // Removing the setting CompressionSegmentBy = [], OldCompressionSegmentBy = ["device_id"] }; @@ -927,8 +919,7 @@ public void Runtime_Alter_RemovingCompressionSegmentBy_GeneratesEmptyStringSetti string result = GetRuntimeSql(operation); // Assert - // TimescaleDB requires setting the value to an empty string '' to unset it - Assert.Contains("timescaledb.compress_segmentby = ''", result); + Assert.Contains("timescaledb.segmentby = ''", result); } [Fact] @@ -939,7 +930,6 @@ public void Runtime_Alter_RemovingCompressionOrderBy_GeneratesEmptyStringSetting { TableName = "metrics", Schema = "public", - // Removing the setting CompressionOrderBy = null, OldCompressionOrderBy = ["time DESC"] }; @@ -948,7 +938,7 @@ public void Runtime_Alter_RemovingCompressionOrderBy_GeneratesEmptyStringSetting string result = GetRuntimeSql(operation); // Assert - Assert.Contains("timescaledb.compress_orderby = ''", result); + Assert.Contains("timescaledb.orderby = ''", result); } [Fact] @@ -959,17 +949,10 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() { TableName = "metrics", Schema = "public", - - // Enable compression (was off) - // Since SegmentBy was set, compression was turned on implicitly before; now explicitly enabling it EnableCompression = true, OldEnableCompression = false, - - // Change SegmentBy CompressionSegmentBy = ["new_col"], OldCompressionSegmentBy = ["old_col"], - - // Remove OrderBy CompressionOrderBy = [], OldCompressionOrderBy = ["time DESC"] }; @@ -978,10 +961,9 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() string result = GetRuntimeSql(operation); // Assert - // Should be a single ALTER TABLE statement with 3 settings Assert.Contains("ALTER TABLE \"public\".\"metrics\" SET", result); - Assert.Contains("timescaledb.compress_segmentby = ''\"new_col\"''", result); - Assert.Contains("timescaledb.compress_orderby = ''''", result); + Assert.Contains("timescaledb.segmentby = ''\"new_col\"''", result); + Assert.Contains("timescaledb.orderby = ''''", result); } #endregion @@ -991,7 +973,7 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() [Fact] public void Create_ChunkSkipping_RequiresCompression() { - // Arrange - TimescaleDB requires compression for chunk skipping + // Arrange CreateHypertableOperation operation = new() { TableName = "test", @@ -1004,14 +986,14 @@ public void Create_ChunkSkipping_RequiresCompression() // Act string result = GetRuntimeSql(operation); - // Assert - Compression must be automatically enabled - Assert.Contains("timescaledb.compress = true", result); + // Assert + Assert.Contains("timescaledb.enable_columnstore = true", result); } [Fact] public void Alter_AddingChunkSkipping_AutoEnablesCompression() { - // Arrange - Adding chunk skip columns when compression is disabled + // Arrange AlterHypertableOperation operation = new() { TableName = "test", @@ -1025,14 +1007,14 @@ public void Alter_AddingChunkSkipping_AutoEnablesCompression() // Act string result = GetRuntimeSql(operation); - // Assert - Compression should be auto-enabled - Assert.Contains("ALTER TABLE \"public\".\"test\" SET (timescaledb.compress = true)", result); + // Assert + Assert.Contains("ALTER TABLE \"public\".\"test\" SET (timescaledb.enable_columnstore = true)", result); } [Fact] public void Alter_RemovingAllChunkSkipColumns_CanDisableCompression() { - // Arrange - Removing all chunk skip columns when compression not explicitly enabled + // Arrange AlterHypertableOperation operation = new() { TableName = "test", @@ -1046,15 +1028,15 @@ public void Alter_RemovingAllChunkSkipColumns_CanDisableCompression() // Act string result = GetRuntimeSql(operation); - // Assert - Compression can be disabled when no chunk skipping - Assert.Contains("timescaledb.compress = false", result); + // Assert + Assert.Contains("timescaledb.enable_columnstore = false", result); Assert.Contains("disable_chunk_skipping", result); } [Fact] public void Alter_KeepingExplicitCompression_WhenRemovingChunkSkipping() { - // Arrange - Compression explicitly enabled, removing chunk skip columns + // Arrange AlterHypertableOperation operation = new() { TableName = "test", @@ -1068,7 +1050,7 @@ public void Alter_KeepingExplicitCompression_WhenRemovingChunkSkipping() // Act string result = GetRuntimeSql(operation); - // Assert - Compression should remain enabled + // Assert Assert.DoesNotContain("timescaledb.compress = false", result); Assert.Contains("disable_chunk_skipping", result); } @@ -1076,7 +1058,7 @@ public void Alter_KeepingExplicitCompression_WhenRemovingChunkSkipping() [Fact] public void Create_EmptyHypertable_OnlyGeneratesCreateStatement() { - // Arrange - Minimal hypertable + // Arrange CreateHypertableOperation operation = new() { TableName = "minimal", @@ -1095,7 +1077,7 @@ public void Create_EmptyHypertable_OnlyGeneratesCreateStatement() [Fact] public void Create_Dimensions_GeneratedAfterHypertableCreation() { - // Arrange - Dimensions must be added after create_hypertable + // Arrange CreateHypertableOperation operation = new() { TableName = "test", @@ -1111,7 +1093,7 @@ public void Create_Dimensions_GeneratedAfterHypertableCreation() string result = GetRuntimeSql(operation); string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); - // Assert - create_hypertable must come before add_dimension + // Assert int createIndex = Array.FindIndex(lines, l => l.Contains("create_hypertable")); int dimensionIndex = Array.FindIndex(lines, l => l.Contains("add_dimension")); Assert.True(createIndex < dimensionIndex, "create_hypertable must execute before add_dimension"); @@ -1133,7 +1115,7 @@ public void Create_Compression_GeneratedAfterHypertableCreation() string result = GetRuntimeSql(operation); string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); - // Assert - create_hypertable must come before ALTER TABLE + // Assert int createIndex = Array.FindIndex(lines, l => l.Contains("create_hypertable")); int compressIndex = Array.FindIndex(lines, l => l.Contains("ALTER TABLE")); Assert.True(createIndex < compressIndex, "create_hypertable must execute before compression settings"); @@ -1155,12 +1137,85 @@ public void Create_ChunkSkipping_GeneratedAfterCompression() string result = GetRuntimeSql(operation); string[] lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); - // Assert - Compression (ALTER TABLE) must come before chunk skipping + // Assert int compressIndex = Array.FindIndex(lines, l => l.Contains("ALTER TABLE") && l.Contains("compress")); int skipIndex = Array.FindIndex(lines, l => l.Contains("enable_chunk_skipping")); Assert.True(compressIndex < skipIndex, "Compression must be enabled before chunk skipping"); } #endregion + + #region Legacy mode + + [Fact] + public void Legacy_Create_WithCompression_EmitsLegacyOptionNames() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "legacy_create", + Schema = "public", + TimeColumnName = "ts", + EnableCompression = true, + CompressionSegmentBy = ["device_id"], + CompressionOrderBy = ["ts DESC"] + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = true", result); + Assert.Contains("timescaledb.compress_segmentby", result); + Assert.Contains("timescaledb.compress_orderby", result); + Assert.DoesNotContain("enable_columnstore", result); + Assert.DoesNotContain("timescaledb.segmentby =", result); + Assert.DoesNotContain("timescaledb.orderby =", result); + } + + [Fact] + public void Legacy_Alter_EnableCompression_EmitsLegacyCompressName() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "legacy_alter_enable", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = true", result); + Assert.DoesNotContain("enable_columnstore", result); + } + + [Fact] + public void Legacy_Alter_DisableCompression_EmitsLegacyCompressFalse() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "legacy_alter_disable", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = false", result); + Assert.DoesNotContain("enable_columnstore", result); + } + + #endregion } } diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs index bb1d4ac..70ed2a3 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs @@ -7,17 +7,12 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { public class HypertableSqlGeneratorTests { - /// - /// A helper to run the generator and capture its string output. - /// private static string GetGeneratedCode(dynamic operation) { List statements = HypertableSqlGenerator.Generate(operation); return string.Join("\n", statements); } - // --- Tests for CreateHypertableOperation --- - [Fact] public void Generate_Create_with_minimal_details_generates_correct_sql() { @@ -68,7 +63,7 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.enable_columnstore = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""FullTable""'', ''DeviceId'')'; ELSE @@ -106,7 +101,7 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""Metrics"" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""Metrics"" SET (timescaledb.enable_columnstore = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""Metrics""'', ''device_id'')'; ELSE @@ -122,8 +117,6 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } - // --- Tests for AlterHypertableOperation --- - [Fact] public void Generate_Alter_when_changing_compression_generates_correct_sql() { @@ -144,7 +137,7 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""SensorData"" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""public"".""SensorData"" SET (timescaledb.enable_columnstore = true)'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -172,7 +165,6 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre CompressionOrderBy = ["Timestamp DESC", "Value ASC NULLS LAST"] }; - // Expected: implicit compress=true, plus segmentby/orderby strings string expected = @" SELECT create_hypertable('public.""CompressedTable""', 'Timestamp'); DO $$ @@ -182,7 +174,7 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.compress_orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')'; + EXECUTE 'ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -204,7 +196,6 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() { TableName = "Metrics", Schema = "public", - // Adding segment by configuration CompressionSegmentBy = ["DeviceId"], OldCompressionSegmentBy = [] }; @@ -217,7 +208,7 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""DeviceId""'')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""DeviceId""'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -239,7 +230,6 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() { TableName = "Metrics", Schema = "public", - // Changing from ASC to DESC CompressionOrderBy = ["Timestamp DESC"], OldCompressionOrderBy = ["Timestamp ASC"] }; @@ -252,7 +242,7 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress_orderby = ''""Timestamp"" DESC'')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.orderby = ''""Timestamp"" DESC'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -276,15 +266,12 @@ public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_St Schema = "public", EnableCompression = true, OldEnableCompression = true, - - // Removing both settings CompressionSegmentBy = [], OldCompressionSegmentBy = ["DeviceId"], CompressionOrderBy = null, OldCompressionOrderBy = ["Timestamp DESC"] }; - // TimescaleDB requires setting the value to '' (empty string) to clear it string expected = @" DO $$ DECLARE @@ -293,7 +280,7 @@ public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_St license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.compress_segmentby = '''', timescaledb.compress_orderby = '''')'; + EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.segmentby = '''', timescaledb.orderby = '''')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; @@ -387,7 +374,7 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Logs"" SET (timescaledb.compress = false)'; + EXECUTE 'ALTER TABLE ""public"".""Logs"" SET (timescaledb.enable_columnstore = false)'; EXECUTE 'SELECT disable_chunk_skipping(''public.""Logs""'', ''trace_id'')'; ELSE RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; @@ -402,8 +389,6 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } - // --- Tests for MigrateData Parameter --- - [Fact] public void Generate_Create_When_MigrateData_Is_False_Does_Not_Include_Migrate_Data_Parameter() { @@ -479,7 +464,7 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com license := current_setting('timescaledb.license', true); IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.compress = true)'; + EXECUTE 'ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.enable_columnstore = true)'; EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""CompleteTable""'', ''DeviceId'')'; ELSE @@ -498,13 +483,12 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com [Fact] public void Generate_Create_Default_MigrateData_Does_Not_Include_Parameter() { - // Arrange - CreateHypertableOperation with default MigrateData (false) + // Arrange CreateHypertableOperation operation = new() { TableName = "DefaultTable", Schema = "public", TimeColumnName = "Timestamp" - // MigrateData not explicitly set, defaults to false }; string expected = @" @@ -518,5 +502,72 @@ public void Generate_Create_Default_MigrateData_Does_Not_Include_Parameter() Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); Assert.DoesNotContain("migrate_data", result); } + + [Fact] + public void Legacy_Generate_Create_WithCompression_Emits_Legacy_Names() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "LegacyTable", + Schema = "public", + TimeColumnName = "ts", + EnableCompression = true, + CompressionSegmentBy = ["device_id"], + CompressionOrderBy = ["ts DESC"] + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = true", result); + Assert.Contains("timescaledb.compress_segmentby", result); + Assert.Contains("timescaledb.compress_orderby", result); + Assert.DoesNotContain("enable_columnstore", result); + } + + [Fact] + public void Legacy_Generate_Alter_EnableCompression_Emits_compress_Name() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "LegacyAlter", + Schema = "public", + EnableCompression = true, + OldEnableCompression = false + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = true", result); + Assert.DoesNotContain("enable_columnstore", result); + } + + [Fact] + public void Legacy_Generate_Alter_DisableCompression_Emits_compress_False() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "LegacyDisable", + Schema = "public", + EnableCompression = false, + OldEnableCompression = true + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, useLegacyCompressionNames: true); + string result = string.Join("\n", statements); + + // Assert + Assert.Contains("timescaledb.compress = false", result); + Assert.DoesNotContain("enable_columnstore", result); + } } } diff --git a/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs b/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs index a7cfc7d..7569eb0 100644 --- a/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs +++ b/tests/Eftdb.Tests/Generators/PolicyJobSqlBuilderTests.cs @@ -4,8 +4,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators { public class PolicyJobSqlBuilderTests { - // --- BuildJobClauses --- - #region BuildJobClauses_AllProvided_EmitsFourClauses [Fact] @@ -68,7 +66,7 @@ public void BuildJobClauses_WhitespaceStrings_AreSkipped() [Fact] public void BuildJobClauses_MaxRetriesZero_IsEmitted() { - // Arrange — 0 is a valid value distinct from null; uses a null check, not truthiness + // Arrange int maxRetries = 0; // Act @@ -92,7 +90,7 @@ public void BuildJobClauses_MaxRetries_EmittedAsBareInt() // Act List clauses = PolicyJobSqlBuilder.BuildJobClauses(null, null, maxRetries, null); - // Assert — bare int, not wrapped in INTERVAL or quotes + // Assert Assert.Single(clauses); Assert.Equal("max_retries => 7", clauses[0]); Assert.DoesNotContain("INTERVAL", clauses[0]); @@ -100,14 +98,33 @@ public void BuildJobClauses_MaxRetries_EmittedAsBareInt() #endregion - // --- BuildChangedJobClauses --- + #region BuildAlterJobSql_QuoteInIdentifiers_EscapesStringLiterals + + [Fact] + public void BuildAlterJobSql_QuoteInIdentifiers_EscapesStringLiterals() + { + // Arrange + string tableName = "user's_table"; + string schema = "app's_schema"; + string procName = "policy_retention"; + + // Act + string sql = PolicyJobSqlBuilder.BuildAlterJobSql(tableName, schema, procName, ["schedule_interval => INTERVAL '1 day'"]); + + // Assert + Assert.Contains("hypertable_name = 'user''s_table'", sql); + Assert.Contains("hypertable_schema = 'app''s_schema'", sql); + Assert.DoesNotContain("'user's_table'", sql); + } + + #endregion #region BuildChangedJobClauses_OnlyDifferingValuesProduceClauses [Fact] public void BuildChangedJobClauses_OnlyDifferingValuesProduceClauses() { - // Arrange — only scheduleInterval changes + // Arrange List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( scheduleInterval: "2 days", oldScheduleInterval: "1 day", maxRuntime: "1 hour", oldMaxRuntime: "1 hour", @@ -163,8 +180,7 @@ public void BuildChangedJobClauses_MaxRetriesChanged_EmitsClause() [Fact] public void BuildChangedJobClauses_NewValueNullWhileOldSet_NoClause() { - // Arrange — a value cannot be cleared via alter_job: a null/whitespace new value - // while the old value is set produces no clause (documented limitation). + // Arrange List clauses = PolicyJobSqlBuilder.BuildChangedJobClauses( scheduleInterval: null, oldScheduleInterval: "1 day", maxRuntime: " ", oldMaxRuntime: "1 hour", @@ -177,8 +193,6 @@ public void BuildChangedJobClauses_NewValueNullWhileOldSet_NoClause() #endregion - // --- BuildAlterJobSql --- - #region BuildAlterJobSql_ProducesExpectedStatement [Fact] @@ -209,7 +223,7 @@ public void BuildAlterJobSql_ResultIsTrimmed() // Act string sql = PolicyJobSqlBuilder.BuildAlterJobSql("TestTable", "public", "policy_retention", clauses); - // Assert — no leading/trailing whitespace + // Assert Assert.Equal(sql, sql.Trim()); Assert.StartsWith("SELECT alter_job", sql); } diff --git a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs index 339d81f..e92cd87 100644 --- a/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/ReorderPolicyOperationGeneratorComprehensiveTests.cs @@ -37,7 +37,7 @@ private static string GetRuntimeSql(dynamic operation) [Fact] public void DesignTime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() { - // Arrange - Minimal reorder policy with only required fields (no alter_job when using defaults) + // Arrange AddReorderPolicyOperation operation = new() { TableName = "metrics", @@ -52,14 +52,14 @@ public void DesignTime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() // Act string result = GetDesignTimeCode(operation); - // Assert - Should only generate add_reorder_policy, not alter_job + // Assert Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } [Fact] public void DesignTime_Add_WithAllOptions_GeneratesCorrectCode() { - // Arrange - Policy with all optional parameters + // Arrange DateTime initialStart = new(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc); AddReorderPolicyOperation operation = new() { @@ -90,7 +90,7 @@ FROM timescaledb_information.jobs [Fact] public void DesignTime_Add_WithUnlimitedRetries_UsesNegativeOne() { - // Arrange - MaxRetries = -1 means unlimited (TimescaleDB convention) + // Arrange AddReorderPolicyOperation operation = new() { TableName = "important_data", @@ -102,14 +102,14 @@ public void DesignTime_Add_WithUnlimitedRetries_UsesNegativeOne() // Act string result = GetDesignTimeCode(operation); - // Assert - Should use max_retries => -1 for unlimited + // Assert Assert.Contains("max_retries => -1", result); } [Fact] public void DesignTime_Add_WithNoMaxRuntime_UsesZeroInterval() { - // Arrange - MaxRuntime = "00:00:00" means no limit (TimescaleDB convention) + // Arrange AddReorderPolicyOperation operation = new() { TableName = "data", @@ -121,7 +121,7 @@ public void DesignTime_Add_WithNoMaxRuntime_UsesZeroInterval() // Act string result = GetDesignTimeCode(operation); - // Assert - Should use INTERVAL '00:00:00' for no limit + // Assert Assert.Contains("INTERVAL '00:00:00'", result); } @@ -141,7 +141,7 @@ public void DesignTime_Add_WithInitialStart_FormatsAsISO8601() // Act string result = GetDesignTimeCode(operation); - // Assert - Should use ISO 8601 format with microseconds and Z suffix + // Assert Assert.Contains("2025-03-15T10:30:45", result); Assert.Contains("Z", result); } @@ -153,7 +153,7 @@ public void DesignTime_Add_WithInitialStart_FormatsAsISO8601() [Fact] public void Runtime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() { - // Arrange - Minimal policy without custom scheduling + // Arrange AddReorderPolicyOperation operation = new() { TableName = "simple_table", @@ -164,7 +164,7 @@ public void Runtime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() // Act List statements = GetRuntimeSqlStatements(operation); - // Assert - Should only generate add_reorder_policy (uses TimescaleDB defaults) + // Assert Assert.Single(statements); Assert.Contains("SELECT add_reorder_policy('public.\"simple_table\"', 'simple_idx')", statements[0]); Assert.DoesNotContain("alter_job", statements[0]); @@ -173,7 +173,7 @@ public void Runtime_Add_MinimalPolicy_GeneratesOnlyAddReorderPolicy() [Fact] public void Runtime_Add_WithoutInitialStart_OmitsParameter() { - // Arrange - No InitialStart specified + // Arrange AddReorderPolicyOperation operation = new() { TableName = "test", @@ -184,27 +184,27 @@ public void Runtime_Add_WithoutInitialStart_OmitsParameter() // Act string result = GetRuntimeSql(operation); - // Assert - Should not include initial_start parameter + // Assert Assert.DoesNotContain("initial_start =>", result); } [Fact] public void Runtime_Add_WithCustomSchedule_QueriesCorrectJobView() { - // Arrange - Add custom schedule to trigger alter_job generation + // Arrange AddReorderPolicyOperation operation = new() { TableName = "my_table", Schema = "my_schema", IndexName = "my_idx", - ScheduleInterval = "6 hours" // Non-default triggers alter_job + ScheduleInterval = "6 hours" }; // Act List statements = GetRuntimeSqlStatements(operation); - string alterJobSql = statements[1]; // alter_job is the second statement + string alterJobSql = statements[1]; - // Assert - Must query timescaledb_information.jobs to find job_id + // Assert Assert.Equal(2, statements.Count); Assert.Contains("timescaledb_information.jobs", alterJobSql); Assert.Contains("proc_name = 'policy_reorder'", alterJobSql); @@ -269,7 +269,7 @@ FROM timescaledb_information.jobs [Fact] public void DesignTime_Alter_MaxRetries_GeneratesCorrectCode() { - // Arrange - Changing from unlimited (-1) to limited (3) + // Arrange AlterReorderPolicyOperation operation = new() { TableName = "test", @@ -319,7 +319,7 @@ FROM timescaledb_information.jobs [Fact] public void DesignTime_Alter_MultipleProperties_GeneratesCombinedStatement() { - // Arrange - Altering multiple job properties at once + // Arrange AlterReorderPolicyOperation operation = new() { TableName = "complex", @@ -335,7 +335,7 @@ public void DesignTime_Alter_MultipleProperties_GeneratesCombinedStatement() // Act string result = GetDesignTimeCode(operation); - // Assert - All changes should be in one alter_job call + // Assert Assert.Contains("schedule_interval => INTERVAL '2 days'", result); Assert.Contains("max_runtime => INTERVAL '2 hours'", result); Assert.Contains("max_retries => 10", result); @@ -344,7 +344,7 @@ public void DesignTime_Alter_MultipleProperties_GeneratesCombinedStatement() [Fact] public void Alter_NoChanges_GeneratesNoSQL() { - // Arrange - Nothing changed + // Arrange AlterReorderPolicyOperation operation = new() { TableName = "unchanged", @@ -358,7 +358,7 @@ public void Alter_NoChanges_GeneratesNoSQL() // Act string result = GetRuntimeSql(operation); - // Assert - Should generate empty result + // Assert Assert.Empty(result.Trim()); } @@ -389,7 +389,7 @@ public void Runtime_Alter_SingleProperty_GeneratesCorrectSQL() [Fact] public void Runtime_Alter_ChangingToUnlimitedRetries_UsesNegativeOne() { - // Arrange - Changing to unlimited retries + // Arrange AlterReorderPolicyOperation operation = new() { TableName = "test", @@ -468,7 +468,7 @@ public void Runtime_Drop_GeneratesCorrectSQL() // Act string result = GetRuntimeSql(operation); - // Assert - Runtime uses single quotes + // Assert Assert.Contains("SELECT remove_reorder_policy('public.\"remove_this\"', if_exists => true)", result); Assert.EndsWith(";", result.Trim()); } @@ -486,7 +486,7 @@ public void Runtime_Drop_AlwaysUsesIfExists() // Act string result = GetRuntimeSql(operation); - // Assert - if_exists => true prevents errors + // Assert Assert.Contains("if_exists => true", result); } @@ -497,22 +497,22 @@ public void Runtime_Drop_AlwaysUsesIfExists() [Fact] public void Add_DefaultValues_MatchTimescaleDBDefaults() { - // Arrange - Using default values from DefaultValues.cs + // Arrange AddReorderPolicyOperation operation = new() { TableName = "test", Schema = "public", IndexName = "idx", - ScheduleInterval = "1 day", // Default - MaxRuntime = "00:00:00", // Default (no limit) - MaxRetries = -1, // Default (unlimited) - RetryPeriod = "00:05:00" // Default (5 minutes) + ScheduleInterval = "1 day", + MaxRuntime = "00:00:00", + MaxRetries = -1, + RetryPeriod = "00:05:00" }; // Act string result = GetRuntimeSql(operation); - // Assert - Should match TimescaleDB defaults + // Assert Assert.Contains("schedule_interval => INTERVAL '1 day'", result); Assert.Contains("max_runtime => INTERVAL '00:00:00'", result); Assert.Contains("max_retries => -1", result); @@ -522,19 +522,19 @@ public void Add_DefaultValues_MatchTimescaleDBDefaults() [Fact] public void Add_WithCustomSchedule_RequiresAlterJob_AfterAddReorderPolicy() { - // Arrange - Provide custom schedule to trigger alter_job + // Arrange AddReorderPolicyOperation operation = new() { TableName = "test", Schema = "public", IndexName = "idx", - ScheduleInterval = "12 hours" // Triggers alter_job + ScheduleInterval = "12 hours" }; // Act List statements = GetRuntimeSqlStatements(operation); - // Assert - add_reorder_policy must come before alter_job + // Assert Assert.Equal(2, statements.Count); Assert.Contains("add_reorder_policy", statements[0]); Assert.Contains("alter_job", statements[1]); @@ -555,7 +555,7 @@ public void Alter_QueriesJobByTableAndSchema() // Act string result = GetRuntimeSql(operation); - // Assert - Must identify correct job by schema and table name + // Assert Assert.Contains("hypertable_schema = 'specific_schema'", result); Assert.Contains("hypertable_name = 'specific_table'", result); Assert.Contains("proc_name = 'policy_reorder'", result); @@ -564,7 +564,7 @@ public void Alter_QueriesJobByTableAndSchema() [Fact] public void Add_IntervalFormat_AcceptsVariousFormats() { - // Arrange - TimescaleDB accepts various interval formats + // Arrange AddReorderPolicyOperation operation = new() { TableName = "test", @@ -578,7 +578,7 @@ public void Add_IntervalFormat_AcceptsVariousFormats() // Act string result = GetRuntimeSql(operation); - // Assert - All interval formats should work + // Assert Assert.Contains("INTERVAL '2 days'", result); Assert.Contains("INTERVAL '30 minutes'", result); Assert.Contains("INTERVAL '1 hour'", result); @@ -587,7 +587,7 @@ public void Add_IntervalFormat_AcceptsVariousFormats() [Fact] public void Drop_SafeOperation_UsesIfExists() { - // Arrange - Drop should be safe operation + // Arrange DropReorderPolicyOperation operation = new() { TableName = "maybe_has_policy", @@ -597,7 +597,7 @@ public void Drop_SafeOperation_UsesIfExists() // Act string result = GetRuntimeSql(operation); - // Assert - if_exists prevents errors if policy doesn't exist + // Assert Assert.Contains("if_exists => true", result); } diff --git a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs index 69c1fd9..db82c19 100644 --- a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs @@ -15,6 +15,32 @@ private static string GetGeneratedCode(dynamic operation) return string.Join("\n", statements); } + #region Generate_Add_Numeric_DropAfter_emits_bigint_for_integer_time_columns + + [Fact] + public void Generate_Add_Numeric_DropAfter_emits_bigint_for_integer_time_columns() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "604800000000" + }; + + string expected = @" + SELECT add_retention_policy('public.""TestTable""', drop_after => 604800000000::bigint); + "; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + #region Generate_Add_DropAfter_with_minimal_config_creates_only_add_policy_sql [Fact] @@ -211,7 +237,7 @@ public void Generate_Alter_when_DropAfter_changes_creates_remove_add_and_alter_j { Schema = "public", TableName = "TestTable", - DropAfter = "14 days", // <-- Changed from "7 days" + DropAfter = "14 days", OldDropAfter = "7 days", ScheduleInterval = "1 day", OldScheduleInterval = "1 day" @@ -244,9 +270,9 @@ public void Generate_Alter_changed_to_DropCreatedBefore_creates_remove_add_and_a { Schema = "public", TableName = "TestTable", - DropAfter = null, // <-- Changed from "7 days" + DropAfter = null, OldDropAfter = "7 days", - DropCreatedBefore = "30 days", // <-- Changed from null + DropCreatedBefore = "30 days", OldDropCreatedBefore = null, ScheduleInterval = "1 day", OldScheduleInterval = "1 day" @@ -283,7 +309,7 @@ public void Generate_Alter_when_InitialStart_changes_creates_remove_add_and_alte TableName = "TestTable", DropAfter = "7 days", OldDropAfter = "7 days", - InitialStart = newDate, // <-- Changed from oldDate + InitialStart = newDate, OldInitialStart = oldDate, ScheduleInterval = "1 day", OldScheduleInterval = "1 day" @@ -366,11 +392,11 @@ public void Generate_Alter_when_both_fundamental_and_job_settings_change_creates { Schema = "public", TableName = "TestTable", - DropAfter = "14 days", // <-- Changed from "7 days" + DropAfter = "14 days", OldDropAfter = "7 days", - ScheduleInterval = "2 days", // <-- Changed from "1 day" + ScheduleInterval = "2 days", OldScheduleInterval = "1 day", - MaxRetries = 5, // <-- Changed from default + MaxRetries = 5, OldMaxRetries = -1, RetryPeriod = "10 minutes", OldRetryPeriod = "10 minutes" @@ -409,7 +435,7 @@ public void Generate_Alter_DropCreatedBefore_only_job_settings_change_emits_alte OldDropCreatedBefore = "30 days", InitialStart = null, OldInitialStart = null, - ScheduleInterval = "2 days", // <-- Changed from "1 day" + ScheduleInterval = "2 days", OldScheduleInterval = "1 day" }; @@ -444,7 +470,7 @@ public void Generate_Alter_MaxRuntime_change_emits_alter_job() OldDropCreatedBefore = null, InitialStart = null, OldInitialStart = null, - MaxRuntime = "2 hours", // <-- Changed from "1 hour" + MaxRuntime = "2 hours", OldMaxRuntime = "1 hour", ScheduleInterval = "1 day", OldScheduleInterval = "1 day", @@ -491,7 +517,7 @@ public void Generate_Alter_RetryPeriod_change_emits_alter_job() OldMaxRuntime = "00:00:00", MaxRetries = -1, OldMaxRetries = -1, - RetryPeriod = "30 minutes", // <-- Changed from "1 day" + RetryPeriod = "30 minutes", OldRetryPeriod = "1 day" }; diff --git a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs index 9bbbb4b..04158a8 100644 --- a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs +++ b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs @@ -1,5 +1,4 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; -using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -41,40 +40,6 @@ public void QualifiedIdentifier_Runtime_ReturnsCorrectlyQuotedString() Assert.Equal(expected, result); } - [Fact] - public void BuildQueryString_IndentedStringBuilder_WritesCorrectCSharpSql() - { - // Arrange - List statements = ["SELECT 1;", "SELECT 2;"]; - IndentedStringBuilder indentedBuilder = new(); - string expected = @".Sql(@"" - SELECT 1; - SELECT 2; - "")"; - - // Act - SqlBuilderHelper.BuildQueryString(statements, indentedBuilder); - string result = indentedBuilder.ToString(); - - // Assert - Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); - } - - [Fact] - public void BuildQueryString_IndentedStringBuilder_WritesNothingForEmptyList() - { - // Arrange - List statements = []; - IndentedStringBuilder indentedBuilder = new(); - - // Act - SqlBuilderHelper.BuildQueryString(statements, indentedBuilder); - string result = indentedBuilder.ToString(); - - // Assert - Assert.Empty(result); - } - [Fact] public void BuildQueryString_MigrationCommandListBuilder_AppendsAndEndsCommands() { @@ -132,38 +97,77 @@ public void BuildQueryString_MigrationCommandListBuilder_WritesNothingForEmptyLi mockBuilder.Verify(b => b.Append(It.IsAny()), Times.Never); mockBuilder.Verify(b => b.EndCommand(It.IsAny()), Times.Never); } - #region ReplaceSelectWithPerform_NonSelect_Returns_Unchanged + #region EscapeStringLiteral [Fact] - public void ReplaceSelectWithPerform_NonSelect_Returns_Unchanged() + public void EscapeStringLiteral_DoublesSingleQuotes() { - // Arrange - string input = "INSERT INTO x VALUES (1);"; + // Act + string result = SqlBuilderHelper.EscapeStringLiteral("it's a 'test'"); + + // Assert + Assert.Equal("it''s a ''test''", result); + } + [Fact] + public void EscapeStringLiteral_ReturnsUnchangedWithoutQuotes() + { // Act - string result = SqlBuilderHelper.ReplaceSelectWithPerform(input); + string result = SqlBuilderHelper.EscapeStringLiteral("plain_value"); // Assert - Assert.Equal(input, result); + Assert.Equal("plain_value", result); } #endregion - #region BuildQueryString_IndentedStringBuilder_SuppressTransaction_True + #region IntervalOrBigint + + [Fact] + public void IntervalOrBigint_NumericValue_EmitsBigintCast() + { + // Act + string result = SqlBuilderHelper.IntervalOrBigint("604800000000"); + + // Assert + Assert.Equal("604800000000::bigint", result); + } [Fact] - public void BuildQueryString_IndentedStringBuilder_SuppressTransaction_True() + public void IntervalOrBigint_IntervalString_EmitsEscapedIntervalLiteral() + { + // Act + string result = SqlBuilderHelper.IntervalOrBigint("7 days"); + + // Assert + Assert.Equal("INTERVAL '7 days'", result); + } + + [Fact] + public void IntervalOrBigint_Iso8601Duration_EmitsIntervalLiteral() + { + // Act + string result = SqlBuilderHelper.IntervalOrBigint("P7D"); + + // Assert + Assert.Equal("INTERVAL 'P7D'", result); + } + + #endregion + + #region ReplaceSelectWithPerform_NonSelect_Returns_Unchanged + + [Fact] + public void ReplaceSelectWithPerform_NonSelect_Returns_Unchanged() { // Arrange - List statements = ["SELECT 1;"]; - IndentedStringBuilder indentedBuilder = new(); + string input = "INSERT INTO x VALUES (1);"; // Act - SqlBuilderHelper.BuildQueryString(statements, indentedBuilder, suppressTransaction: true); - string result = indentedBuilder.ToString(); + string result = SqlBuilderHelper.ReplaceSelectWithPerform(input); // Assert - Assert.Contains("suppressTransaction: true", result); + Assert.Equal(input, result); } #endregion @@ -230,7 +234,7 @@ public void ReplaceSelectWithPerformMultiLine_ReplacesAllSelectStatements() { string input = """ SELECT create_hypertable('public."Events"', 'Time'); - SELECT add_dimension('public."Events"', by_range('sensor_id', 100)); + SELECT add_dimension('public."Events"', by_range('sensor_id', 100::bigint)); SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention'; @@ -404,6 +408,31 @@ public void BuildQueryString_MigrationCommandListBuilder_UsePerform_And_Suppress } #endregion + + #region BuildQueryString_Flushes_Trailing_Group_When_No_Terminating_Semicolon + + [Fact] + public void BuildQueryString_Flushes_Trailing_Group_When_No_Terminating_Semicolon() + { + // Arrange + List statements = + [ + "ALTER TABLE \"public\".\"events\" SET (timescaledb.compress = true)", + "ALTER TABLE \"public\".\"events\" SET (timescaledb.compress_orderby = 'ts DESC')" + ]; + Mock mockBuilder = CreateMockBuilder(); + + // Act + SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object); + + // Assert + mockBuilder.Verify(b => b.EndCommand(It.IsAny()), Times.Once); + mockBuilder.Verify( + b => b.Append(It.Is(s => s.Contains("timescaledb.compress = true") && s.Contains("timescaledb.compress_orderby"))), + Times.Once); + } + + #endregion } #pragma warning restore EF1001 // Internal EF Core API usage. } diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index edb8e7c..8283f31 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -264,11 +264,11 @@ public void Generate_AlterReorderPolicy_WithScheduleIntervalChange_GeneratesVali TableName = "sensor_data", Schema = "public", IndexName = "sensor_data_idx", - OldIndexName = "sensor_data_idx", // Same index name + OldIndexName = "sensor_data_idx", InitialStart = null, - OldInitialStart = null, // Same initial start + OldInitialStart = null, ScheduleInterval = "1 day", - OldScheduleInterval = "4 days" // Different schedule interval + OldScheduleInterval = "4 days" }; // Act @@ -371,7 +371,7 @@ public void Generate_AlterContinuousAggregate_WithMaterializedOnlyChange_Generat CreateGroupIndexes = true, OldCreateGroupIndexes = true, MaterializedOnly = true, - OldMaterializedOnly = false // Changed from false to true + OldMaterializedOnly = false }; // Act @@ -401,7 +401,7 @@ public void Generate_AlterContinuousAggregate_WithCreateGroupIndexesChange_Gener ChunkInterval = null, OldChunkInterval = null, CreateGroupIndexes = false, - OldCreateGroupIndexes = true, // Changed from true to false + OldCreateGroupIndexes = true, MaterializedOnly = false, OldMaterializedOnly = false }; @@ -704,7 +704,7 @@ public void Generate_AlterRetentionPolicy_WithDropAfterChange_GeneratesValidCSha TableName = "sensor_data", Schema = "public", DropAfter = "60 days", - OldDropAfter = "30 days" // <-- Changed from 30 days + OldDropAfter = "30 days" }; // Act @@ -733,9 +733,9 @@ public void Generate_AlterRetentionPolicy_WithScheduleIntervalChange_GeneratesVa TableName = "sensor_data", Schema = "public", DropAfter = "30 days", - OldDropAfter = "30 days", // Same drop_after + OldDropAfter = "30 days", ScheduleInterval = "1 day", - OldScheduleInterval = "4 days" // <-- Changed from 4 days + OldScheduleInterval = "4 days" }; // Act @@ -1099,6 +1099,131 @@ public void Generate_UnknownCustomOperation_Falls_Through_To_Base() #endregion + #region CompressionPolicyOperation Tests + + [Fact] + public void Generate_AddCompressionPolicy_WithAfter_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddCompressionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + After = "7 days" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AddCompressionPolicy(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterCompressionPolicy_WithAfterChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterCompressionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + After = "14 days", + OldAfter = "7 days" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".AlterCompressionPolicy(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_DropCompressionPolicy_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + DropCompressionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".DropCompressionPolicy(", result); + Assert.Contains("tableName:", result); + Assert.DoesNotContain(".Sql(", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Should_GenerateAllThree_When_AddAlterDropCompressionPolicyPassedTogether() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddCompressionPolicyOperation addOp = new() + { + TableName = "metrics_add", + Schema = "public", + After = "7 days" + }; + + AlterCompressionPolicyOperation alterOp = new() + { + TableName = "metrics_alter", + Schema = "public", + After = "14 days", + OldAfter = "7 days" + }; + + DropCompressionPolicyOperation dropOp = new() + { + TableName = "metrics_drop", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [addOp, alterOp, dropOp], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains(".AddCompressionPolicy(", result); + Assert.Contains(".AlterCompressionPolicy(", result); + Assert.Contains(".DropCompressionPolicy(", result); + Assert.DoesNotContain(".Sql(", result); + } + + #endregion + #region Helper Methods private static CSharpMigrationOperationGeneratorDependencies CreateDependencies() diff --git a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs index 309d6b8..c3597e5 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleDbMigrationsSqlGeneratorTests.cs @@ -126,7 +126,7 @@ public void Should_Preserve_Select_For_DDL_SqlOperation_In_Idempotent_Mode() // Act string sql = GenerateSql(operations, MigrationsSqlGenerationOptions.Idempotent | MigrationsSqlGenerationOptions.Script); - // Assert — DDL statement passes through unchanged; SELECT inside CREATE ... AS SELECT must not be replaced + // Assert Assert.Contains("SELECT", sql); Assert.Contains("CREATE MATERIALIZED VIEW", sql); } @@ -160,8 +160,7 @@ public void Should_Use_Select_For_SqlOperation_In_NonIdempotent_Mode() [Fact] public void Should_Suppress_Transaction_For_CreateContinuousAggregate() { - // Arrange — CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) cannot run - // inside a transaction, so the generator must mark the command as transaction-suppressed. + // Arrange CreateContinuousAggregateOperation operation = new() { Schema = "public", @@ -247,7 +246,7 @@ public void Should_Not_Suppress_Transaction_For_CreateHypertable() // Act IReadOnlyList commands = GenerateCommands(operations); - // Assert — only continuous aggregate creation suppresses the transaction + // Assert Assert.All(commands, c => Assert.False(c.TransactionSuppressed)); } diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs new file mode 100644 index 0000000..431124d --- /dev/null +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs @@ -0,0 +1,692 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class CompressionPolicyIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task HasCompressionPolicyAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_compression', 'policy_columnstore') + AND hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task GetCompressionPolicyJobIdAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT job_id + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_compression', 'policy_columnstore') + AND hypertable_name = @tableName + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is int jobId ? jobId : 0; + } + + private static async Task GetScheduleIntervalAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT schedule_interval + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is TimeSpan interval ? interval : TimeSpan.Zero; + } + + private static async Task GetCompressionPolicyConfigAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT config::text + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_compression', 'policy_columnstore') + AND hypertable_name = @tableName + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result as string; + } + + private static async Task GetJobInitialStartAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT initial_start + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is DateTime dt ? dt : null; + } + + #endregion + + #region Should_Create_CompressionPolicy_WithAfter + + private class CompressAfterMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CompressAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Create_CompressionPolicy_WithAfter() + { + await using CompressAfterContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasCompressionPolicyAsync(context, "compression_after"); + Assert.True(hasPolicy); + + string? config = await GetCompressionPolicyConfigAsync(context, "compression_after"); + Assert.NotNull(config); + Assert.Contains("compress_after", config); + } + + #endregion + + #region Should_Create_CompressionPolicy_WithCreatedBefore + + private class CompressCreatedBeforeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CompressCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_created_before"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Create_CompressionPolicy_WithCreatedBefore() + { + await using CompressCreatedBeforeContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasCompressionPolicyAsync(context, "compression_created_before"); + Assert.True(hasPolicy); + + string? config = await GetCompressionPolicyConfigAsync(context, "compression_created_before"); + Assert.NotNull(config); + Assert.Contains("compress_created_before", config); + } + + #endregion + + #region Should_Create_CompressionPolicy_WithCustomScheduleInterval + + private class CompressCustomScheduleMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CompressCustomScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_custom_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy( + after: "14 days", + scheduleInterval: "6 hours" + ); + }); + } + } + + [Fact] + public async Task Should_Create_CompressionPolicy_WithCustomScheduleInterval() + { + await using CompressCustomScheduleContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + int jobId = await GetCompressionPolicyJobIdAsync(context, "compression_custom_schedule"); + Assert.True(jobId > 0); + + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); + Assert.Equal(TimeSpan.FromHours(6), scheduleInterval); + } + + #endregion + + #region Should_Create_CompressionPolicy_WithInitialStart + + private class CompressInitialStartMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CompressInitialStartContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_initial_start"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public async Task Should_Create_CompressionPolicy_WithInitialStart() + { + await using CompressInitialStartContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasCompressionPolicyAsync(context, "compression_initial_start"); + Assert.True(hasPolicy); + + int jobId = await GetCompressionPolicyJobIdAsync(context, "compression_initial_start"); + Assert.True(jobId > 0); + + DateTime? initialStart = await GetJobInitialStartAsync(context, jobId); + Assert.NotNull(initialStart); + } + + #endregion + + #region Should_Alter_CompressionPolicy_After + + private class AlterAfterMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class ModifiedAlterAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Alter_CompressionPolicy_After() + { + await using InitialAlterAfterContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + bool hasPolicy = await HasCompressionPolicyAsync(initialContext, "compression_alter_after"); + Assert.True(hasPolicy); + + await using ModifiedAlterAfterContext modifiedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + hasPolicy = await HasCompressionPolicyAsync(modifiedContext, "compression_alter_after"); + Assert.True(hasPolicy); + + string? config = await GetCompressionPolicyConfigAsync(modifiedContext, "compression_alter_after"); + Assert.NotNull(config); + Assert.Contains("compress_after", config); + } + + #endregion + + #region Should_Alter_CompressionPolicy_After_To_CreatedBefore + + private class AlterAfterToCreatedBeforeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterAfterToCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_a_to_cb"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class ModifiedAlterAfterToCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_a_to_cb"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Alter_CompressionPolicy_After_To_CreatedBefore() + { + await using InitialAlterAfterToCreatedBeforeContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + string? initialConfig = await GetCompressionPolicyConfigAsync(initialContext, "compression_alter_a_to_cb"); + Assert.NotNull(initialConfig); + Assert.Contains("compress_after", initialConfig); + + await using ModifiedAlterAfterToCreatedBeforeContext modifiedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + bool hasPolicy = await HasCompressionPolicyAsync(modifiedContext, "compression_alter_a_to_cb"); + Assert.True(hasPolicy); + + string? modifiedConfig = await GetCompressionPolicyConfigAsync(modifiedContext, "compression_alter_a_to_cb"); + Assert.NotNull(modifiedConfig); + Assert.Contains("compress_created_before", modifiedConfig); + Assert.DoesNotContain("compress_after", modifiedConfig); + } + + #endregion + + #region Should_Alter_CompressionPolicy_ScheduleInterval + + private class AlterScheduleMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy( + after: "7 days", + scheduleInterval: "1 day" + ); + }); + } + } + + private class ModifiedAlterScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_alter_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy( + after: "7 days", + scheduleInterval: "12 hours" + ); + }); + } + } + + [Fact] + public async Task Should_Alter_CompressionPolicy_ScheduleInterval() + { + await using InitialAlterScheduleContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + int jobId = await GetCompressionPolicyJobIdAsync(initialContext, "compression_alter_schedule"); + TimeSpan initialSchedule = await GetScheduleIntervalAsync(initialContext, jobId); + Assert.Equal(TimeSpan.FromDays(1), initialSchedule); + + await using ModifiedAlterScheduleContext modifiedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + bool hasPolicy = await HasCompressionPolicyAsync(modifiedContext, "compression_alter_schedule"); + Assert.True(hasPolicy); + } + + #endregion + + #region Should_Drop_CompressionPolicy + + private class DropPolicyMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DropPolicyInitialContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class DropPolicyRemovedContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + }); + } + } + + [Fact] + public async Task Should_Drop_CompressionPolicy() + { + await using DropPolicyInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + bool hasPolicy = await HasCompressionPolicyAsync(initialContext, "compression_drop_policy"); + Assert.True(hasPolicy); + + await using DropPolicyRemovedContext removedContext = new(_connectionString!); + await AlterDatabaseViaMigrationAsync(initialContext, removedContext); + + hasPolicy = await HasCompressionPolicyAsync(removedContext, "compression_drop_policy"); + Assert.False(hasPolicy); + } + + #endregion + + #region Should_Create_CompressionPolicy_ViaEnsureCreated + + private class EnsureCreatedMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class EnsureCreatedContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("compression_ensure_created"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .WithCompressionOrderBy(s => s.By(x => x.Time)); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Create_CompressionPolicy_ViaEnsureCreated() + { + await using EnsureCreatedContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + bool hasPolicy = await HasCompressionPolicyAsync(context, "compression_ensure_created"); + Assert.True(hasPolicy); + + int jobId = await GetCompressionPolicyJobIdAsync(context, "compression_ensure_created"); + Assert.True(jobId > 0); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs new file mode 100644 index 0000000..ef72f66 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs @@ -0,0 +1,377 @@ +#pragma warning disable EF1001 // IScaffoldingModelFactory is an EF Core internal API, used here to drive the full scaffold-to-IModel pipeline. + +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using System.Reflection; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Validates that the compression policy scaffolding roundtrip is lossless. +/// A code-first reference database is created via migrations, scaffolded back to C# with +/// the design-time pipeline, and the generated code is compiled in-memory with Roslyn. +/// A phantom-migration check verifies the differ produces zero operations between +/// the reference model and the scaffolded model. +/// +public sealed class CompressionPolicyRoundTripTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } + + #region Reference-model entity types + + private class CompressAfterEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressCreatedBeforeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressScheduleEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + #endregion + + private sealed class CompressionRoundTripReferenceContext(string connectionString) : DbContext + { + public DbSet CompressAfterMetrics => Set(); + public DbSet CompressCreatedBeforeMetrics => Set(); + public DbSet CompressScheduleMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_cp_after"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy( + after: "7 days", + scheduleInterval: "12 hours", + initialStart: new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_cp_created_before"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(createdBefore: "30 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_cp_schedule"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "14 days", scheduleInterval: "6 hours"); + }); + } + } + + // ── Design-time helpers ─────────────────────────────────────────────────── + + private static async Task CreateIsolatedDatabaseAsync(string adminConnectionString) + { + string dbName = $"rrt_cp_{Guid.NewGuid():N}"; + + await using NpgsqlConnection admin = new(adminConnectionString); + await admin.OpenAsync(); + await using (NpgsqlCommand cmd = new($"CREATE DATABASE {dbName}", admin)) + { + await cmd.ExecuteNonQueryAsync(); + } + + string isolated = adminConnectionString.Replace("test_db", dbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection conn = new(isolated); + await conn.OpenAsync(); + await using (NpgsqlCommand ext = new("CREATE EXTENSION IF NOT EXISTS timescaledb", conn)) + { + await ext.ExecuteNonQueryAsync(); + } + + return isolated; + } + + private static ServiceProvider CreateDesignServices() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider(); + } + + private static ModelCodeGenerationOptions MakeCodeGenOptions( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) => new() + { + Language = "C#", + UseDataAnnotations = useDataAnnotations, + ProjectDir = ".", + ModelNamespace = "CpRoundTripScaffold", + ContextName = "CpRoundTripDbContext", + ContextNamespace = "CpRoundTripScaffold", + ConnectionString = connectionString, + SuppressOnConfiguring = suppressOnConfiguring + }; + + private static (IModel Model, ScaffoldedModel ScaffoldedCode) ScaffoldDatabase( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) + { + using ServiceProvider sp = CreateDesignServices(); + + IDatabaseModelFactory dbFactory = sp.GetRequiredService(); + using NpgsqlConnection conn = new(connectionString); + DatabaseModel dbModel = dbFactory.Create(conn, new DatabaseModelFactoryOptions()); + + IScaffoldingModelFactory scaffoldingModelFactory = sp.GetRequiredService(); + IModel model = scaffoldingModelFactory.Create(dbModel, new ModelReverseEngineerOptions()); + + ModelCodeGenerationOptions codeGenOptions = MakeCodeGenOptions(connectionString, useDataAnnotations, suppressOnConfiguring); + using ServiceProvider sp2 = CreateDesignServices(); + IModelCodeGenerator codeGenerator = sp2.GetRequiredService().Select(codeGenOptions); + ScaffoldedModel scaffoldedCode = codeGenerator.GenerateModel(model, codeGenOptions); + + return (model, scaffoldedCode); + } + + private static byte[] CompileAndAssertNoErrors(ScaffoldedModel scaffolded, string label) + { + IEnumerable trustedPlatformAssemblies = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + + IEnumerable loadedAssemblyPaths = + AppDomain.CurrentDomain + .GetAssemblies() + .Select(a => a.Location) + .Where(loc => !string.IsNullOrWhiteSpace(loc)); + + IEnumerable references = + trustedPlatformAssemblies + .Concat(loadedAssemblyPaths) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(File.Exists) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)); + + IEnumerable sources = + [scaffolded.ContextFile.Code, .. scaffolded.AdditionalFiles.Select(f => f.Code)]; + + CSharpParseOptions parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + IEnumerable trees = sources.Select(src => CSharpSyntaxTree.ParseText(src, parseOptions)); + + CSharpCompilation compilation = CSharpCompilation.Create( + $"CpRoundTripScaffold_{label}", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + using MemoryStream pe = new(); + Microsoft.CodeAnalysis.Emit.EmitResult emitResult = compilation.Emit(pe); + + IReadOnlyList errors = + [.. emitResult.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)]; + + Assert.True( + errors.Count == 0, + $"[{label}] Scaffolded code has {errors.Count} compilation error(s):\n" + + string.Join("\n", errors.Select(e => e.ToString()))); + + return pe.ToArray(); + } + + private static void AssertZeroDiff( + IRelationalModel source, + IRelationalModel target, + string direction) + { + IFeatureDiffer[] differs = + [ + new HypertableDiffer(), + new CompressionPolicyDiffer(), + ]; + + List failures = []; + foreach (IFeatureDiffer differ in differs) + { + IReadOnlyList ops = differ.GetDifferences(source, target); + if (ops.Count > 0) + { + string opList = string.Join(", ", ops.Select(op => + { + PropertyInfo? nameProp = op.GetType().GetProperty("TableName"); + string name = nameProp?.GetValue(op)?.ToString() ?? "?"; + return $"{op.GetType().Name}({name})"; + })); + failures.Add($" {differ.GetType().Name}: {ops.Count} op(s) — {opList}"); + } + } + + Assert.True( + failures.Count == 0, + $"Feature differs produced non-zero operations [{direction}]:\n" + + string.Join("\n", failures)); + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #region Should_RoundTrip_CompressionPolicy_With_Zero_Diff_In_Fluent_Mode + + [Fact] + public async Task Should_RoundTrip_CompressionPolicy_With_Zero_Diff_In_Fluent_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: false); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: false); + + // Assert + CompileAndAssertNoErrors(codeA, "fluent"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + AssertZeroDiff(relA, relB, "cp-fluent: pass-1 → pass-2"); + AssertZeroDiff(relB, relA, "cp-fluent: pass-2 → pass-1"); + + IRelationalModel refRelModel = refCtx.GetService().Model.GetRelationalModel(); + AssertZeroDiff(refRelModel, relA, "cp-fluent: reference → scaffolded"); + AssertZeroDiff(relA, refRelModel, "cp-fluent: scaffolded → reference"); + } + + #endregion + + #region Should_RoundTrip_CompressionPolicy_With_Zero_Diff_In_DataAnnotations_Mode + + [Fact] + public async Task Should_RoundTrip_CompressionPolicy_With_Zero_Diff_In_DataAnnotations_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: true); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: true); + + // Assert + CompileAndAssertNoErrors(codeA, "data-annotations"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + AssertZeroDiff(relA, relB, "cp-da: pass-1 → pass-2"); + AssertZeroDiff(relB, relA, "cp-da: pass-2 → pass-1"); + + IRelationalModel refRelModel = refCtx.GetService().Model.GetRelationalModel(); + AssertZeroDiff(refRelModel, relA, "cp-da: reference → scaffolded"); + AssertZeroDiff(relA, refRelModel, "cp-da: scaffolded → reference"); + } + + #endregion + + #region Should_Scaffold_CompressionPolicy_Contains_WithCompressionPolicy_Call + + [Fact] + public async Task Should_Scaffold_CompressionPolicy_Contains_WithCompressionPolicy_Call() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (_, ScaffoldedModel code) = ScaffoldDatabase(isolated, useDataAnnotations: false); + + // Assert + string allGeneratedCode = code.ContextFile.Code + + string.Join("\n", code.AdditionalFiles.Select(f => f.Code)); + + Assert.Contains("WithCompressionPolicy", allGeneratedCode); + } + + #endregion + + #region Should_Scaffold_CompressionPolicy_Contains_CompressionPolicyAttribute_In_DataAnnotations_Mode + + [Fact] + public async Task Should_Scaffold_CompressionPolicy_Contains_CompressionPolicyAttribute_In_DataAnnotations_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (_, ScaffoldedModel code) = ScaffoldDatabase(isolated, useDataAnnotations: true); + + // Assert + string allGeneratedCode = code.ContextFile.Code + + string.Join("\n", code.AdditionalFiles.Select(f => f.Code)); + + Assert.Contains("CompressionPolicy", allGeneratedCode); + } + + #endregion +} + +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs new file mode 100644 index 0000000..7450d1f --- /dev/null +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs @@ -0,0 +1,666 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class CompressionPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Extract_Minimal_CompressionPolicy_With_After + + private class AfterMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AfterContext(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("scaff_compression_after"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_Minimal_CompressionPolicy_With_After() + { + await using AfterContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_compression_after"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_after")]; + Assert.Equal("7 days", info.After); + Assert.Null(info.CreatedBefore); + } + + #endregion + + #region Should_Extract_CompressionPolicy_With_CreatedBefore + + private class CreatedBeforeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CreatedBeforeContext(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("scaff_compression_cb"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(createdBefore: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionPolicy_With_CreatedBefore() + { + await using CreatedBeforeContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_compression_cb"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_cb")]; + Assert.Null(info.After); + Assert.Equal("30 days", info.CreatedBefore); + } + + #endregion + + #region Should_Extract_CompressionPolicy_With_ScheduleInterval + + private class ScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext(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("scaff_compression_schedule"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "14 days", scheduleInterval: "6 hours"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionPolicy_With_ScheduleInterval() + { + await using ScheduleIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.True(result.ContainsKey(("public", "scaff_compression_schedule"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_schedule")]; + Assert.Equal("14 days", info.After); + Assert.Equal("6 hours", info.ScheduleInterval); + } + + #endregion + + #region Should_Extract_CompressionPolicy_With_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext(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("scaff_compression_initial_start"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionPolicy_With_InitialStart() + { + await using InitialStartContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.True(result.ContainsKey(("public", "scaff_compression_initial_start"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_initial_start")]; + Assert.NotNull(info.InitialStart); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), info.InitialStart.Value.ToUniversalTime()); + } + + #endregion + + #region Should_Extract_Multiple_CompressionPolicies + + private class MultiPolicyMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiPolicyMetric2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiPolicyContext(string connectionString) : DbContext + { + public DbSet Metrics1 => Set(); + public DbSet Metrics2 => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_compression_multi_1"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_compression_multi_2"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_Multiple_CompressionPolicies() + { + await using MultiPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey(("public", "scaff_compression_multi_1"))); + Assert.True(result.ContainsKey(("public", "scaff_compression_multi_2"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info1 = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_multi_1")]; + Assert.Equal("7 days", info1.After); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info2 = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_multi_2")]; + Assert.Equal("30 days", info2.After); + } + + #endregion + + #region Should_Return_Empty_When_No_Policies + + private class NoPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoPolicyContext(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("scaff_compression_none"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)); + }); + } + } + + [Fact] + public async Task Should_Return_Empty_When_No_Policies() + { + await using NoPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Empty(result); + } + + #endregion + + #region Should_Handle_Connection_Already_Open + + private class OpenConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OpenConnectionContext(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("scaff_compression_open_conn"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Handle_Connection_Already_Open() + { + await using OpenConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_compression_open_conn"))); + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + } + + #endregion + + #region Should_Handle_Connection_Closed + + private class ClosedConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ClosedConnectionContext(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("scaff_compression_closed_conn"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Handle_Connection_Closed() + { + await using ClosedConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + Assert.Equal(System.Data.ConnectionState.Closed, connection.State); + + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_compression_closed_conn"))); + Assert.Equal(System.Data.ConnectionState.Closed, connection.State); + } + + #endregion + + #region Should_Extract_IfNotExists_Is_Always_Null + + private class IfNotExistsMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class IfNotExistsContext(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("scaff_compression_if_not_exists"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "7 days", ifNotExists: true); + }); + } + } + + [Fact] + public async Task Should_Extract_IfNotExists_Is_Always_Null() + { + await using IfNotExistsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.True(result.ContainsKey(("public", "scaff_compression_if_not_exists"))); + + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_if_not_exists")]; + Assert.Null(info.IfNotExists); + } + + #endregion + + #region Should_Suppress_ScheduleInterval_Annotation_When_Default_For_Sub_Day_Chunk_Interval + + private class SubDayChunkMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class SubDayChunkContext(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("scaff_compression_subday"); + entity.IsHypertable(x => x.Timestamp).WithChunkTimeInterval("4 hours") + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy(after: "1 day"); + }); + } + } + + [Fact] + public async Task Should_Suppress_ScheduleInterval_Annotation_When_Default_For_Sub_Day_Chunk_Interval() + { + // Arrange + await using SubDayChunkContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "scaff_compression_subday"))); + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_subday")]; + + DatabaseTable table = new() { Name = "scaff_compression_subday", Schema = "public" }; + table[HypertableAnnotations.ChunkTimeInterval] = "4 hours"; + CompressionPolicyAnnotationApplier applier = new(); + applier.ApplyAnnotations(table, info); + Assert.Null(table[CompressionPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Extract_CompressionPolicy_With_Timezone + + private class TimezoneMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimezoneContext(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("scaff_compression_tz"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + timezone: "Europe/Berlin"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionPolicy_With_Timezone() + { + // Arrange + await using TimezoneContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "scaff_compression_tz"))); + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_tz")]; + + Assert.Equal("Europe/Berlin", info.Timezone); + + DatabaseTable table = new() { Name = "scaff_compression_tz", Schema = "public" }; + table[HypertableAnnotations.ChunkTimeInterval] = "7 days"; + CompressionPolicyAnnotationApplier applier = new(); + applier.ApplyAnnotations(table, info); + + Assert.Equal("Europe/Berlin", table[CompressionPolicyAnnotations.Timezone]); + } + + #endregion + + #region Should_RoundTrip_CompressionPolicy_With_All_Fields + + private class RoundTripMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RoundTripContext(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("scaff_compression_roundtrip"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => s.By(x => x.Timestamp)) + .WithCompressionPolicy( + after: "14 days", + scheduleInterval: "6 hours", + initialStart: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public async Task Should_RoundTrip_CompressionPolicy_With_All_Fields() + { + // Arrange + await using RoundTripContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + CompressionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "scaff_compression_roundtrip"))); + CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo info = + (CompressionPolicyScaffoldingExtractor.CompressionPolicyInfo)result[("public", "scaff_compression_roundtrip")]; + + DatabaseTable table = new() { Name = "scaff_compression_roundtrip", Schema = "public" }; + table[HypertableAnnotations.ChunkTimeInterval] = "7 days"; + CompressionPolicyAnnotationApplier applier = new(); + applier.ApplyAnnotations(table, info); + + Assert.Equal(true, table[CompressionPolicyAnnotations.HasCompressionPolicy]); + Assert.Equal("14 days", table[CompressionPolicyAnnotations.After]); + Assert.Null(table[CompressionPolicyAnnotations.CreatedBefore]); + Assert.Equal("6 hours", table[CompressionPolicyAnnotations.ScheduleInterval]); + + DateTime expectedInitialStart = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime actualInitialStart = Assert.IsType(table[CompressionPolicyAnnotations.InitialStart]); + Assert.Equal(expectedInitialStart, actualInitialStart.ToUniversalTime()); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs new file mode 100644 index 0000000..10fd8fa --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs @@ -0,0 +1,619 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ContinuousAggregateCompressionIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + GC.SuppressFinalize(this); + } + + private async Task GetIsolatedConnectionStringAsync() + { + string dbName = $"test_db_{Guid.NewGuid():N}"; + + await using NpgsqlConnection admin = new(_connectionString); + await admin.OpenAsync(); + await using (NpgsqlCommand cmd = new($"CREATE DATABASE {dbName}", admin)) + { + await cmd.ExecuteNonQueryAsync(); + } + + string isolated = _connectionString!.Replace("test_db", dbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection conn = new(isolated); + await conn.OpenAsync(); + await using (NpgsqlCommand ext = new("CREATE EXTENSION IF NOT EXISTS timescaledb", conn)) + { + await ext.ExecuteNonQueryAsync(); + } + + return isolated; + } + + private static async Task IsCompressionEnabledAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT compression_enabled + FROM timescaledb_information.continuous_aggregates + WHERE view_name = @viewName;"; + cmd.Parameters.AddWithValue("viewName", viewName); + object? result = await cmd.ExecuteScalarAsync(); + return result is bool b && b; + } + + private static async Task> GetCompressionSegmentByAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT cs.attname + FROM timescaledb_information.compression_settings cs + INNER JOIN _timescaledb_catalog.continuous_agg cagg + ON cagg.mat_hypertable_id = ( + SELECT h.id FROM _timescaledb_catalog.hypertable h + WHERE h.schema_name = cs.hypertable_schema + AND h.table_name = cs.hypertable_name + ) + WHERE cagg.user_view_name = @viewName + AND cs.segmentby_column_index IS NOT NULL + ORDER BY cs.segmentby_column_index;"; + cmd.Parameters.AddWithValue("viewName", viewName); + + List cols = []; + await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + cols.Add(reader.GetString(0)); + } + return cols; + } + + private static async Task> GetCompressionOrderByAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT cs.attname, cs.orderby_asc + FROM timescaledb_information.compression_settings cs + INNER JOIN _timescaledb_catalog.continuous_agg cagg + ON cagg.mat_hypertable_id = ( + SELECT h.id FROM _timescaledb_catalog.hypertable h + WHERE h.schema_name = cs.hypertable_schema + AND h.table_name = cs.hypertable_name + ) + WHERE cagg.user_view_name = @viewName + AND cs.orderby_column_index IS NOT NULL + ORDER BY cs.orderby_column_index;"; + cmd.Parameters.AddWithValue("viewName", viewName); + + List cols = []; + await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + string col = reader.GetString(0); + bool asc = reader.GetBoolean(1); + cols.Add($"{col} {(asc ? "ASC" : "DESC")}"); + } + return cols; + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #region Should_Create_CAgg_With_Compression_Enabled + + private class CompSourceMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompHourlyView1 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class CompressedCaggContext1(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_comp1_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_comp1_hourly"); + entity.IsContinuousAggregate( + "cagg_comp1_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(true); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Create_CAgg_With_Compression_Enabled() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + + // Act + await using CompressedCaggContext1 ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Assert + bool enabled = await IsCompressionEnabledAsync(conn, "cagg_comp1_hourly"); + Assert.True(enabled); + } + + #endregion + + #region Should_Create_CAgg_With_CompressionSegmentBy_And_OrderBy + + private class CompSourceMetric2 + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class CompHourlyView2 + { + public DateTime TimeBucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + private class CompressedSegByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_comp2_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_comp2_hourly"); + entity.IsContinuousAggregate( + "cagg_comp2_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.TimeBucket)]); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.Property(x => x.DeviceId).HasColumnName("DeviceId"); + }); + } + } + + [Fact] + public async Task Should_Create_CAgg_With_CompressionSegmentBy_And_OrderBy() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + + // Act + await using CompressedSegByContext ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Assert + bool enabled = await IsCompressionEnabledAsync(conn, "cagg_comp2_hourly"); + Assert.True(enabled); + + List segmentBy = await GetCompressionSegmentByAsync(conn, "cagg_comp2_hourly"); + Assert.Single(segmentBy); + Assert.Equal("DeviceId", segmentBy[0]); + + List orderBy = await GetCompressionOrderByAsync(conn, "cagg_comp2_hourly"); + Assert.Single(orderBy); + Assert.Equal("time_bucket DESC", orderBy[0]); + } + + #endregion + + #region Should_Alter_CAgg_To_Change_CompressionOrderBy + + private class AltSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AltHourlyView + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class AltOrderByInitialContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_alt_ord_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_alt_ord_hourly"); + entity.IsContinuousAggregate( + "cagg_alt_ord_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => [s.ByAscending(x => x.TimeBucket)]); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + private class AltOrderByModifiedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_alt_ord_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_alt_ord_hourly"); + entity.IsContinuousAggregate( + "cagg_alt_ord_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.TimeBucket)]); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Alter_CAgg_To_Change_CompressionOrderBy() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + await using AltOrderByInitialContext initial = new(conn); + await CreateDatabaseViaMigrationAsync(initial); + + List before = await GetCompressionOrderByAsync(conn, "cagg_alt_ord_hourly"); + Assert.Single(before); + Assert.Contains("ASC", before[0]); + + // Act + await using AltOrderByModifiedContext modified = new(conn); + await AlterDatabaseViaMigrationAsync(initial, modified); + + // Assert + List after = await GetCompressionOrderByAsync(conn, "cagg_alt_ord_hourly"); + Assert.Single(after); + Assert.Contains("DESC", after[0]); + } + + #endregion + + #region Should_Disable_CAgg_Compression + + private class DisableSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DisableHourlyView + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DisableCompressedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_dis_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_dis_hourly"); + entity.IsContinuousAggregate( + "cagg_dis_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(true); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + private class DisableUncompressedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_dis_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_dis_hourly"); + entity.IsContinuousAggregate( + "cagg_dis_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Disable_CAgg_Compression() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + await using DisableCompressedContext compressed = new(conn); + await CreateDatabaseViaMigrationAsync(compressed); + + bool before = await IsCompressionEnabledAsync(conn, "cagg_dis_hourly"); + Assert.True(before); + + // Act + await using DisableUncompressedContext uncompressed = new(conn); + await AlterDatabaseViaMigrationAsync(compressed, uncompressed); + + // Assert + bool after = await IsCompressionEnabledAsync(conn, "cagg_dis_hourly"); + Assert.False(after); + } + + #endregion + + #region Should_Add_CompressionPolicy_On_Compressed_CAgg + + private class PolicySourceMetric5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class PolicyHourlyView5 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CaggWithRefreshContext5(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_comp5_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_comp5_hourly"); + entity.IsContinuousAggregate( + "cagg_comp5_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(true) + .WithRefreshPolicy(startOffset: "3 hours", endOffset: "1 hour", scheduleInterval: "1 hour"); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + private class CaggWithCompressionPolicyContext5(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("cagg_comp5_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cagg_comp5_hourly"); + entity.IsContinuousAggregate( + "cagg_comp5_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(true) + .WithRefreshPolicy(startOffset: "3 hours", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.WithCompressionPolicy(after: "7 days"); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + }); + } + } + + private static async Task HasCompressionPolicyOnCaggAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_compression', 'policy_columnstore') + AND hypertable_name = @viewName;"; + cmd.Parameters.AddWithValue("viewName", viewName); + object? result = await cmd.ExecuteScalarAsync(); + return result is bool b && b; + } + + private static async Task GetCaggCompressionPolicyConfigAsync(string connectionString, string viewName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT config::text + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_compression', 'policy_columnstore') + AND hypertable_name = @viewName + LIMIT 1;"; + cmd.Parameters.AddWithValue("viewName", viewName); + object? result = await cmd.ExecuteScalarAsync(); + return result as string; + } + + [Fact] + public async Task Should_Add_CompressionPolicy_On_Compressed_CAgg() + { + // Arrange + string conn = await GetIsolatedConnectionStringAsync(); + await using CaggWithRefreshContext5 initial = new(conn); + await CreateDatabaseViaMigrationAsync(initial); + + // Act + await using CaggWithCompressionPolicyContext5 withPolicy = new(conn); + await AlterDatabaseViaMigrationAsync(initial, withPolicy); + + // Assert + bool compressionEnabled = await IsCompressionEnabledAsync(conn, "cagg_comp5_hourly"); + Assert.True(compressionEnabled); + bool hasPolicy = await HasCompressionPolicyOnCaggAsync(conn, "cagg_comp5_hourly"); + Assert.True(hasPolicy); + string? config = await GetCaggCompressionPolicyConfigAsync(conn, "cagg_comp5_hourly"); + Assert.NotNull(config); + Assert.Contains("compress_after", config); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs new file mode 100644 index 0000000..b50f31c --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs @@ -0,0 +1,344 @@ +#pragma warning disable EF1001 // IScaffoldingModelFactory is an EF Core internal API, used here to drive the full scaffold-to-IModel pipeline. + +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using System.Reflection; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Validates that scaffolding a compressed continuous aggregate produces zero phantom operations +/// when the scaffolded model is diffed against itself (idempotency) and against the +/// code-first reference model (losslessness of annotation round-trip). +/// +public sealed class ContinuousAggregateCompressionRoundTripTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } + + #region Reference-model entity types + + private class RrtCaCompSourceMetric + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class RrtCaCompHourlyView + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + #endregion + + private sealed class CaggCompressionRoundTripReferenceContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("rrt_cacomp_source"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("rrt_cacomp_hourly"); + + entity.IsContinuousAggregate( + "rrt_cacomp_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Bucket)]); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.Property(x => x.DeviceId).HasColumnName("DeviceId"); + }); + } + } + + // ── Design-time helpers ─────────────────────────────────────────────────── + + private static async Task CreateIsolatedDatabaseAsync(string adminConnectionString) + { + string dbName = $"rrt_cacomp_{Guid.NewGuid():N}"; + + await using NpgsqlConnection admin = new(adminConnectionString); + await admin.OpenAsync(); + await using (NpgsqlCommand cmd = new($"CREATE DATABASE {dbName}", admin)) + { + await cmd.ExecuteNonQueryAsync(); + } + + string isolated = adminConnectionString.Replace("test_db", dbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection conn = new(isolated); + await conn.OpenAsync(); + await using (NpgsqlCommand ext = new("CREATE EXTENSION IF NOT EXISTS timescaledb", conn)) + { + await ext.ExecuteNonQueryAsync(); + } + + return isolated; + } + + private static ServiceProvider CreateDesignServices() + { + ServiceCollection services = new(); + services.AddEntityFrameworkDesignTimeServices(); + new TimescaleDBDesignTimeServices().ConfigureDesignTimeServices(services); + return services.BuildServiceProvider(); + } + + private static ModelCodeGenerationOptions MakeCodeGenOptions( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) => new() + { + Language = "C#", + UseDataAnnotations = useDataAnnotations, + ProjectDir = ".", + ModelNamespace = "CaCompRoundTripScaffold", + ContextName = "CaCompRoundTripDbContext", + ContextNamespace = "CaCompRoundTripScaffold", + ConnectionString = connectionString, + SuppressOnConfiguring = suppressOnConfiguring + }; + + private static (IModel Model, ScaffoldedModel ScaffoldedCode) ScaffoldDatabase( + string connectionString, + bool useDataAnnotations, + bool suppressOnConfiguring = false) + { + using ServiceProvider sp = CreateDesignServices(); + + IDatabaseModelFactory dbFactory = sp.GetRequiredService(); + using NpgsqlConnection conn = new(connectionString); + DatabaseModel dbModel = dbFactory.Create(conn, new DatabaseModelFactoryOptions()); + + IScaffoldingModelFactory scaffoldingModelFactory = sp.GetRequiredService(); + IModel model = scaffoldingModelFactory.Create(dbModel, new ModelReverseEngineerOptions()); + + ModelCodeGenerationOptions codeGenOptions = MakeCodeGenOptions(connectionString, useDataAnnotations, suppressOnConfiguring); + using ServiceProvider sp2 = CreateDesignServices(); + IModelCodeGenerator codeGenerator = sp2.GetRequiredService().Select(codeGenOptions); + ScaffoldedModel scaffoldedCode = codeGenerator.GenerateModel(model, codeGenOptions); + + return (model, scaffoldedCode); + } + + private static byte[] CompileAndAssertNoErrors(ScaffoldedModel scaffolded, string label) + { + IEnumerable trustedPlatformAssemblies = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + + IEnumerable loadedAssemblyPaths = + AppDomain.CurrentDomain + .GetAssemblies() + .Select(a => a.Location) + .Where(loc => !string.IsNullOrWhiteSpace(loc)); + + IEnumerable references = + trustedPlatformAssemblies + .Concat(loadedAssemblyPaths) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(File.Exists) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)); + + IEnumerable sources = + [scaffolded.ContextFile.Code, .. scaffolded.AdditionalFiles.Select(f => f.Code)]; + + CSharpParseOptions parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + IEnumerable trees = sources.Select(src => CSharpSyntaxTree.ParseText(src, parseOptions)); + + CSharpCompilation compilation = CSharpCompilation.Create( + $"CaCompRoundTripScaffold_{label}", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + using MemoryStream pe = new(); + Microsoft.CodeAnalysis.Emit.EmitResult emitResult = compilation.Emit(pe); + + IReadOnlyList errors = + [.. emitResult.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)]; + + Assert.True( + errors.Count == 0, + $"[{label}] Scaffolded code has {errors.Count} compilation error(s):\n" + + string.Join("\n", errors.Select(e => e.ToString()))); + + return pe.ToArray(); + } + + private static void AssertZeroCaggDiff( + IRelationalModel source, + IRelationalModel target, + string direction) + { + IFeatureDiffer differ = new ContinuousAggregateDiffer(); + + IReadOnlyList ops = differ.GetDifferences(source, target); + if (ops.Count > 0) + { + string opList = string.Join(", ", ops.Select(op => + { + PropertyInfo? nameProp = + op.GetType().GetProperty("TableName") + ?? op.GetType().GetProperty("MaterializedViewName"); + string name = nameProp?.GetValue(op)?.ToString() ?? "?"; + return $"{op.GetType().Name}({name})"; + })); + + Assert.Fail($"ContinuousAggregateDiffer produced {ops.Count} op(s) [{direction}]: {opList}"); + } + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #region Should_RoundTrip_CompressedCAgg_With_Zero_Diff_In_Fluent_Mode + + [Fact] + public async Task Should_RoundTrip_CompressedCAgg_With_Zero_Diff_In_Fluent_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CaggCompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: false); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: false); + + // Assert + CompileAndAssertNoErrors(codeA, "fluent"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + AssertZeroCaggDiff(relA, relB, "cacomp-fluent: pass-1 → pass-2"); + AssertZeroCaggDiff(relB, relA, "cacomp-fluent: pass-2 → pass-1"); + } + + #endregion + + #region Should_RoundTrip_CompressedCAgg_With_Zero_Diff_In_DataAnnotations_Mode + + [Fact] + public async Task Should_RoundTrip_CompressedCAgg_With_Zero_Diff_In_DataAnnotations_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CaggCompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (IModel modelA, ScaffoldedModel codeA) = ScaffoldDatabase(isolated, useDataAnnotations: true); + (IModel modelB, ScaffoldedModel _) = ScaffoldDatabase(isolated, useDataAnnotations: true); + + // Assert + CompileAndAssertNoErrors(codeA, "data-annotations"); + + IRelationalModel relA = modelA.GetRelationalModel(); + IRelationalModel relB = modelB.GetRelationalModel(); + + AssertZeroCaggDiff(relA, relB, "cacomp-da: pass-1 → pass-2"); + AssertZeroCaggDiff(relB, relA, "cacomp-da: pass-2 → pass-1"); + } + + #endregion + + #region Should_Scaffold_WithCompressionSegmentBy_In_Fluent_Mode + + [Fact] + public async Task Should_Scaffold_WithCompressionSegmentBy_In_Fluent_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CaggCompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (_, ScaffoldedModel code) = ScaffoldDatabase(isolated, useDataAnnotations: false); + + // Assert + string allCode = code.ContextFile.Code + + string.Join("\n", code.AdditionalFiles.Select(f => f.Code)); + Assert.Contains("WithCompressionSegmentBy", allCode); + } + + #endregion + + #region Should_Scaffold_CompressionSegmentBy_In_DataAnnotations_Mode + + [Fact] + public async Task Should_Scaffold_CompressionSegmentBy_In_DataAnnotations_Mode() + { + // Arrange + string isolated = await CreateIsolatedDatabaseAsync(_connectionString!); + await using CaggCompressionRoundTripReferenceContext refCtx = new(isolated); + await CreateDatabaseViaMigrationAsync(refCtx); + + // Act + (_, ScaffoldedModel code) = ScaffoldDatabase(isolated, useDataAnnotations: true); + + // Assert + string allCode = code.ContextFile.Code + + string.Join("\n", code.AdditionalFiles.Select(f => f.Code)); + Assert.Contains("CompressionSegmentBy", allCode); + } + + #endregion +} + +#pragma warning restore EF1001 diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs new file mode 100644 index 0000000..68761a3 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs @@ -0,0 +1,430 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class ContinuousAggregateCompressionScaffoldingExtractorTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + private async Task GetTestConnectionStringAsync() + { + string testDbName = $"test_db_{Guid.NewGuid():N}"; + + await using NpgsqlConnection adminConnection = new(_connectionString); + await adminConnection.OpenAsync(); + + await using (NpgsqlCommand createCmd = new($"CREATE DATABASE {testDbName}", adminConnection)) + { + await createCmd.ExecuteNonQueryAsync(); + } + + string testConnectionString = _connectionString!.Replace("test_db", testDbName, StringComparison.OrdinalIgnoreCase); + await using NpgsqlConnection testConnection = new(testConnectionString); + await testConnection.OpenAsync(); + await using (NpgsqlCommand extCmd = new("CREATE EXTENSION IF NOT EXISTS timescaledb", testConnection)) + { + await extCmd.ExecuteNonQueryAsync(); + } + + return testConnectionString; + } + + // ── Extractor: compression fields ───────────────────────────────────────── + + #region Should_Extract_CompressionEnabled_True_For_Compressed_CAgg + + private class ScaffExtSourceMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffExtHourlyView1 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ScaffExtCompressedContext1(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_ext1_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("scaff_ext1_hourly"); + entity.IsContinuousAggregate( + "scaff_ext1_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(true); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionEnabled_True_For_Compressed_CAgg() + { + // Arrange + string conn = await GetTestConnectionStringAsync(); + await using ScaffExtCompressedContext1 ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(conn); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "scaff_ext1_hourly"))); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "scaff_ext1_hourly")]; + + Assert.True(info.CompressionEnabled); + } + + #endregion + + #region Should_Extract_CompressionSegmentBy_For_Compressed_CAgg + + private class ScaffExtSourceMetric2 + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ScaffExtHourlyView2 + { + public DateTime Bucket { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + private class ScaffExtSegByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_ext2_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("scaff_ext2_hourly"); + entity.IsContinuousAggregate( + "scaff_ext2_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.DeviceId) + .WithCompressionSegmentBy(x => x.DeviceId); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.Property(x => x.DeviceId).HasColumnName("DeviceId"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionSegmentBy_For_Compressed_CAgg() + { + // Arrange + string conn = await GetTestConnectionStringAsync(); + await using ScaffExtSegByContext ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(conn); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "scaff_ext2_hourly")]; + + Assert.True(info.CompressionEnabled); + Assert.NotNull(info.CompressionSegmentBy); + Assert.Contains("DeviceId", info.CompressionSegmentBy); + } + + #endregion + + #region Should_Extract_CompressionOrderBy_For_Compressed_CAgg + + private class ScaffExtSourceMetric3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffExtHourlyView3 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ScaffExtOrderByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_ext3_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("scaff_ext3_hourly"); + entity.IsContinuousAggregate( + "scaff_ext3_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Bucket)]); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionOrderBy_For_Compressed_CAgg() + { + // Arrange + string conn = await GetTestConnectionStringAsync(); + await using ScaffExtOrderByContext ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(conn); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "scaff_ext3_hourly")]; + + Assert.True(info.CompressionEnabled); + Assert.NotNull(info.CompressionOrderBy); + Assert.NotEmpty(info.CompressionOrderBy); + Assert.Contains("DESC", info.CompressionOrderBy[0]); + } + + #endregion + + #region Should_Extract_CompressionEnabled_False_For_Uncompressed_CAgg + + private class ScaffExtSourceMetric4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffExtHourlyView4 + { + public DateTime Bucket { get; set; } + public double AvgValue { get; set; } + } + + private class ScaffExtUncompressedContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_ext4_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("scaff_ext4_hourly"); + entity.IsContinuousAggregate( + "scaff_ext4_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionEnabled_False_For_Uncompressed_CAgg() + { + // Arrange + string conn = await GetTestConnectionStringAsync(); + await using ScaffExtUncompressedContext ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(conn); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "scaff_ext4_hourly")]; + + Assert.False(info.CompressionEnabled); + Assert.NotNull(info.CompressionSegmentBy); + Assert.Empty(info.CompressionSegmentBy); + Assert.NotNull(info.CompressionOrderBy); + Assert.Empty(info.CompressionOrderBy); + } + + #endregion + + // ── Extractor: materialization hypertable key resolution ────────────────── + + #region Should_Key_Result_By_View_Name_Not_Materialization_Hypertable + + private class ScaffExtSourceMetric5 + { + public DateTime Timestamp { get; set; } + public string Region { get; set; } = string.Empty; + public double Value { get; set; } + } + + private class ScaffExtHourlyView5 + { + public DateTime Bucket { get; set; } + public string Region { get; set; } = string.Empty; + public double AvgValue { get; set; } + } + + private class ScaffExtViewKeyContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet HourlyMetrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_ext5_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("scaff_ext5_hourly"); + entity.IsContinuousAggregate( + "scaff_ext5_hourly", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .AddGroupByColumn(x => x.Region) + .WithCompressionSegmentBy(x => x.Region); + + entity.Property(x => x.Bucket).HasColumnName("time_bucket"); + entity.Property(x => x.Region).HasColumnName("Region"); + }); + } + } + + [Fact] + public async Task Should_Key_Result_By_View_Name_Not_Materialization_Hypertable() + { + // Arrange + string conn = await GetTestConnectionStringAsync(); + await using ScaffExtViewKeyContext ctx = new(conn); + await CreateDatabaseViaMigrationAsync(ctx); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(conn); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "scaff_ext5_hourly"))); + bool hasMatKey = result.Keys.Any(k => + k.TableName.StartsWith("_materialized_hypertable_", StringComparison.OrdinalIgnoreCase) || + k.Schema.Contains("_timescaledb_internal", StringComparison.OrdinalIgnoreCase)); + Assert.False(hasMatKey); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "scaff_ext5_hourly")]; + Assert.True(info.CompressionEnabled); + Assert.NotNull(info.CompressionSegmentBy); + Assert.NotEmpty(info.CompressionSegmentBy); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs index 9723db6..7114604 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -722,7 +722,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "trade_aggregate_alterable", "1 hour", x => x.Timestamp, - chunkInterval: "14 days") // <-- Changed from "7 days" + chunkInterval: "14 days") .AddAggregateFunction(x => x.AvgPrice, x => x.Price, EAggregateFunction.Avg); entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs index 5a7f6fc..2abd3c7 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs @@ -87,20 +87,16 @@ public async Task Should_Create_Policy_With_FluentApi() // Arrange await using WithPolicyFluentContext1 context = new(_connectionString!); - // Act - This should not throw any exceptions + // Act await CreateDatabaseViaMigrationAsync(context); - // Assert - Verify the continuous aggregate view exists + // Assert List aggregates = await context.Aggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotNull(aggregates); } #endregion - // NOTE: Attribute-based test removed because continuous aggregates require - // aggregate function definitions which are complex to set up with attributes alone. - // The convention tests already verify that the attribute applies annotations correctly. - #region Should_Remove_Policy private class MetricEntity3 @@ -172,7 +168,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp) - .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); // <-- Policy removed + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgValue).HasColumnName("AvgValue"); @@ -191,8 +187,7 @@ public async Task Should_Remove_Policy() await using WithoutPolicyContext3 contextWithoutPolicy = new(_connectionString!); await AlterDatabaseViaMigrationAsync(contextWithPolicy, contextWithoutPolicy); - // Assert - Verify policy was removed (no error should be thrown) - // The policy removal should succeed with if_exists => true + // Assert Assert.True(true); } @@ -270,7 +265,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "7 days", endOffset: "30 minutes", scheduleInterval: "30 minutes"); // <-- Changed parameters + .WithRefreshPolicy(startOffset: "7 days", endOffset: "30 minutes", scheduleInterval: "30 minutes"); entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgValue).HasColumnName("AvgValue"); @@ -289,7 +284,7 @@ public async Task Should_Modify_Policy_Parameters() await using ModifiedPolicyContext4 modifiedContext = new(_connectionString!); await AlterDatabaseViaMigrationAsync(originalContext, modifiedContext); - // Assert - Verify policy was re-created (no error should be thrown) + // Assert Assert.True(true); } @@ -357,7 +352,7 @@ public async Task Should_Handle_All_Optional_Parameters() // Act await CreateDatabaseViaMigrationAsync(context); - // Assert - Verify database creation succeeded (no errors) + // Assert Assert.True(true); } @@ -434,7 +429,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "1 hour", x => x.Timestamp) .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) - .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); // <-- Policy added + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgValue).HasColumnName("AvgValue"); @@ -518,16 +513,15 @@ public async Task Should_Execute_Migration_Successfully() // Arrange await using ExecuteMigrationContext7 context = new(_connectionString!); - // Act - This should not throw any exceptions + // Act await CreateDatabaseViaMigrationAsync(context); - // Assert - Insert data and verify policy can work + // Assert await context.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""Metrics"" (""Timestamp"", ""Value"") VALUES ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {100.5}) ", TestContext.Current.CancellationToken); - // Manually refresh the continuous aggregate await context.Database.ExecuteSqlRawAsync( "CALL refresh_continuous_aggregate('public.hourly_metrics', NULL, NULL);", [], TestContext.Current.CancellationToken); @@ -608,7 +602,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp) - .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); // <-- Policy removed + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); entity.Property(x => x.AvgValue).HasColumnName("AvgValue"); @@ -619,15 +613,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public async Task Should_Rollback_Migration_Successfully() { - // Arrange - Create with policy + // Arrange await using WithPolicyContext8 contextWithPolicy = new(_connectionString!); await CreateDatabaseViaMigrationAsync(contextWithPolicy); - // Act - Rollback (remove policy) + // Act await using WithoutPolicyContext8 contextWithoutPolicy = new(_connectionString!); await AlterDatabaseViaMigrationAsync(contextWithPolicy, contextWithoutPolicy); - // Assert - Verify rollback succeeded (no errors) + // Assert Assert.True(true); } @@ -688,19 +682,15 @@ public async Task Should_Work_With_Custom_Schema() // Arrange await using CustomSchemaContext9 context = new(_connectionString!); - // Create the schema first await context.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS analytics;", [], TestContext.Current.CancellationToken); // Act await CreateDatabaseViaMigrationAsync(context); - // Assert - Verify continuous aggregate was created in custom schema + // Assert Assert.True(true); } #endregion - // NOTE: Integer-based time column test removed due to complexity - // The IsHypertable method does not support integer-based columns with two parameters - // This can be tested separately if needed in the future } diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs index 2a2032c..59023bb 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs @@ -245,7 +245,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) true, "7 days") .AddAggregateFunction(cagg => cagg.AverageValue, source => source.Value, EAggregateFunction.Avg); - // No WithRefreshPolicy call }); } } diff --git a/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs new file mode 100644 index 0000000..6566887 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs @@ -0,0 +1,527 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Diagnostics.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.Logging; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; +using System.Diagnostics; +using System.Text.Json; +using Testcontainers.PostgreSql; + +#pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class HypertableColumnstoreIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + private async Task CreateIsolatedDbAsync() + { + string testDbName = $"test_{Guid.NewGuid():N}"; + + await using NpgsqlConnection adminConnection = new(_connectionString); + await adminConnection.OpenAsync(); + + await using (NpgsqlCommand createCmd = new($"CREATE DATABASE {testDbName}", adminConnection)) + { + await createCmd.ExecuteNonQueryAsync(); + } + + string connStr = _connectionString!.Replace("test_db", testDbName); + await using NpgsqlConnection testConnection = new(connStr); + await testConnection.OpenAsync(); + await using (NpgsqlCommand extCmd = new("CREATE EXTENSION IF NOT EXISTS timescaledb", testConnection)) + { + await extCmd.ExecuteNonQueryAsync(); + } + + return connStr; + } + + private static TimescaleDatabaseModelFactory CreateFactory() + { + LoggerFactory loggerFactory = new(); + DiagnosticsLogger logger = new( + loggerFactory, + new LoggingOptions(), + new DiagnosticListener("Test"), + new NpgsqlLoggingDefinitions(), + new NullDbContextLogger()); + + return new TimescaleDatabaseModelFactory(logger); + } + + private sealed class NullDbContextLogger : IDbContextLogger + { + public void Log(EventData eventData) { } + public bool ShouldLog(EventId eventId, LogLevel logLevel) => false; + } + + private static async Task GetReloption(string connectionString, string tableName, string optionName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT opt.option_value + FROM pg_catalog.pg_class AS c + JOIN LATERAL pg_catalog.pg_options_to_table(c.reloptions) AS opt ON true + WHERE c.relname = @table + AND opt.option_name = @option + LIMIT 1"; + cmd.Parameters.AddWithValue("table", tableName); + cmd.Parameters.AddWithValue("option", optionName); + + object? result = await cmd.ExecuteScalarAsync(); + return result is DBNull or null ? null : (string)result; + } + + private static async Task GetColumnstoreSetting(string connectionString, string tableName, string columnName) + { + await using NpgsqlConnection conn = new(connectionString); + await conn.OpenAsync(); + + await using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT hcs.{columnName}::text + FROM timescaledb_information.hypertable_columnstore_settings AS hcs + WHERE hcs.hypertable::text = @table + LIMIT 1"; + cmd.Parameters.AddWithValue("table", tableName); + + object? result = await cmd.ExecuteScalarAsync(); + return result is DBNull or null ? null : (string)result; + } + + private static async Task SparseIndexContainsEntry( + string connectionString, + string tableName, + string indexType, + string columnName) + { + string? indexJson = await GetColumnstoreSetting(connectionString, tableName, "index"); + if (string.IsNullOrEmpty(indexJson)) + { + return false; + } + + using JsonDocument doc = JsonDocument.Parse(indexJson); + foreach (JsonElement entry in doc.RootElement.EnumerateArray()) + { + string? type = entry.TryGetProperty("type", out JsonElement t) ? t.GetString() : null; + string? col = entry.TryGetProperty("column", out JsonElement c) ? c.GetString() : null; + string? source = entry.TryGetProperty("source", out JsonElement s) ? s.GetString() : null; + + if (string.Equals(type, indexType, StringComparison.OrdinalIgnoreCase) && + string.Equals(col, columnName, StringComparison.OrdinalIgnoreCase) && + string.Equals(source, "config", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + // ── Migration roundtrip: sparse_index applied to DB ── + + #region Should_Create_Hypertable_With_SparseIndex_In_Database + + private class SparseIndexMigrationEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SparseIndexMigrationContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_sparse_create"); + e.HasNoKey(); + e.Property(x => x.DeviceId).HasColumnName("device_id"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + [Fact] + public async Task Should_Create_Hypertable_With_SparseIndex_In_Database() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + // Act + await using SparseIndexMigrationContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Assert + bool hasBloomDeviceId = await SparseIndexContainsEntry(connStr, "cs_sparse_create", "bloom", "device_id"); + Assert.True(hasBloomDeviceId); + } + + #endregion + + // ── Migration roundtrip: compress_chunk_time_interval applied to DB ── + + #region Should_Create_Hypertable_With_CompressChunkTimeInterval_In_Database + + private class CctiMigrationEntity { public DateTime Ts { get; set; } } + + private class CctiMigrationContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_ccti_create"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("24 hours"); + }); + } + + [Fact] + public async Task Should_Create_Hypertable_With_CompressChunkTimeInterval_In_Database() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + // Act + await using CctiMigrationContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Assert + string? value = await GetColumnstoreSetting(connStr, "cs_ccti_create", "compress_interval_length"); + Assert.NotNull(value); + } + + #endregion + + // ── Migration alter: sparse_index changed ── + + #region Should_Update_SparseIndex_When_Altered + + private class SparseIndexAlterEntity { public DateTime Ts { get; set; } public int DeviceId { get; set; } } + + private class SparseIndexAlterSourceContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_sparse_alter"); + e.HasNoKey(); + e.Property(x => x.Ts).HasColumnName("ts"); + e.Property(x => x.DeviceId).HasColumnName("device_id"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id)"); + }); + } + + private class SparseIndexAlterTargetContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_sparse_alter"); + e.HasNoKey(); + e.Property(x => x.Ts).HasColumnName("ts"); + e.Property(x => x.DeviceId).HasColumnName("device_id"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(device_id), minmax(ts)"); + }); + } + + [Fact] + public async Task Should_Update_SparseIndex_When_Altered() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using SparseIndexAlterSourceContext sourceContext = new(connStr); + await CreateDatabaseViaMigrationAsync(sourceContext); + + // Act + await using SparseIndexAlterTargetContext targetContext = new(connStr); + await AlterDatabaseViaMigrationAsync(sourceContext, targetContext); + + // Assert + bool hasBloom = await SparseIndexContainsEntry(connStr, "cs_sparse_alter", "bloom", "device_id"); + bool hasMinmax = await SparseIndexContainsEntry(connStr, "cs_sparse_alter", "minmax", "ts"); + Assert.True(hasBloom); + Assert.True(hasMinmax); + } + + #endregion + + // ── Migration alter: sparse_index removed → RESET ── + + #region Should_Remove_SparseIndex_When_Set_To_Null + + private class SparseIndexRemoveAlterEntity { public DateTime Ts { get; set; } public double Value { get; set; } } + + private class SparseIndexRemoveSourceContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_sparse_remove"); + e.HasNoKey(); + e.Property(x => x.Value).HasColumnName("value"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(value)"); + }); + } + + private class SparseIndexRemoveTargetContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_sparse_remove"); + e.HasNoKey(); + e.Property(x => x.Value).HasColumnName("value"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]); + }); + } + + [Fact] + public async Task Should_Remove_SparseIndex_When_Set_To_Null() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using SparseIndexRemoveSourceContext sourceContext = new(connStr); + await CreateDatabaseViaMigrationAsync(sourceContext); + + // Act + await using SparseIndexRemoveTargetContext targetContext = new(connStr); + await AlterDatabaseViaMigrationAsync(sourceContext, targetContext); + + // Assert + string? value = await GetReloption(connStr, "cs_sparse_remove", "timescaledb.sparse_index"); + Assert.Null(value); + } + + #endregion + + // ── Scaffolding: sparse_index read from reloptions ── + + #region Should_Scaffold_SparseIndex_From_Reloptions + + private class ScaffoldSparseIndexEntity { public DateTime Ts { get; set; } public double Value { get; set; } } + + private class ScaffoldSparseIndexContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_scaffold_sparse"); + e.HasNoKey(); + e.Property(x => x.Value).HasColumnName("value"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(value)"); + }); + } + + [Fact] + public async Task Should_Scaffold_SparseIndex_From_Reloptions() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using ScaffoldSparseIndexContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Act + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(connStr); + DatabaseModelFactoryOptions options = new(tables: ["cs_scaffold_sparse"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + DatabaseTable? table = model.Tables.FirstOrDefault(t => t.Name == "cs_scaffold_sparse"); + Assert.NotNull(table); + Assert.Equal("bloom(value)", table[HypertableAnnotations.CompressionSparseIndex]); + } + + #endregion + + // ── Scaffolding: compress_chunk_time_interval read from columnstore settings ── + + #region Should_Scaffold_CompressChunkTimeInterval_From_ColumnstoreSettings + + private class ScaffoldCctiEntity { public DateTime Ts { get; set; } } + + private class ScaffoldCctiContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_scaffold_ccti"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).WithCompressChunkTimeInterval("7 days"); + }); + } + + [Fact] + public async Task Should_Scaffold_CompressChunkTimeInterval_From_ColumnstoreSettings() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using ScaffoldCctiContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Act + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(connStr); + DatabaseModelFactoryOptions options = new(tables: ["cs_scaffold_ccti"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + DatabaseTable? table = model.Tables.FirstOrDefault(t => t.Name == "cs_scaffold_ccti"); + Assert.NotNull(table); + Assert.NotNull(table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion + + // ── Scaffolding: hypertable WITHOUT columnstore settings → no annotations set ── + + #region Should_Not_Scaffold_Columnstore_Annotations_When_Not_Configured + + private class ScaffoldNoColumnstoreEntity { public DateTime Ts { get; set; } } + + private class ScaffoldNoColumnstoreContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_scaffold_none"); + e.HasNoKey(); + e.IsHypertable(x => x.Ts).EnableCompression(); + }); + } + + [Fact] + public async Task Should_Not_Scaffold_Columnstore_Annotations_When_Not_Configured() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using ScaffoldNoColumnstoreContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Act + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(connStr); + DatabaseModelFactoryOptions options = new(tables: ["cs_scaffold_none"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + DatabaseTable? table = model.Tables.FirstOrDefault(t => t.Name == "cs_scaffold_none"); + Assert.NotNull(table); + Assert.Null(table[HypertableAnnotations.CompressionSparseIndex]); + Assert.Null(table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion + + // ── Phantom migration: scaffolded model diffs to zero operations ── + + #region Should_Not_Generate_Phantom_Hypertable_Alter_After_Scaffolding_Columnstore + + private class PhantomMigrationEntity { public DateTime Ts { get; set; } public double Value { get; set; } } + + private class PhantomMigrationSourceContext(string conn) : DbContext + { + public DbSet Metrics => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("cs_phantom"); + e.HasNoKey(); + e.Property(x => x.Value).HasColumnName("value"); + e.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(value)"); + }); + } + + [Fact] + public async Task Should_Not_Generate_Phantom_Hypertable_Alter_After_Scaffolding_Columnstore() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using PhantomMigrationSourceContext sourceContext = new(connStr); + await CreateDatabaseViaMigrationAsync(sourceContext); + + // Act + IReadOnlyList operations = + GenerateMigrationOperations(sourceContext, sourceContext); + + // Assert + Assert.Empty(operations); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index facb53b..e0ba566 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -139,7 +139,6 @@ private static async Task> GetCompressionSettingsAs } await using NpgsqlCommand command = connection.CreateCommand(); - // This view contains the exact details of how compression is configured per column command.CommandText = @" SELECT attname, @@ -447,7 +446,7 @@ public async Task Should_Create_Hypertable_With_CompressionSegmentBy() await CreateDatabaseViaMigrationAsync(context); bool isCompressed = await IsCompressionEnabledAsync(context, "segment_by_metrics"); - Assert.True(isCompressed, "Compression should be implicitly enabled by SegmentBy"); + Assert.True(isCompressed); List settings = await GetCompressionSettingsAsync(context, "segment_by_metrics"); @@ -501,16 +500,14 @@ public async Task Should_Create_Hypertable_With_CompressionOrderBy() List settings = await GetCompressionSettingsAsync(context, "order_by_metrics"); - // Verify Timestamp (DESC) CompressionSettingInfo tsSetting = settings.First(s => s.ColumnName == "Timestamp"); - Assert.NotNull(tsSetting.OrderByIndex); // Should be ordered - Assert.False(tsSetting.IsAscending); // DESC + Assert.NotNull(tsSetting.OrderByIndex); + Assert.False(tsSetting.IsAscending); - // Verify Value (ASC, NULLS FIRST) CompressionSettingInfo valSetting = settings.First(s => s.ColumnName == "Value"); Assert.NotNull(valSetting.OrderByIndex); - Assert.True(valSetting.IsAscending); // ASC (Default) - Assert.True(valSetting.IsNullsFirst); // NULLS FIRST + Assert.True(valSetting.IsAscending); + Assert.True(valSetting.IsNullsFirst); } #endregion @@ -552,11 +549,9 @@ public async Task Should_Create_Hypertable_With_FullCompressionSettings() List settings = await GetCompressionSettingsAsync(context, "full_comp_metrics"); - // DeviceId should be Segment #1 CompressionSettingInfo deviceSetting = settings.First(s => s.ColumnName == "DeviceId"); Assert.Equal(1, deviceSetting.SegmentByIndex); - // Timestamp should be Order #1 (DESC) CompressionSettingInfo tsSetting = settings.First(s => s.ColumnName == "Timestamp"); Assert.Equal(1, tsSetting.OrderByIndex); Assert.False(tsSetting.IsAscending); diff --git a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs index 6d9b174..0cb7348 100644 --- a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs @@ -112,7 +112,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("MigrateDataFluentApi"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp) - .WithMigrateData(true); // <-- Configure MigrateData via Fluent API + .WithMigrateData(true); }); } } @@ -123,20 +123,20 @@ public void Should_Generate_Migration_SQL_With_MigrateData_True_FluentAPI() // Arrange using MigrateDataFluentApiContext context = new(_connectionString!); - // Act - Generate migration operations + // Act IReadOnlyList operations = GenerateMigrationOperations(null, context); - // Assert - Verify CreateHypertableOperation contains MigrateData = true + // Assert CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); Assert.NotNull(hypertableOp); Assert.Equal("MigrateDataFluentApi", hypertableOp.TableName); Assert.True(hypertableOp.MigrateData); - // Act - Generate SQL from operations + // Act IMigrationsSqlGenerator sqlGenerator = context.GetService(); IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); - // Assert - Verify SQL contains migrate_data => true + // Assert string allSql = string.Join("\n", commands.Select(c => c.CommandText)); Assert.Contains("migrate_data => true", allSql); } @@ -145,7 +145,7 @@ public void Should_Generate_Migration_SQL_With_MigrateData_True_FluentAPI() #region Should_Generate_Migration_SQL_With_MigrateData_True_Attribute - [Hypertable("Timestamp", MigrateData = true)] // <-- Configure MigrateData via Attribute + [Hypertable("Timestamp", MigrateData = true)] private class MigrateDataAttributeEntity { public DateTime Timestamp { get; set; } @@ -175,20 +175,20 @@ public void Should_Generate_Migration_SQL_With_MigrateData_True_Attribute() // Arrange using MigrateDataAttributeContext context = new(_connectionString!); - // Act - Generate migration operations + // Act IReadOnlyList operations = GenerateMigrationOperations(null, context); - // Assert - Verify CreateHypertableOperation contains MigrateData = true + // Assert CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); Assert.NotNull(hypertableOp); Assert.Equal("MigrateDataAttribute", hypertableOp.TableName); Assert.True(hypertableOp.MigrateData); - // Act - Generate SQL from operations + // Act IMigrationsSqlGenerator sqlGenerator = context.GetService(); IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); - // Assert - Verify SQL contains migrate_data => true + // Assert string allSql = string.Join("\n", commands.Select(c => c.CommandText)); Assert.Contains("migrate_data => true", allSql); } @@ -216,7 +216,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("DefaultMigrateData"); entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp); // <-- No MigrateData configured + entity.IsHypertable(x => x.Timestamp); }); } } @@ -227,20 +227,20 @@ public void Should_Generate_Migration_SQL_Without_MigrateData_By_Default() // Arrange using DefaultMigrateDataContext context = new(_connectionString!); - // Act - Generate migration operations + // Act IReadOnlyList operations = GenerateMigrationOperations(null, context); - // Assert - Verify CreateHypertableOperation has default MigrateData = false + // Assert CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); Assert.NotNull(hypertableOp); Assert.Equal("DefaultMigrateData", hypertableOp.TableName); Assert.False(hypertableOp.MigrateData); - // Act - Generate SQL from operations + // Act IMigrationsSqlGenerator sqlGenerator = context.GetService(); IReadOnlyList commands = sqlGenerator.Generate(operations, context.Model); - // Assert - Verify SQL does NOT contain migrate_data parameter + // Assert string allSql = string.Join("\n", commands.Select(c => c.CommandText)); Assert.DoesNotContain("migrate_data", allSql); } @@ -269,7 +269,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("SensorDataMigration"); entity.HasNoKey(); - // <-- Not configured as hypertable yet }); } } @@ -288,7 +287,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("SensorDataMigration"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp) - .WithMigrateData(true); // <-- Changed: Convert to hypertable with data migration + .WithMigrateData(true); }); } } @@ -296,11 +295,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public async Task Should_Migrate_Existing_Data_When_Converting_To_Hypertable() { - // Arrange - Create initial regular table with data + // Arrange await using InitialRegularTableContext initialContext = new(_connectionString!); await CreateDatabaseViaMigrationAsync(initialContext); - // Insert test data into regular table await initialContext.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""SensorDataMigration"" (""Timestamp"", ""DeviceId"", ""Temperature"") VALUES @@ -308,23 +306,20 @@ await initialContext.Database.ExecuteSqlInterpolatedAsync($@" ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {21.0}), ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_3"}, {19.5})", TestContext.Current.CancellationToken); - // Verify data exists before conversion int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataMigration"); Assert.Equal(3, countBeforeConversion); - // Act - Convert to hypertable with MigrateData = true + // Act await using ConvertedHypertableContext convertedContext = new(_connectionString!); await AlterDatabaseViaMigrationAsync(initialContext, convertedContext); - // Assert - Verify table is now a hypertable + // Assert bool isHypertable = await IsHypertableAsync(convertedContext, "SensorDataMigration"); Assert.True(isHypertable); - // Assert - Verify existing data was preserved int countAfterConversion = await GetRowCountAsync(convertedContext, "SensorDataMigration"); Assert.Equal(3, countAfterConversion); - // Assert - Verify data can still be queried via EF Core List data = await convertedContext.SensorData.ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(3, data.Count); Assert.Contains(data, d => d.DeviceId == "device_1" && d.Temperature == 20.5); @@ -356,7 +351,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("SensorDataNoMigration"); entity.HasNoKey(); - // <-- Not configured as hypertable yet }); } } @@ -375,7 +369,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("SensorDataNoMigration"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp) - .WithMigrateData(false); // <-- Changed: Explicitly set MigrateData = false + .WithMigrateData(false); }); } } @@ -383,36 +377,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public async Task Should_Apply_MigrateData_False_When_Converting_To_Hypertable() { - // Arrange - Create initial regular table with data + // Arrange await using InitialRegularTableMigrateDataFalseContext initialContext = new(_connectionString!); await CreateDatabaseViaMigrationAsync(initialContext); - // Insert test data into regular table await initialContext.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""SensorDataNoMigration"" (""Timestamp"", ""DeviceId"", ""Temperature"") VALUES ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5})", TestContext.Current.CancellationToken); - // Verify data exists before conversion int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataNoMigration"); Assert.Equal(1, countBeforeConversion); - // Act - Convert to hypertable with MigrateData = false + // Act await using ConvertedHypertableMigrateDataFalseContext convertedContext = new(_connectionString!); - - // Generate migration operations IReadOnlyList operations = GenerateMigrationOperations(initialContext, convertedContext); - // Assert - Verify CreateHypertableOperation has MigrateData = false + // Assert CreateHypertableOperation? hypertableOp = operations.OfType().FirstOrDefault(); Assert.NotNull(hypertableOp); Assert.False(hypertableOp.MigrateData); - // Generate SQL from operations IMigrationsSqlGenerator sqlGenerator = convertedContext.GetService(); IReadOnlyList commands = sqlGenerator.Generate(operations, convertedContext.Model); - // Assert - Verify SQL does NOT contain migrate_data parameter string allSql = string.Join("\n", commands.Select(c => c.CommandText)); Assert.DoesNotContain("migrate_data", allSql); } diff --git a/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs b/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs new file mode 100644 index 0000000..266c1f5 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs @@ -0,0 +1,191 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Diagnostics.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.Extensions.Logging; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; +using System.Diagnostics; +using Testcontainers.PostgreSql; + +#pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class HypertableMixedCaseOrderbyScaffoldingTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + private async Task CreateIsolatedDbAsync() + { + string testDbName = $"test_{Guid.NewGuid():N}"; + + await using NpgsqlConnection adminConnection = new(_connectionString); + await adminConnection.OpenAsync(); + + await using (NpgsqlCommand createCmd = new($"CREATE DATABASE {testDbName}", adminConnection)) + { + await createCmd.ExecuteNonQueryAsync(); + } + + string connStr = _connectionString!.Replace("test_db", testDbName); + await using NpgsqlConnection testConnection = new(connStr); + await testConnection.OpenAsync(); + await using (NpgsqlCommand extCmd = new("CREATE EXTENSION IF NOT EXISTS timescaledb", testConnection)) + { + await extCmd.ExecuteNonQueryAsync(); + } + + return connStr; + } + + private static TimescaleDatabaseModelFactory CreateFactory() + { + LoggerFactory loggerFactory = new(); + DiagnosticsLogger logger = new( + loggerFactory, + new LoggingOptions(), + new DiagnosticListener("Test"), + new NpgsqlLoggingDefinitions(), + new NullDbContextLogger()); + + return new TimescaleDatabaseModelFactory(logger); + } + + private sealed class NullDbContextLogger : IDbContextLogger + { + public void Log(EventData eventData) { } + public bool ShouldLog(EventId eventId, LogLevel logLevel) => false; + } + + // ── Mixed-case orderby: annotation uses unquoted column name ── + + #region Should_Scaffold_MixedCase_OrderBy_Without_Quote_Characters + + private class MixedCaseOrderByEntity + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class MixedCaseOrderByContext(string conn) : DbContext + { + public DbSet Readings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("mc_orderby_scaffold"); + e.HasNoKey(); + e.Property(x => x.Timestamp).HasColumnName("Timestamp"); + e.Property(x => x.DeviceId).HasColumnName("DeviceId"); + e.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]); + }); + } + + [Fact] + public async Task Should_Scaffold_MixedCase_OrderBy_Without_Quote_Characters() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using MixedCaseOrderByContext context = new(connStr); + await CreateDatabaseViaMigrationAsync(context); + + // Act + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(connStr); + DatabaseModelFactoryOptions options = new(tables: ["mc_orderby_scaffold"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + // Assert + DatabaseTable? table = model.Tables.FirstOrDefault(t => t.Name == "mc_orderby_scaffold"); + Assert.NotNull(table); + + string? orderByAnnotation = table[HypertableAnnotations.CompressionOrderBy] as string; + Assert.NotNull(orderByAnnotation); + + Assert.Contains("Timestamp DESC", orderByAnnotation); + Assert.DoesNotContain("\"Timestamp\"", orderByAnnotation); + } + + #endregion + + // ── Mixed-case orderby: no phantom alter after scaffolding ── + + #region Should_Not_Generate_Phantom_Alter_After_MixedCase_OrderBy_Scaffolding + + private class PhantomMixedCaseEntity + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + } + + private class PhantomMixedCaseSourceContext(string conn) : DbContext + { + public DbSet Readings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder o) + => o.UseNpgsql(conn).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + => m.Entity(e => + { + e.ToTable("mc_phantom_orderby"); + e.HasNoKey(); + e.Property(x => x.Timestamp).HasColumnName("Timestamp"); + e.Property(x => x.DeviceId).HasColumnName("DeviceId"); + e.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]); + }); + } + + [Fact] + public async Task Should_Not_Generate_Phantom_Alter_After_MixedCase_OrderBy_Scaffolding() + { + // Arrange + string connStr = await CreateIsolatedDbAsync(); + + await using PhantomMixedCaseSourceContext sourceContext = new(connStr); + await CreateDatabaseViaMigrationAsync(sourceContext); + + // Act + IReadOnlyList operations = + GenerateMigrationOperations(sourceContext, sourceContext); + + // Assert + Assert.Empty(operations); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 9ebf873..2bf3547 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -216,10 +216,8 @@ public async Task Should_Extract_CompressionSegmentBy() Assert.Single(result); HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; - // Compression should be enabled Assert.True(info.CompressionEnabled); - // SegmentBy list should contain "TenantId" Assert.Single(info.CompressionSegmentBy); Assert.Equal("TenantId", info.CompressionSegmentBy[0]); } @@ -247,7 +245,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasNoKey(); entity.ToTable("Metrics"); - // "Timestamp DESC", "Value ASC NULLS FIRST" entity.IsHypertable(x => x.Timestamp) .WithCompressionOrderBy(s => [ s.ByDescending(x => x.Timestamp), @@ -274,9 +271,7 @@ public async Task Should_Extract_CompressionOrderBy() Assert.Equal(2, info.CompressionOrderBy.Count); - // Extractor reconstructs the string: "ColumnName [ASC|DESC] [NULLS FIRST|LAST]" Assert.Equal("Timestamp DESC", info.CompressionOrderBy[0]); - // Note: Default for ASC is usually NULLS LAST in Postgres, but if we set NULLS FIRST explicitly: Assert.Equal("Value ASC NULLS FIRST", info.CompressionOrderBy[1]); } @@ -324,13 +319,10 @@ public async Task Should_Extract_Full_Compression_Configuration() Assert.Single(result); HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; - // SegmentBy Assert.Single(info.CompressionSegmentBy); Assert.Equal("DeviceId", info.CompressionSegmentBy[0]); - // OrderBy Assert.Single(info.CompressionOrderBy); - // Postgres default for DESC is NULLS FIRST, extractor logic appends what it reads Assert.Contains("Timestamp DESC", info.CompressionOrderBy[0]); } @@ -532,13 +524,13 @@ public async Task Should_Extract_With_Already_Open_Connection_DoesNotClose() HypertableScaffoldingExtractor extractor = new(); await using NpgsqlConnection connection = new(_connectionString); - connection.Open(); // Explicitly open before extraction + connection.Open(); // Act Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); // Assert - Assert.Equal(System.Data.ConnectionState.Open, connection.State); // Connection should still be open + Assert.Equal(System.Data.ConnectionState.Open, connection.State); Assert.Single(result); } @@ -579,10 +571,10 @@ public async Task Should_Extract_With_Closed_Connection_OpensAndCloses() HypertableScaffoldingExtractor extractor = new(); await using NpgsqlConnection connection = new(_connectionString); - // Act - Pass closed connection + // Act Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); - // Assert - Connection should be closed after extraction + // Assert Assert.Equal(System.Data.ConnectionState.Closed, connection.State); Assert.Single(result); } @@ -610,7 +602,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasNoKey(); entity.ToTable("Metrics"); - // No compression enabled entity.IsHypertable(x => x.Timestamp); }); } @@ -656,7 +647,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasNoKey(); entity.ToTable("Metrics"); entity.IsHypertable(x => x.Timestamp) - .EnableCompression(); // Compression enabled but no chunk skip columns + .EnableCompression(); }); } } @@ -674,7 +665,7 @@ public async Task Should_Handle_Hypertable_With_Empty_ChunkSkipColumns() Assert.Single(result); HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; Assert.True(info.CompressionEnabled); - Assert.Empty(info.ChunkSkipColumns); // No chunk skip columns configured + Assert.Empty(info.ChunkSkipColumns); } #endregion @@ -701,7 +692,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasNoKey(); entity.ToTable("Metrics"); entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("86400000"); // 1 day in milliseconds + .WithChunkTimeInterval("86400000"); }); } } @@ -719,8 +710,6 @@ public async Task Should_Extract_Custom_ChunkTimeInterval() Assert.Single(result); HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; Assert.NotNull(info.ChunkTimeInterval); - // Verify that a custom chunk interval is extracted (the exact value depends on database interpretation) - // Input was 86400000 (ms), extractor does EPOCH*1000 conversion Assert.False(string.IsNullOrEmpty(info.ChunkTimeInterval)); } @@ -913,7 +902,6 @@ public async Task Should_Extract_AdditionalDimension_TimeRange_In_Microseconds() Dimension dimension = Assert.Single(info.AdditionalDimensions); Assert.Equal("SecondaryTimestamp", dimension.ColumnName); Assert.Equal(EDimensionType.Range, dimension.Type); - // 30 days in microseconds Assert.Equal("30 days", dimension.Interval); } @@ -1179,7 +1167,6 @@ public async Task Should_Humanize_ChunkTimeInterval_NonRound_Falls_Back_To_Secon HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "humanize_nonround_metrics")]; - // 90s doesn't fit a round minute, so humanizer outputs "90 seconds" Assert.Equal("90 seconds", info.ChunkTimeInterval); } @@ -1235,4 +1222,173 @@ public async Task Should_Extract_BigInt_ChunkTimeInterval_For_Integer_TimeColumn } #endregion + + #region Should_Extract_Integer_ChunkTimeInterval_Via_Raw_SQL + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_Integer_ChunkTimeInterval_Via_Raw_SQL() + { + // Arrange + await using NpgsqlConnection setupConn = new(_connectionString); + await setupConn.OpenAsync(TestContext.Current.CancellationToken); + await using (NpgsqlCommand cmd = new(@" + CREATE TABLE int_chunk_raw (ts BIGINT NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('int_chunk_raw', 'ts', chunk_time_interval => 86400000000::BIGINT);", setupConn)) + { + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "int_chunk_raw"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "int_chunk_raw")]; + + Assert.True(long.TryParse(info.ChunkTimeInterval, out long parsedInterval)); + Assert.Equal(86400000000L, parsedInterval); + } + + #endregion + + #region Should_Extract_Integer_Range_Additional_Dimension_Via_Raw_SQL + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_Integer_Range_Additional_Dimension_Via_Raw_SQL() + { + // Arrange + await using NpgsqlConnection setupConn = new(_connectionString); + await setupConn.OpenAsync(TestContext.Current.CancellationToken); + await using (NpgsqlCommand cmd = new(@" + CREATE TABLE int_range_dim_raw (ts TIMESTAMPTZ NOT NULL, sensor_id INT NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('int_range_dim_raw', 'ts'); + SELECT add_dimension('int_range_dim_raw', 'sensor_id', chunk_time_interval => 10000);", setupConn)) + { + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "int_range_dim_raw"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "int_range_dim_raw")]; + + Dimension dimension = Assert.Single(info.AdditionalDimensions); + Assert.Equal("sensor_id", dimension.ColumnName); + Assert.Equal(EDimensionType.Range, dimension.Type); + Assert.True(long.TryParse(dimension.Interval, out _), + $"Expected an integer interval; got '{dimension.Interval}'"); + } + + #endregion + + #region Should_Extract_Composite_SparseIndex_Via_HypertableScaffoldingExtractor + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_Composite_SparseIndex_Via_HypertableScaffoldingExtractor() + { + // Arrange + await using NpgsqlConnection setupConn = new(_connectionString); + await setupConn.OpenAsync(TestContext.Current.CancellationToken); + await using (NpgsqlCommand cmd = new(@" + CREATE TABLE ht_composite_sparse ( + ts TIMESTAMPTZ NOT NULL, + col_a TEXT, + col_b TEXT + ); + SELECT create_hypertable('ht_composite_sparse', 'ts'); + ALTER TABLE ht_composite_sparse SET ( + timescaledb.compress = true, + timescaledb.compress_orderby = 'ts DESC', + timescaledb.sparse_index = 'bloom(col_a,col_b)' + );", setupConn)) + { + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "ht_composite_sparse"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "ht_composite_sparse")]; + + Assert.NotNull(info.CompressionSparseIndex); + Assert.Contains("bloom", info.CompressionSparseIndex, StringComparison.OrdinalIgnoreCase); + Assert.Contains("col_a", info.CompressionSparseIndex, StringComparison.OrdinalIgnoreCase); + Assert.Contains("col_b", info.CompressionSparseIndex, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Should_Extract_SparseIndex_Via_HypertableScaffoldingExtractor + + private class SparseIndexScaffoldEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class SparseIndexScaffoldContext(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("ht_sparse_extractor"); + entity.Property(x => x.Value).HasColumnName("value"); + entity.IsHypertable(x => x.Ts) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Ts)]) + .WithSparseIndex("bloom(value)"); + }); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Extract_SparseIndex_Via_HypertableScaffoldingExtractor() + { + // Arrange + await using SparseIndexScaffoldContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + // Act + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "ht_sparse_extractor"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "ht_sparse_extractor")]; + + Assert.NotNull(info.CompressionSparseIndex); + Assert.Contains("bloom", info.CompressionSparseIndex, StringComparison.OrdinalIgnoreCase); + Assert.Contains("value", info.CompressionSparseIndex, StringComparison.OrdinalIgnoreCase); + } + + #endregion } diff --git a/tests/Eftdb.Tests/Integration/LegacyCompressionScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/LegacyCompressionScaffoldingExtractorTests.cs new file mode 100644 index 0000000..a4c7411 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/LegacyCompressionScaffoldingExtractorTests.cs @@ -0,0 +1,373 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Integration tests that verify scaffolding extractor fallback paths on TimescaleDB 2.17.x (pre-2.18). +/// On 2.17, the hypertable_columnstore_settings view does not exist, so extractors fall back to +/// timescaledb_information.compression_settings. +/// +public class LegacyCompressionScaffoldingExtractorTests : IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:2.17.2-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + + await using NpgsqlConnection conn = new(_connectionString); + await conn.OpenAsync(); + await using NpgsqlCommand cmd = new("CREATE EXTENSION IF NOT EXISTS timescaledb;", conn); + await cmd.ExecuteNonQueryAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + private async Task ExecuteSqlAsync(string sql) + { + await using NpgsqlConnection conn = new(_connectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + await using NpgsqlCommand cmd = new(sql, conn); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + // ── TryReadCompressionSettingsFromColumnstoreView early return ─────────── + + #region Should_Return_False_From_TryReadColumnstoreView_When_View_Absent + + [Fact] + public async Task Should_Return_False_From_TryReadColumnstoreView_When_View_Absent() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_view_check (ts TIMESTAMPTZ NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_view_check', 'ts'); + ALTER TABLE legacy_view_check SET (timescaledb.compress = true);"); + + // Act + await using NpgsqlConnection connection = new(_connectionString); + bool result = false; + await connection.OpenAsync(TestContext.Current.CancellationToken); + result = CompressionSettingsScaffoldingHelper.TryReadCompressionSettingsFromColumnstoreView( + connection, + rawKey => (rawKey, true), + (_, _) => { }, + (_, _) => { }); + + // Assert + Assert.False(result); + } + + #endregion + + // ── ReadCompressionSettings: segmentby + orderby via legacy view ───────── + + #region Should_Extract_SegmentBy_And_OrderBy_Via_Legacy_CompressionSettings_View + + [Fact] + public async Task Should_Extract_SegmentBy_And_OrderBy_Via_Legacy_CompressionSettings_View() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_ht_segby (ts TIMESTAMPTZ NOT NULL, device_id INT, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_ht_segby', 'ts'); + ALTER TABLE legacy_ht_segby SET ( + timescaledb.compress = true, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'ts DESC' + );"); + + List segmentByCols = []; + List orderByCols = []; + + // Act + await using NpgsqlConnection connection = new(_connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + CompressionSettingsScaffoldingHelper.ReadCompressionSettings( + connection, + rawKey => (rawKey, rawKey == ("public", "legacy_ht_segby")), + (key, columnName, isSegmentBy, isOrderBy, isAscending, isNullsFirst) => + { + if (isSegmentBy) + { + segmentByCols.Add(columnName); + } + + if (isOrderBy) + { + orderByCols.Add(CompressionSettingsScaffoldingHelper.BuildOrderByEntry(columnName, isAscending, isNullsFirst)); + } + }); + + // Assert + Assert.Single(segmentByCols); + Assert.Equal("device_id", segmentByCols[0]); + + Assert.Single(orderByCols); + Assert.Equal("ts DESC", orderByCols[0]); + } + + #endregion + + // ── ReadCompressionSettings: NULL segmentby (only orderby configured) ──── + + #region Should_Extract_OrderBy_Only_When_No_SegmentBy_Via_Legacy_View + + [Fact] + public async Task Should_Extract_OrderBy_Only_When_No_SegmentBy_Via_Legacy_View() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_ht_no_segby (ts TIMESTAMPTZ NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_ht_no_segby', 'ts'); + ALTER TABLE legacy_ht_no_segby SET ( + timescaledb.compress = true, + timescaledb.compress_orderby = 'ts ASC' + );"); + + List segmentByCols = []; + List orderByCols = []; + + // Act + await using NpgsqlConnection connection = new(_connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + CompressionSettingsScaffoldingHelper.ReadCompressionSettings( + connection, + rawKey => (rawKey, rawKey == ("public", "legacy_ht_no_segby")), + (key, columnName, isSegmentBy, isOrderBy, isAscending, isNullsFirst) => + { + if (isSegmentBy) + { + segmentByCols.Add(columnName); + } + + if (isOrderBy) + { + orderByCols.Add(CompressionSettingsScaffoldingHelper.BuildOrderByEntry(columnName, isAscending, isNullsFirst)); + } + }); + + // Assert + Assert.Empty(segmentByCols); + Assert.Single(orderByCols); + Assert.Equal("ts ASC", orderByCols[0]); + } + + #endregion + + // ── HypertableScaffoldingExtractor: legacy fallback path ───────────────── + + #region Should_Extract_Hypertable_SegmentBy_Via_Legacy_Path + + [Fact] + public async Task Should_Extract_Hypertable_SegmentBy_Via_Legacy_Path() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_ht_extract_segby (ts TIMESTAMPTZ NOT NULL, region TEXT, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_ht_extract_segby', 'ts'); + ALTER TABLE legacy_ht_extract_segby SET ( + timescaledb.compress = true, + timescaledb.compress_segmentby = 'region', + timescaledb.compress_orderby = 'ts DESC' + );"); + + // Act + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "legacy_ht_extract_segby"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "legacy_ht_extract_segby")]; + + Assert.True(info.CompressionEnabled); + Assert.Single(info.CompressionSegmentBy); + Assert.Equal("region", info.CompressionSegmentBy[0]); + + Assert.Single(info.CompressionOrderBy); + Assert.Contains("ts", info.CompressionOrderBy[0]); + Assert.Contains("DESC", info.CompressionOrderBy[0]); + } + + #endregion + + #region Should_Extract_Hypertable_With_Compression_No_SegmentBy_Via_Legacy_Path + + [Fact] + public async Task Should_Extract_Hypertable_With_Compression_No_SegmentBy_Via_Legacy_Path() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_ht_compress_only (ts TIMESTAMPTZ NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_ht_compress_only', 'ts'); + ALTER TABLE legacy_ht_compress_only SET ( + timescaledb.compress = true, + timescaledb.compress_orderby = 'ts DESC' + );"); + + // Act + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "legacy_ht_compress_only"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "legacy_ht_compress_only")]; + + Assert.True(info.CompressionEnabled); + Assert.Empty(info.CompressionSegmentBy); + Assert.Single(info.CompressionOrderBy); + } + + #endregion + + // ── ContinuousAggregateScaffoldingExtractor: legacy CAgg fallback path ─── + + #region Should_Extract_CAgg_CompressionSettings_Via_Legacy_Path + + [Fact] + public async Task Should_Extract_CAgg_CompressionSettings_Via_Legacy_Path() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_cagg_source (ts TIMESTAMPTZ NOT NULL, device_id INT, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_cagg_source', 'ts'); + + CREATE MATERIALIZED VIEW legacy_cagg_hourly + WITH (timescaledb.continuous) AS + SELECT time_bucket('1 hour', ts) AS bucket, + device_id, + avg(val) AS avg_val + FROM legacy_cagg_source + GROUP BY 1, 2 + WITH NO DATA; + + ALTER MATERIALIZED VIEW legacy_cagg_hourly SET ( + timescaledb.compress = true, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'bucket DESC' + );"); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "legacy_cagg_hourly"))); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "legacy_cagg_hourly")]; + + Assert.True(info.CompressionEnabled); + Assert.NotNull(info.CompressionSegmentBy); + Assert.Contains("device_id", info.CompressionSegmentBy); + Assert.NotNull(info.CompressionOrderBy); + Assert.NotEmpty(info.CompressionOrderBy); + Assert.Contains("DESC", info.CompressionOrderBy[0]); + } + + #endregion + + // ── ReadCompressionSettings: !accepted row-skip (lines 55-56) ──────────── + + #region Should_Skip_CAgg_Materialization_Row_In_Legacy_ReadCompressionSettings + + [Fact] + public async Task Should_Skip_CAgg_Materialization_Row_In_Legacy_ReadCompressionSettings() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_ht_with_cagg (ts TIMESTAMPTZ NOT NULL, region TEXT, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_ht_with_cagg', 'ts'); + ALTER TABLE legacy_ht_with_cagg SET ( + timescaledb.compress = true, + timescaledb.compress_segmentby = 'region', + timescaledb.compress_orderby = 'ts DESC' + ); + + CREATE MATERIALIZED VIEW legacy_cagg_for_skip_test + WITH (timescaledb.continuous) AS + SELECT time_bucket('1 hour', ts) AS bucket, + region, + avg(val) AS avg_val + FROM legacy_ht_with_cagg + GROUP BY 1, 2 + WITH NO DATA; + + ALTER MATERIALIZED VIEW legacy_cagg_for_skip_test SET ( + timescaledb.compress = true, + timescaledb.compress_segmentby = 'region', + timescaledb.compress_orderby = 'bucket DESC' + );"); + + // Act + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "legacy_ht_with_cagg"))); + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "legacy_ht_with_cagg")]; + + Assert.True(info.CompressionEnabled); + Assert.Single(info.CompressionSegmentBy); + Assert.Equal("region", info.CompressionSegmentBy[0]); + } + + #endregion + + #region Should_Extract_CAgg_Without_CompressionSettings_Via_Legacy_Path + + [Fact] + public async Task Should_Extract_CAgg_Without_CompressionSettings_Via_Legacy_Path() + { + // Arrange + await ExecuteSqlAsync(@" + CREATE TABLE legacy_cagg_uncompressed_source (ts TIMESTAMPTZ NOT NULL, val DOUBLE PRECISION); + SELECT create_hypertable('legacy_cagg_uncompressed_source', 'ts'); + + CREATE MATERIALIZED VIEW legacy_cagg_uncompressed_hourly + WITH (timescaledb.continuous) AS + SELECT time_bucket('1 hour', ts) AS bucket, + avg(val) AS avg_val + FROM legacy_cagg_uncompressed_source + GROUP BY 1 + WITH NO DATA;"); + + // Act + ContinuousAggregateScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + // Assert + Assert.True(result.ContainsKey(("public", "legacy_cagg_uncompressed_hourly"))); + ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo info = + (ContinuousAggregateScaffoldingExtractor.ContinuousAggregateInfo)result[("public", "legacy_cagg_uncompressed_hourly")]; + + Assert.False(info.CompressionEnabled); + Assert.NotNull(info.CompressionSegmentBy); + Assert.Empty(info.CompressionSegmentBy); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs b/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs index 3a7ed96..ee05799 100644 --- a/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationDifferRenameTests.cs @@ -48,7 +48,7 @@ private class HtModifiedContext1 : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") - .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseSnakeCaseNamingConvention() .UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -112,7 +112,7 @@ private class CompressedModifiedContext5 : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") - .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseSnakeCaseNamingConvention() .UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -178,7 +178,7 @@ private class ReorderModifiedContext2 : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") - .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseSnakeCaseNamingConvention() .UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -240,7 +240,7 @@ private class RetentionModifiedContext3 : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") - .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseSnakeCaseNamingConvention() .UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -319,7 +319,7 @@ private class CaggModifiedContext4 : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") - .UseSnakeCaseNamingConvention() // <-- Added on top of the existing migration + .UseSnakeCaseNamingConvention() .UseTimescaleDb(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -361,11 +361,9 @@ public void Should_Readd_Policies_When_ContinuousAggregate_Recreated() AddRetentionPolicyOperation retention = Assert.Single(operations.OfType()); Assert.Equal("sensor_hourly", retention.TableName); - // The cascade already removed these, so no explicit remove/drop should be emitted for the view. Assert.Empty(operations.OfType()); Assert.DoesNotContain(operations.OfType(), o => o.TableName == "sensor_hourly"); - // Re-adds must be ordered after the recreate. int createIndex = IndexOf(operations); Assert.True(createIndex < IndexOf(operations)); Assert.True(createIndex < IndexOf(operations)); diff --git a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs index b56a379..38cff22 100644 --- a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs @@ -162,7 +162,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("Metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("12 hours"); // <-- Changed from "1 day" + .WithChunkTimeInterval("12 hours"); }); } } @@ -228,7 +228,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("Metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("12 hours"); // <-- Changed from "1 day" + .WithChunkTimeInterval("12 hours"); }); } } @@ -432,7 +432,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp, - chunkInterval: "30 days") // <-- Changed from default "7 days" + chunkInterval: "30 days") .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); }); } @@ -530,7 +530,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "hourly_metrics", "1 hour", x => x.Timestamp) - .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max); // <-- Changed from Avg + .AddAggregateFunction(x => x.MaxValue, x => x.Value, EAggregateFunction.Max); }); } } @@ -678,7 +678,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("Metrics"); entity.HasNoKey(); entity.IsHypertable(x => x.Timestamp); - entity.WithReorderPolicy("metrics_time_idx", scheduleInterval: "12:00:00"); // <-- Changed from "1 day" + entity.WithReorderPolicy("metrics_time_idx", scheduleInterval: "12:00:00"); entity.HasIndex(x => x.Timestamp).HasDatabaseName("metrics_time_idx"); }); } diff --git a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs index 1ef11af..35e6c64 100644 --- a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs @@ -54,10 +54,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that CreateTableOperation appears before CreateIndexOperation. - /// This is essential because indexes cannot be created on tables that don't exist yet. - /// [Fact] public void Should_Order_CreateTable_Before_CreateIndex() { @@ -129,11 +125,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that CreateTableOperation appears before CreateIndexOperation for foreign key index. - /// EF Core includes foreign keys in the CreateTableOperation, but generates a separate - /// CreateIndexOperation for the foreign key index. - /// [Fact] public void Should_Order_CreateTable_Before_Foreign_Key_Index() { @@ -192,10 +183,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that CreateTableOperation appears before CreateHypertableOperation. - /// TimescaleDB requires the table to exist before it can be converted to a hypertable. - /// [Fact] public void Should_Order_CreateTable_Before_CreateHypertable() { @@ -248,10 +235,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that CreateIndexOperation appears before AddReorderPolicyOperation. - /// Reorder policies reference an index, so the index must exist first. - /// [Fact] public void Should_Order_CreateIndex_Before_AddReorderPolicy() { @@ -336,11 +319,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that when multiple tables have indexes, each table's CreateTableOperation - /// appears before its associated CreateIndexOperations. This test is particularly sensitive - /// to unstable sorts because all standard EF Core operations have priority 0. - /// [Fact] public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() { @@ -351,7 +329,6 @@ public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() IReadOnlyList operations = GenerateMigrationOperations(null, context); // Assert - // Table A and its indexes int tableAIndex = operations.ToList().FindIndex(op => op is CreateTableOperation createTable && createTable.Name == "OrderingTableA5"); int indexA1Index = operations.ToList().FindIndex(op => @@ -359,7 +336,6 @@ public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() int indexA2Index = operations.ToList().FindIndex(op => op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5a_name"); - // Table B and its indexes int tableBIndex = operations.ToList().FindIndex(op => op is CreateTableOperation createTable && createTable.Name == "OrderingTableB5"); int indexB1Index = operations.ToList().FindIndex(op => @@ -367,7 +343,6 @@ public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() int indexB2Index = operations.ToList().FindIndex(op => op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5b_description"); - // Table C and its indexes int tableCIndex = operations.ToList().FindIndex(op => op is CreateTableOperation createTable && createTable.Name == "OrderingTableC5"); int indexC1Index = operations.ToList().FindIndex(op => @@ -375,7 +350,6 @@ public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() int indexC2Index = operations.ToList().FindIndex(op => op is CreateIndexOperation createIndex && createIndex.Name == "idx_ordering5c_reason"); - // Verify all operations were found Assert.NotEqual(-1, tableAIndex); Assert.NotEqual(-1, indexA1Index); Assert.NotEqual(-1, indexA2Index); @@ -386,19 +360,16 @@ public void Should_Preserve_Order_For_Multiple_Tables_With_Indexes() Assert.NotEqual(-1, indexC1Index); Assert.NotEqual(-1, indexC2Index); - // Verify Table A comes before its indexes Assert.True(tableAIndex < indexA1Index, $"TableA (index {tableAIndex}) should appear before its first index (index {indexA1Index})"); Assert.True(tableAIndex < indexA2Index, $"TableA (index {tableAIndex}) should appear before its second index (index {indexA2Index})"); - // Verify Table B comes before its indexes Assert.True(tableBIndex < indexB1Index, $"TableB (index {tableBIndex}) should appear before its first index (index {indexB1Index})"); Assert.True(tableBIndex < indexB2Index, $"TableB (index {tableBIndex}) should appear before its second index (index {indexB2Index})"); - // Verify Table C comes before its indexes Assert.True(tableCIndex < indexC1Index, $"TableC (index {tableCIndex}) should appear before its first index (index {indexC1Index})"); Assert.True(tableCIndex < indexC2Index, @@ -470,11 +441,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies correct ordering in a complex migration with multiple operation types: - /// CreateTable, CreateIndex, AddForeignKey, CreateHypertable, and AddReorderPolicy. - /// This comprehensive test ensures the stable sort preserves all necessary dependencies. - /// [Fact] public void Should_Order_Complex_Migration_With_All_Operation_Types() { @@ -485,7 +451,6 @@ public void Should_Order_Complex_Migration_With_All_Operation_Types() IReadOnlyList operations = GenerateMigrationOperations(null, context); // Assert - // Find all operation indices int parentTableIndex = operations.ToList().FindIndex(op => op is CreateTableOperation createTable && createTable.Name == "OrderingParents6"); int childTableIndex = operations.ToList().FindIndex(op => @@ -509,7 +474,6 @@ op is CreateIndexOperation createIndex && int reorderPolicyIndex = operations.ToList().FindIndex(op => op is AddReorderPolicyOperation); - // Verify all operations were found Assert.NotEqual(-1, parentTableIndex); Assert.NotEqual(-1, childTableIndex); Assert.NotEqual(-1, metricsTableIndex); @@ -520,7 +484,6 @@ op is CreateIndexOperation createIndex && Assert.NotEqual(-1, hypertableIndex); Assert.NotEqual(-1, reorderPolicyIndex); - // Verify CreateTable operations come before their dependent operations Assert.True(parentTableIndex < parentIndexIndex, "Parent table should be created before its index"); Assert.True(parentTableIndex < foreignKeyIndexIndex, @@ -528,7 +491,6 @@ op is CreateIndexOperation createIndex && Assert.True(childTableIndex < foreignKeyIndexIndex, "Child table should be created before foreign key index"); - // Verify metrics table dependencies Assert.True(metricsTableIndex < metricsTimestampIndexIndex, "Metrics table should be created before its timestamp index"); Assert.True(metricsTableIndex < metricsDeviceIndexIndex, @@ -536,11 +498,9 @@ op is CreateIndexOperation createIndex && Assert.True(metricsTableIndex < hypertableIndex, "Metrics table should be created before hypertable operation"); - // Verify hypertable comes before reorder policy Assert.True(hypertableIndex < reorderPolicyIndex, "Hypertable should be created before reorder policy"); - // Verify index comes before reorder policy Assert.True(metricsTimestampIndexIndex < reorderPolicyIndex, "Index should be created before reorder policy that references it"); } @@ -590,11 +550,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that the stable sort preserves the relative order of standard EF Core operations. - /// All standard operations have priority 0, so using an unstable sort (List.Sort) would - /// scramble their order. This test ensures tables are created before their dependent indexes. - /// [Fact] public void Should_Preserve_Relative_Order_Of_Standard_Operations() { @@ -618,7 +573,6 @@ op is CreateIndexOperation createIndex && Assert.NotEqual(-1, detailTableIndex); Assert.NotEqual(-1, foreignKeyIndexIndex); - // Both tables must be created before the foreign key index Assert.True(masterTableIndex < foreignKeyIndexIndex, $"Master table (index {masterTableIndex}) should be created before foreign key index (index {foreignKeyIndexIndex})"); Assert.True(detailTableIndex < foreignKeyIndexIndex, @@ -660,12 +614,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that in a hypertable with multiple indexes: - /// 1. CreateTable comes first - /// 2. CreateHypertable comes after table creation - /// 3. All CreateIndex operations come after table creation - /// [Fact] public void Should_Order_Hypertable_And_Indexes_Correctly() { @@ -693,11 +641,9 @@ public void Should_Order_Hypertable_And_Indexes_Correctly() Assert.NotEqual(-1, humidityIndexIndex); Assert.NotEqual(-1, sensorIndexIndex); - // Table must come before hypertable Assert.True(tableIndex < hypertableIndex, $"Table (index {tableIndex}) should be created before hypertable (index {hypertableIndex})"); - // Table must come before all indexes Assert.True(tableIndex < tempIndexIndex, $"Table (index {tableIndex}) should be created before temperature index (index {tempIndexIndex})"); Assert.True(tableIndex < humidityIndexIndex, @@ -737,10 +683,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Verifies that CreateHypertableOperation appears before AddRetentionPolicyOperation. - /// Retention policies require the hypertable to exist first. - /// [Fact] public void Should_Order_CreateHypertable_Before_AddRetentionPolicy() { @@ -806,10 +748,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) .UseTimescaleDb(); } - /// - /// Verifies that DropRetentionPolicyOperation appears before DropTableOperation. - /// The retention policy must be removed before the hypertable is dropped. - /// [Fact] public void Should_Order_DropRetentionPolicy_Before_DropTable() { @@ -817,7 +755,7 @@ public void Should_Order_DropRetentionPolicy_Before_DropTable() using OrderingSourceContext10 sourceContext = new(); using OrderingTargetContext10 targetContext = new(); - // Act — diff from source (has table + retention policy) to target (empty) + // Act IReadOnlyList operations = GenerateMigrationOperations(sourceContext, targetContext); // Assert @@ -837,7 +775,6 @@ public void Should_Order_DropRetentionPolicy_Before_DropTable() #region Should_Fail_With_Unstable_Sort_Many_Tables - // Many entity classes to generate enough operations to trigger unstable sort behavior private class LargeEntity01 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } private class LargeEntity02 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } private class LargeEntity03 { public int Id { get; set; } public string? Field1 { get; set; } public string? Field2 { get; set; } } @@ -868,7 +805,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Configure 10 tables, each with 2 indexes = 30 operations (well above 16 threshold) ConfigureEntity(modelBuilder, "LargeTable01"); ConfigureEntity(modelBuilder, "LargeTable02"); ConfigureEntity(modelBuilder, "LargeTable03"); @@ -893,12 +829,6 @@ private static void ConfigureEntity(ModelBuilder modelBuilder, string tableNa } } - /// - /// This test uses a large model (10 tables with 2 indexes each = 30+ operations) to trigger - /// the unstable sort behavior in List.Sort(). IntroSort uses InsertionSort for lists under 16 elements, - /// which is stable. For larger lists, it uses QuickSort which is unstable. - /// This test should FAIL when using Sort() and PASS when using OrderBy(). - /// [Fact] public void Should_Maintain_Order_With_Many_Tables_And_Indexes() { @@ -908,7 +838,7 @@ public void Should_Maintain_Order_With_Many_Tables_And_Indexes() // Act IReadOnlyList operations = GenerateMigrationOperations(null, context); - // Assert - verify each table's CreateTable comes before its indexes + // Assert List opsList = [.. operations]; for (int i = 1; i <= 10; i++) diff --git a/tests/Eftdb.Tests/Integration/MigrationTestBase.cs b/tests/Eftdb.Tests/Integration/MigrationTestBase.cs index 3ab0187..2a33b2e 100644 --- a/tests/Eftdb.Tests/Integration/MigrationTestBase.cs +++ b/tests/Eftdb.Tests/Integration/MigrationTestBase.cs @@ -12,9 +12,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; /// public abstract class MigrationTestBase { - /// - /// Simulates "dotnet ef migrations add" by comparing two models and generating operations. - /// protected static IReadOnlyList GenerateMigrationOperations( DbContext? sourceContext, DbContext targetContext) @@ -27,10 +24,6 @@ protected static IReadOnlyList GenerateMigrationOperations( return differ.GetDifferences(sourceModel, targetModel); } - /// - /// Simulates "dotnet ef database update" by generating SQL from operations and executing them. - /// Groups commands with SET statements to maintain PostgreSQL session state. - /// protected static async Task ApplyMigrationAsync( DbContext context, IReadOnlyList operations) @@ -46,22 +39,17 @@ protected static async Task ApplyMigrationAsync( { string sql = command.CommandText.Trim(); - // Check if this is a SET command if (sql.StartsWith("SET ", StringComparison.OrdinalIgnoreCase)) { - // Add SET to current batch currentBatch.Add(sql.TrimEnd(';')); } else { - // Add command to batch currentBatch.Add(sql.TrimEnd(';')); - // Execute the batch (SET + command, or just command if no SET) string batchSql = string.Join(";\n", currentBatch); await context.Database.ExecuteSqlRawAsync(batchSql); - // Clear batch for next iteration currentBatch.Clear(); } } @@ -74,9 +62,6 @@ protected static async Task ApplyMigrationAsync( } } - /// - /// Complete migration workflow: generate operations and apply them. - /// protected static async Task ExecuteMigrationAsync( DbContext? sourceContext, DbContext targetContext) @@ -90,13 +75,9 @@ protected static async Task ExecuteMigrationAsync( /// protected static async Task CreateDatabaseViaMigrationAsync(DbContext context) { - // Generate operations from null (empty database) to current model await ExecuteMigrationAsync(null, context); } - /// - /// Simulates altering the database by comparing two different contexts. - /// protected static async Task AlterDatabaseViaMigrationAsync( DbContext oldContext, DbContext newContext) diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs index 323a11f..a41b4d9 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs @@ -408,7 +408,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithReorderPolicy( indexName: "metrics_schedule_interval_time_idx", - scheduleInterval: "12 hours" // <-- Changed from "1 day" + scheduleInterval: "12 hours" ); entity.HasIndex(e => new { e.Time, e.Id }) .HasDatabaseName("metrics_schedule_interval_time_idx"); @@ -497,7 +497,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithReorderPolicy( indexName: "metrics_max_runtime_time_idx", - maxRuntime: "00:15:00" // <-- Changed from "00:30:00" + maxRuntime: "00:15:00" ); entity.HasIndex(e => new { e.Time, e.Id }) .HasDatabaseName("metrics_max_runtime_time_idx"); @@ -584,7 +584,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithReorderPolicy( indexName: "metrics_max_retries_time_idx", - maxRetries: 5 // <-- Changed from 3 + maxRetries: 5 ); entity.HasIndex(e => new { e.Time, e.Id }) .HasDatabaseName("metrics_max_retries_time_idx"); @@ -673,9 +673,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithReorderPolicy( indexName: "metrics_multiple_params_time_idx", - scheduleInterval: "12 hours", // <-- Changed from "1 day" - maxRuntime: "00:15:00", // <-- Changed from "00:30:00" - maxRetries: 5 // <-- Changed from 3 + scheduleInterval: "12 hours", + maxRuntime: "00:15:00", + maxRetries: 5 ); entity.HasIndex(e => new { e.Time, e.Id }) .HasDatabaseName("metrics_multiple_params_time_idx"); @@ -762,7 +762,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("metrics_drop_policy"); entity.HasKey(e => new { e.Time, e.Id }); entity.IsHypertable(e => e.Time); - // <-- Reorder policy removed entity.HasIndex(e => new { e.Time, e.Id }) .HasDatabaseName("metrics_drop_policy_time_idx"); }); diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs index afc24d1..86fcd69 100644 --- a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs @@ -340,7 +340,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithRetentionPolicy( dropAfter: "7 days", - scheduleInterval: "12 hours" // <-- Changed from "1 day" + scheduleInterval: "12 hours" ); }); } @@ -408,7 +408,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("retention_alter_drop_after"); entity.HasKey(e => new { e.Time, e.Id }); entity.IsHypertable(e => e.Time); - entity.WithRetentionPolicy(dropAfter: "30 days"); // <-- Changed from "7 days" + entity.WithRetentionPolicy(dropAfter: "30 days"); }); } } @@ -478,7 +478,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("retention_drop_policy"); entity.HasKey(e => new { e.Time, e.Id }); entity.IsHypertable(e => e.Time); - // <-- Retention policy removed }); } } @@ -590,7 +589,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("retention_alter_da_to_dcb"); entity.HasKey(e => new { e.Time, e.Id }); entity.IsHypertable(e => e.Time); - entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); // <-- Changed from dropAfter: "7 days" + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); }); } } @@ -817,7 +816,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => new { e.Time, e.Id }); entity.IsHypertable(e => e.Time); entity.WithRetentionPolicy( - dropCreatedBefore: "30 days", // <-- Changed from dropAfter: "7 days" + dropCreatedBefore: "30 days", scheduleInterval: "6 hours", maxRetries: 5 ); @@ -898,7 +897,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithRetentionPolicy( dropCreatedBefore: "30 days", - scheduleInterval: "12 hours" // <-- Changed from "1 day" + scheduleInterval: "12 hours" ); }); } @@ -917,8 +916,6 @@ public async Task Should_Alter_DropCreatedBefore_RetentionPolicy_ScheduleInterva await using ModifiedAlterDcbScheduleContext modifiedContext = new(_connectionString!); await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); - // Pre-2.26.3 this alter_job call would fail because the policy config carries - // drop_created_before instead of drop_after. TimeSpan newSchedule = await GetScheduleIntervalAsync(modifiedContext, jobId); Assert.Equal(TimeSpan.FromHours(12), newSchedule); } @@ -972,7 +969,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.IsHypertable(e => e.Time); entity.WithRetentionPolicy( dropAfter: "7 days", - initialStart: new DateTime(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc) // <-- Changed from 2025-01-01 + initialStart: new DateTime(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc) ); }); } diff --git a/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs index 7cdbd46..2f56edf 100644 --- a/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs +++ b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs @@ -246,10 +246,6 @@ private static ModelCodeGenerationOptions MakeCodeGenOptions( SuppressOnConfiguring = suppressOnConfiguring }; - /// - /// Scaffolds the database into an (via ) - /// and also renders the model to a for Roslyn compilation. - /// private static (IModel Model, ScaffoldedModel ScaffoldedCode) ScaffoldDatabase( string connectionString, bool useDataAnnotations, @@ -272,11 +268,6 @@ private static (IModel Model, ScaffoldedModel ScaffoldedCode) ScaffoldDatabase( return (model, scaffoldedCode); } - /// - /// Loads the compiled assembly from Roslyn PE bytes, instantiates - /// RoundTripScaffold.RoundTripDbContext with options pointing at - /// , and applies the migration pipeline to create the schema. - /// private static async Task ApplyCompiledContextToDatabase(byte[] peBytes, string connectionString) { Assembly compiledAssembly = Assembly.Load(peBytes); @@ -294,11 +285,6 @@ private static async Task ApplyCompiledContextToDatabase(byte[] peBytes, string } } - /// - /// Queries a single row for from - /// timescaledb_information.continuous_aggregates joined to the materialization - /// hypertable dimensions, and returns the relevant fields. - /// private static async Task<(string ViewDefinition, bool MaterializedOnly, string? ChunkInterval)> QueryContinuousAggregateInfoAsync(string connectionString, string viewName) { @@ -334,10 +320,6 @@ LEFT JOIN timescaledb_information.dimensions dim return (viewDefinition, materializedOnly, chunkInterval); } - /// - /// Queries the refresh-policy job parameters for the continuous aggregate identified by - /// from timescaledb_information.jobs. - /// private static async Task<(string? StartOffset, string? EndOffset, string ScheduleInterval)> QueryCaRefreshPolicyAsync(string connectionString, string viewName) { @@ -367,10 +349,6 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca return (startOffset, endOffset, scheduleInterval); } - /// - /// Queries the drop-after retention policy on the continuous aggregate view identified by - /// . Retention on a CA targets its materialized hypertable. - /// private static async Task QueryCaRetentionDropAfterAsync( string connectionString, string viewName) { @@ -396,9 +374,6 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca return reader.IsDBNull(0) ? null : reader.GetString(0); } - /// - /// Compiles all files from a with Roslyn and asserts zero errors. - /// private static byte[] CompileAndAssertNoErrors(ScaffoldedModel scaffolded, string label) { IEnumerable trustedPlatformAssemblies = @@ -448,11 +423,6 @@ private static byte[] CompileAndAssertNoErrors(ScaffoldedModel scaffolded, strin // ── Differ assertion helpers ────────────────────────────────────────────── - /// - /// Runs all five feature differs between two relational models and asserts zero operations - /// in both directions. A non-zero result is reported with operation types and table names - /// so a failing test is self-diagnosing. - /// private static void AssertZeroDiff( IRelationalModel source, IRelationalModel target, diff --git a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs index 044540d..25da2fe 100644 --- a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs @@ -118,7 +118,6 @@ public async Task Should_Translate_TimeBucket_In_GroupBy() await CreateDatabaseViaMigrationAsync(context); DateTime baseTime = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); - // Insert 12 rows: 10:00 through 10:11 for (int i = 0; i < 12; i++) { DateTime ts = baseTime.AddMinutes(i); @@ -127,7 +126,7 @@ await context.Database.ExecuteSqlInterpolatedAsync( TestContext.Current.CancellationToken); } - // Act — group into 5-minute buckets and sum + // Act var results = await context.Metrics .GroupBy(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp)) .Select(g => new @@ -139,18 +138,15 @@ await context.Database.ExecuteSqlInterpolatedAsync( .OrderBy(r => r.Bucket) .ToListAsync(TestContext.Current.CancellationToken); - // Assert — expect 3 buckets: [10:00-10:05), [10:05-10:10), [10:10-10:15) + // Assert Assert.Equal(3, results.Count); - // First bucket: minutes 0-4, values 1+2+3+4+5 = 15 Assert.Equal(5, results[0].Count); Assert.Equal(15, results[0].Total); - // Second bucket: minutes 5-9, values 6+7+8+9+10 = 40 Assert.Equal(5, results[1].Count); Assert.Equal(40, results[1].Total); - // Third bucket: minutes 10-11, values 11+12 = 23 Assert.Equal(2, results[2].Count); Assert.Equal(23, results[2].Total); } @@ -197,7 +193,7 @@ await context.Database.ExecuteSqlInterpolatedAsync( TestContext.Current.CancellationToken); } - // Act — bucket SequenceNumber into groups of 5 + // Act var results = await context.Metrics .GroupBy(m => EF.Functions.TimeBucket(5, m.SequenceNumber)) .Select(g => new @@ -208,7 +204,7 @@ await context.Database.ExecuteSqlInterpolatedAsync( .OrderBy(r => r.Bucket) .ToListAsync(TestContext.Current.CancellationToken); - // Assert — expect 4 buckets: [0-5), [5-10), [10-15), [15-20) + // Assert Assert.Equal(4, results.Count); Assert.All(results, r => Assert.Equal(5, r.Count)); } diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index bc91a67..9097964 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -130,7 +130,6 @@ public async Task Should_Scaffold_Minimal_Hypertable() DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); - // Verify hypertable annotations Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); Assert.Equal("Timestamp", metricsTable[HypertableAnnotations.HypertableTimeColumn]); Assert.NotNull(metricsTable[HypertableAnnotations.ChunkTimeInterval]); @@ -231,14 +230,10 @@ public async Task Should_Scaffold_Hypertable_With_Compression_SegmentBy() DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); - // Verify Hypertable Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); - // Compression should be implicitly enabled Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); - // Verify SegmentBy Annotation - // The annotation value should be the column name "TenantId" string? segmentBy = metricsTable[HypertableAnnotations.CompressionSegmentBy] as string; Assert.NotNull(segmentBy); Assert.Equal("TenantId", segmentBy); @@ -294,12 +289,9 @@ public async Task Should_Scaffold_Hypertable_With_Compression_OrderBy() Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); - // Verify OrderBy Annotation - // Expect comma-separated string of clauses string? orderBy = metricsTable[HypertableAnnotations.CompressionOrderBy] as string; Assert.NotNull(orderBy); - // The extractor reconstructs strings like "Column [ASC|DESC] [NULLS FIRST|LAST]" Assert.Contains("Timestamp DESC", orderBy); Assert.Contains("Value ASC", orderBy); Assert.Contains("NULLS FIRST", orderBy); @@ -351,7 +343,6 @@ public async Task Should_Scaffold_Hypertable_With_Full_Compression_Settings() DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); - // Verify all compression settings are present Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); @@ -413,7 +404,6 @@ public async Task Should_Scaffold_Hypertable_With_Hash_Dimension() string? dimensionsJson = metricsTable[HypertableAnnotations.AdditionalDimensions] as string; Assert.NotNull(dimensionsJson); Assert.Contains("DeviceId", dimensionsJson); - // EDimensionType.Hash = 1 in the enum, serialized as integer Assert.Contains("\"Type\":1", dimensionsJson); } @@ -462,7 +452,6 @@ public async Task Should_Scaffold_Hypertable_With_Reorder_Policy() DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); - // Verify both hypertable and reorder policy annotations Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("metrics_time_idx", metricsTable[ReorderPolicyAnnotations.IndexName]); @@ -586,12 +575,10 @@ public async Task Should_Scaffold_Continuous_Aggregate() DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics"], schemas: []); DatabaseModel model = factory.Create(connection, options); - // Verify source hypertable DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); - // Verify continuous aggregate DatabaseTable? caggTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics"); Assert.NotNull(caggTable); Assert.Equal("hourly_metrics", caggTable[ContinuousAggregateAnnotations.MaterializedViewName]); @@ -708,7 +695,6 @@ public async Task Should_Not_Apply_Hypertable_Annotations_To_Regular_Table() DatabaseTable? entitiesTable = model.Tables.FirstOrDefault(t => t.Name == "Entities"); Assert.NotNull(entitiesTable); - // Should NOT have any TimescaleDB annotations Assert.Null(entitiesTable[HypertableAnnotations.IsHypertable]); Assert.Null(entitiesTable[HypertableAnnotations.HypertableTimeColumn]); Assert.Null(entitiesTable[ReorderPolicyAnnotations.HasReorderPolicy]); @@ -841,10 +827,8 @@ public async Task Should_Scaffold_Mixed_Regular_And_Hypertables() Assert.NotNull(entitiesTable); Assert.NotNull(metricsTable); - // Regular table should NOT have hypertable annotations Assert.Null(entitiesTable[HypertableAnnotations.IsHypertable]); - // Hypertable should have annotations Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); } @@ -963,7 +947,6 @@ public async Task Should_Scaffold_Full_Configuration() DatabaseModelFactoryOptions options = new(tables: ["Metrics", "hourly_metrics"], schemas: []); DatabaseModel model = factory.Create(connection, options); - // Verify hypertable with all features DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); @@ -973,7 +956,6 @@ public async Task Should_Scaffold_Full_Configuration() Assert.Equal(true, metricsTable[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("metrics_time_idx", metricsTable[ReorderPolicyAnnotations.IndexName]); - // Verify continuous aggregate DatabaseTable? caggTable = model.Tables.FirstOrDefault(t => t.Name == "hourly_metrics"); Assert.NotNull(caggTable); Assert.Equal("hourly_metrics", caggTable[ContinuousAggregateAnnotations.MaterializedViewName]); @@ -1021,7 +1003,6 @@ public async Task Should_Scaffold_With_Already_Open_Connection() DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); DatabaseModel model = factory.Create(connection, options); - // Connection should remain open Assert.Equal(System.Data.ConnectionState.Open, connection.State); DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); @@ -1049,14 +1030,12 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) [Fact] public async Task Should_Handle_Empty_Database() { - // Don't create any tables - just scaffold an empty database TimescaleDatabaseModelFactory factory = CreateFactory(); await using NpgsqlConnection connection = new(_connectionString); DatabaseModelFactoryOptions options = new(tables: [], schemas: []); DatabaseModel model = factory.Create(connection, options); - // Model should be valid but have no TimescaleDB-specific tables Assert.NotNull(model); } @@ -1084,7 +1063,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasNoKey(); entity.ToTable("Metrics"); entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("86400000"); // 1 day in milliseconds + .WithChunkTimeInterval("86400000"); }); } } @@ -1105,7 +1084,6 @@ public async Task Should_Scaffold_Hypertable_With_Custom_Chunk_Interval() Assert.NotNull(metricsTable); Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); - // Verify chunk interval is extracted object? chunkInterval = metricsTable[HypertableAnnotations.ChunkTimeInterval]; Assert.NotNull(chunkInterval); } @@ -1155,12 +1133,10 @@ public async Task Should_Preserve_Base_Model_Structure() DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); Assert.NotNull(metricsTable); - // Verify columns are preserved Assert.Contains(metricsTable.Columns, c => c.Name == "Timestamp"); Assert.Contains(metricsTable.Columns, c => c.Name == "Name"); Assert.Contains(metricsTable.Columns, c => c.Name == "Value"); - // Verify TimescaleDB annotations are added Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); } @@ -1435,7 +1411,6 @@ public async Task Should_Scaffold_Multiple_Continuous_Aggregates() Assert.Equal("hourly_metrics", hourlyTable[ContinuousAggregateAnnotations.MaterializedViewName]); Assert.Equal("daily_metrics", dailyTable[ContinuousAggregateAnnotations.MaterializedViewName]); - // Both should reference the same source Assert.Equal("Metrics", hourlyTable[ContinuousAggregateAnnotations.ParentName]); Assert.Equal("Metrics", dailyTable[ContinuousAggregateAnnotations.ParentName]); } diff --git a/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs index 43c66f7..538d7f0 100644 --- a/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs +++ b/tests/Eftdb.Tests/Internals/ColumnNameResolverTests.cs @@ -94,7 +94,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [Fact] public void Should_Resolve_Clr_Property_Name_Under_Snake_Case_Convention() { - // Arrange — passing the CLR property name should yield the snake_case column. + // Arrange using SnakeCaseClrContext context = new(); (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics"); @@ -137,8 +137,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [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. + // Arrange using ReverseLookupContext context = new(); (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStoreIdentifier(context, "Metrics"); diff --git a/tests/Eftdb.Tests/Internals/CompressionAnnotationExtractorTests.cs b/tests/Eftdb.Tests/Internals/CompressionAnnotationExtractorTests.cs new file mode 100644 index 0000000..caa9c37 --- /dev/null +++ b/tests/Eftdb.Tests/Internals/CompressionAnnotationExtractorTests.cs @@ -0,0 +1,66 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +/// +/// Tests for . +/// +public class CompressionAnnotationExtractorTests +{ + private static (IEntityType EntityType, StoreObjectIdentifier StoreIdentifier) GetEntityAndStore( + 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); + } + + // ── ExtractSparseIndex: paren-less entry passes through verbatim ────────── + + #region Should_Pass_Through_Paren_Less_Entry_Verbatim + + private class ParenLessEntity + { + public DateTime Ts { get; set; } + public double Value { get; set; } + } + + private class ParenLessContext : 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("annex_paren_less"); + entity.HasAnnotation(HypertableAnnotations.CompressionSparseIndex, "bloom"); + }); + } + } + + [Fact] + public void Should_Pass_Through_Paren_Less_Entry_Verbatim() + { + // Arrange + using ParenLessContext context = new(); + (IEntityType entityType, StoreObjectIdentifier storeIdentifier) = GetEntityAndStore(context, "annex_paren_less"); + + // Act + string? result = CompressionAnnotationExtractor.ExtractSparseIndex(entityType, storeIdentifier); + + // Assert + Assert.Equal("bloom", result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Internals/CompressionPolicyDefaultHelperTests.cs b/tests/Eftdb.Tests/Internals/CompressionPolicyDefaultHelperTests.cs new file mode 100644 index 0000000..d5b59ea --- /dev/null +++ b/tests/Eftdb.Tests/Internals/CompressionPolicyDefaultHelperTests.cs @@ -0,0 +1,611 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.CompressionPolicies; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Internals; + +public class CompressionPolicyDefaultHelperTests +{ + #region Should_Return_12_Hours_When_Chunk_Interval_Is_Exactly_1_Day + + [Fact] + public void Should_Return_12_Hours_When_Chunk_Interval_Is_Exactly_1_Day() + { + // Arrange + string chunkTimeInterval = "1 day"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_12_Hours_When_Chunk_Interval_Is_7_Days + + [Fact] + public void Should_Return_12_Hours_When_Chunk_Interval_Is_7_Days() + { + // Arrange + string chunkTimeInterval = "7 days"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_12_Hours_When_Chunk_Interval_Is_1_Week + + [Fact] + public void Should_Return_12_Hours_When_Chunk_Interval_Is_1_Week() + { + // Arrange + string chunkTimeInterval = "1 week"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_12_Hours_When_Chunk_Interval_Is_Null + + [Fact] + public void Should_Return_12_Hours_When_Chunk_Interval_Is_Null() + { + // Arrange + string? chunkTimeInterval = null; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_12_Hours_When_Chunk_Interval_Is_Empty + + [Fact] + public void Should_Return_12_Hours_When_Chunk_Interval_Is_Empty() + { + // Arrange + string chunkTimeInterval = ""; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_Half_Interval_When_Chunk_Is_4_Hours + + [Fact] + public void Should_Return_Half_Interval_When_Chunk_Is_4_Hours() + { + // Arrange + string chunkTimeInterval = "4 hours"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("2 hours", result); + } + + #endregion + + #region Should_Return_Half_Interval_When_Chunk_Is_1_Hour + + [Fact] + public void Should_Return_Half_Interval_When_Chunk_Is_1_Hour() + { + // Arrange + string chunkTimeInterval = "1 hour"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("30 minutes", result); + } + + #endregion + + #region Should_Return_Half_Interval_When_Chunk_Is_2_Hours + + [Fact] + public void Should_Return_Half_Interval_When_Chunk_Is_2_Hours() + { + // Arrange + string chunkTimeInterval = "2 hours"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 hour", result); + } + + #endregion + + #region Should_Return_Half_Interval_When_Chunk_Is_30_Minutes + + [Fact] + public void Should_Return_Half_Interval_When_Chunk_Is_30_Minutes() + { + // Arrange + string chunkTimeInterval = "30 minutes"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("15 minutes", result); + } + + #endregion + + #region Should_Return_Null_For_Integer_Time_Input + + [Fact] + public void Should_Return_Null_For_Integer_Time_Input() + { + // Arrange + string chunkTimeInterval = "604800000000"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_Calendar_Unit_Months + + [Fact] + public void Should_Return_Null_For_Calendar_Unit_Months() + { + // Arrange + string chunkTimeInterval = "1 month"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_Composite_Interval + + [Fact] + public void Should_Return_Null_For_Composite_Interval() + { + // Arrange + string chunkTimeInterval = "2 days 03:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_12_Hours_For_HH_MM_SS_Form_Of_24_Hours + + [Fact] + public void Should_Return_12_Hours_For_HH_MM_SS_Form_Of_24_Hours() + { + // Arrange + string chunkTimeInterval = "24:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_Half_Interval_For_HH_MM_SS_Form_Of_4_Hours + + [Fact] + public void Should_Return_Half_Interval_For_HH_MM_SS_Form_Of_4_Hours() + { + // Arrange + string chunkTimeInterval = "4:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("2 hours", result); + } + + #endregion + + // ── Sub-day chunk intervals: minute-level half-interval ────────────────── + + #region Should_Return_1_Minute_When_Chunk_Is_2_Minutes + + [Fact] + public void Should_Return_1_Minute_When_Chunk_Is_2_Minutes() + { + // Arrange + string chunkTimeInterval = "2 minutes"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 minute", result); + } + + #endregion + + #region Should_Return_Minutes_When_Chunk_Is_4_Minutes + + [Fact] + public void Should_Return_Minutes_When_Chunk_Is_4_Minutes() + { + // Arrange + string chunkTimeInterval = "4 minutes"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("2 minutes", result); + } + + #endregion + + #region Should_Return_1_Second_When_Chunk_Is_2_Seconds + + [Fact] + public void Should_Return_1_Second_When_Chunk_Is_2_Seconds() + { + // Arrange + string chunkTimeInterval = "2 seconds"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 second", result); + } + + #endregion + + #region Should_Return_Seconds_When_Chunk_Is_4_Seconds + + [Fact] + public void Should_Return_Seconds_When_Chunk_Is_4_Seconds() + { + // Arrange + string chunkTimeInterval = "4 seconds"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("2 seconds", result); + } + + #endregion + + #region Should_Return_Null_When_Half_Interval_Is_Not_Representable + + [Fact] + public void Should_Return_Null_When_Half_Interval_Is_Not_Representable() + { + // Arrange + string chunkTimeInterval = "1 us"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_When_Half_Interval_Is_Sub_Second_Non_Zero + + [Fact] + public void Should_Return_Null_When_Half_Interval_Is_Sub_Second_Non_Zero() + { + // Arrange + string chunkTimeInterval = "1 second"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_When_Half_Interval_Is_500ms + + [Fact] + public void Should_Return_Null_When_Half_Interval_Is_500ms() + { + // Arrange + string chunkTimeInterval = "500 ms"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + // ── Sub-day unit aliases ────────────────────────────────────────────────── + + #region Should_Parse_Millisecond_Unit_Aliases + + [Fact] + public void Should_Parse_Millisecond_Unit_Aliases() + { + // Arrange + string chunkTimeInterval = "2000 ms"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 second", result); + } + + #endregion + + #region Should_Parse_Microsecond_Unit_Aliases + + [Fact] + public void Should_Parse_Microsecond_Unit_Aliases() + { + // Arrange + string chunkTimeInterval = "2000000 us"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 second", result); + } + + #endregion + + // ── HH:MM:SS with fractional seconds ───────────────────────────────────── + + #region Should_Parse_HH_MM_SS_With_Fractional_Seconds + + [Fact] + public void Should_Parse_HH_MM_SS_With_Fractional_Seconds() + { + // Arrange + string chunkTimeInterval = "00:00:02.000000"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("1 second", result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Invalid_Fraction + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Invalid_Fraction() + { + // Arrange + string chunkTimeInterval = "00:00:02.abc"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Empty_Fraction + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Empty_Fraction() + { + // Arrange + string chunkTimeInterval = "00:00:02."; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + // ── HH:MM:SS form with D.HH prefix ─────────────────────────────────────── + + #region Should_Parse_Day_Dot_HH_MM_SS_Form + + [Fact] + public void Should_Parse_Day_Dot_HH_MM_SS_Form() + { + // Arrange + string chunkTimeInterval = "1.00:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Equal("12 hours", result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Negative_Days + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Negative_Days() + { + // Arrange + string chunkTimeInterval = "-1.00:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Hours_Out_Of_Range + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Hours_Out_Of_Range() + { + // Arrange + string chunkTimeInterval = "1.24:00:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Minutes_Out_Of_Range + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Minutes_Out_Of_Range() + { + // Arrange + string chunkTimeInterval = "00:60:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Seconds_Out_Of_Range + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Seconds_Out_Of_Range() + { + // Arrange + string chunkTimeInterval = "00:00:60"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_HH_MM_SS_With_Wrong_Part_Count + + [Fact] + public void Should_Return_Null_For_HH_MM_SS_With_Wrong_Part_Count() + { + // Arrange + string chunkTimeInterval = "04:00"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + // ── TryGetTotalMicroseconds: parse failures ─────────────────────────────── + + #region Should_Return_Null_For_Non_Numeric_Amount + + [Fact] + public void Should_Return_Null_For_Non_Numeric_Amount() + { + // Arrange + string chunkTimeInterval = "abc days"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_Unknown_Unit + + [Fact] + public void Should_Return_Null_For_Unknown_Unit() + { + // Arrange + string chunkTimeInterval = "2 fortnights"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Should_Return_Null_For_Missing_Space_In_Interval + + [Fact] + public void Should_Return_Null_For_Missing_Space_In_Interval() + { + // Arrange + string chunkTimeInterval = "7days"; + + // Act + string? result = CompressionPolicyDefaultHelper.ComputeDefaultScheduleInterval(chunkTimeInterval); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs index 1d7f3ae..d01ea37 100644 --- a/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs +++ b/tests/Eftdb.Tests/Internals/FeatureDiffContextTests.cs @@ -141,10 +141,6 @@ public void ResolveColumn_Returns_Identity_When_No_Rename() [Fact] public void ResolveColumn_Keys_On_Post_Rename_Table_Name() { - // Per the XML remark, the column-rename map is keyed by the POST-rename table name because EF Core - // emits RenameColumnOperation against the already-renamed table. Resolving with the new table name - // succeeds; resolving with the old table name does not. - // Arrange FeatureDiffContext context = new() { @@ -160,7 +156,7 @@ public void ResolveColumn_Keys_On_Post_Rename_Table_Name() // Assert Assert.Equal("new_value", resolvedWithNewTable); - Assert.Equal("old_value", resolvedWithOldTable); // identity: old table name is not a key + Assert.Equal("old_value", resolvedWithOldTable); } #endregion @@ -196,10 +192,6 @@ public void Empty_Has_No_Recreated_Aggregates() [Fact] public void ResolveTable_Requires_Normalized_Schema_Key() { - // The maps store concrete (normalized) schemas. Callers must normalize a missing schema to - // DefaultValues.DefaultSchema before building/querying. This test documents that resolving with the - // normalized schema key works, while a null/blank key misses the entry and resolves to identity. - // Arrange FeatureDiffContext context = new() { @@ -215,7 +207,7 @@ public void ResolveTable_Requires_Normalized_Schema_Key() // Assert Assert.Equal((DefaultValues.DefaultSchema, "new_metrics"), resolvedWithNormalizedKey); - Assert.Equal(("", "old_metrics"), resolvedWithBlankKey); // blank schema is not the stored key -> identity + Assert.Equal(("", "old_metrics"), resolvedWithBlankKey); } #endregion diff --git a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs index 53df2c7..d6a1bb4 100644 --- a/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs +++ b/tests/Eftdb.Tests/Internals/OperationOrderingTests.cs @@ -1,4 +1,5 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; @@ -542,4 +543,249 @@ public void Should_Order_CreateTable_Before_CreateHypertable() } #endregion + + #region Should_Order_DropCompressionPolicy_Before_DropContinuousAggregate + + private class OrderMetricDropCp + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderAggDropCp + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class DropCpSourceContext : 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.ToTable("order_metric_drop_cp"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).EnableCompression(); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_agg_drop_cp", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(); + entity.WithCompressionPolicy(after: "7 days"); + }); + } + } + + private class DropCpTargetContext : 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.ToTable("order_metric_drop_cp"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp).EnableCompression(); + }); + } + } + + [Fact] + public void Should_Order_DropCompressionPolicy_Before_DropContinuousAggregate() + { + // Arrange + using DropCpSourceContext sourceContext = new(); + using DropCpTargetContext targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + int dropCpIndex = operations.FindIndex(op => op is DropCompressionPolicyOperation); + int dropAggIndex = operations.FindIndex(op => op is DropContinuousAggregateOperation); + + Assert.NotEqual(-1, dropCpIndex); + Assert.NotEqual(-1, dropAggIndex); + Assert.True(dropCpIndex < dropAggIndex, + $"DropCompressionPolicy (priority -45) should precede DropContinuousAggregate (priority -40), " + + $"but found indices {dropCpIndex} and {dropAggIndex}."); + } + + #endregion + + #region Should_Order_DropReorderPolicy_Before_DropTable + + private class OrderMetricDropRp + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropRpSourceContext : 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.ToTable("order_metric_drop_rp"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("order_metric_drop_rp_idx"); + }); + } + } + + private class DropRpTargetContext : 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.ToTable("order_metric_drop_rp"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Order_DropReorderPolicy_Before_DropTable() + { + // Arrange + using DropRpSourceContext sourceContext = new(); + using DropRpTargetContext targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + int dropRpIndex = operations.FindIndex(op => op is DropReorderPolicyOperation); + Assert.NotEqual(-1, dropRpIndex); + } + + #endregion + + #region Should_Order_AlterContinuousAggregate_After_CreateContinuousAggregate + + private class OrderMetricAlterCagg + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderAggAlterCaggSource + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class OrderAggAlterCaggSourceContext : 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.ToTable("order_metric_alter_cagg"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_agg_alter_cagg", + "1 hour", + x => x.Timestamp, + chunkInterval: "7 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + private class OrderAggAlterCaggTargetContext : 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.ToTable("order_metric_alter_cagg"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "order_agg_alter_cagg", + "1 hour", + x => x.Timestamp, + chunkInterval: "30 days") + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg); + }); + } + } + + [Fact] + public void Should_Order_AlterContinuousAggregate_After_CreateContinuousAggregate() + { + // Arrange + using OrderAggAlterCaggSourceContext sourceContext = new(); + using OrderAggAlterCaggTargetContext targetContext = new(); + + // Act + List operations = [.. GenerateMigrationOperations(sourceContext, targetContext)]; + + // Assert + int alterAggIndex = operations.FindIndex(op => op is AlterContinuousAggregateOperation); + Assert.NotEqual(-1, alterAggIndex); + } + + #endregion } diff --git a/tests/Eftdb.Tests/MigrationExtensions/CompressionPolicyMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/CompressionPolicyMigrationExtensionsTests.cs new file mode 100644 index 0000000..8bf3bf8 --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/CompressionPolicyMigrationExtensionsTests.cs @@ -0,0 +1,200 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions; + +public class CompressionPolicyMigrationExtensionsTests +{ + #region AddCompressionPolicy_WithAfter_MapsArguments + + [Fact] + public void AddCompressionPolicy_WithAfter_MapsArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime start = new(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + mb.AddCompressionPolicy( + tableName: "sensor_data", + schema: "public", + after: "7 days", + scheduleInterval: "12 hours", + initialStart: start, + timezone: "Europe/Berlin", + ifNotExists: true); + + // Assert + AddCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + Assert.Equal("7 days", op.After); + Assert.Null(op.CreatedBefore); + Assert.Equal("12 hours", op.ScheduleInterval); + Assert.Equal(start, op.InitialStart); + Assert.Equal("Europe/Berlin", op.Timezone); + Assert.True(op.IfNotExists); + } + + #endregion + + #region AddCompressionPolicy_WithCreatedBefore_MapsArguments + + [Fact] + public void AddCompressionPolicy_WithCreatedBefore_MapsArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AddCompressionPolicy(tableName: "sensor_data", createdBefore: "30 days"); + + // Assert + AddCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("30 days", op.CreatedBefore); + Assert.Null(op.After); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + + #region AddCompressionPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void AddCompressionPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AddCompressionPolicy(tableName: "sensor_data", schema: null, after: "7 days"); + + // Assert + AddCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + + #region AlterCompressionPolicy_MapsCurrentAndOldArguments + + [Fact] + public void AlterCompressionPolicy_MapsCurrentAndOldArguments() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + DateTime newStart = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime oldStart = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + mb.AlterCompressionPolicy( + tableName: "sensor_data", + schema: "public", + after: "14 days", + scheduleInterval: "6 hours", + initialStart: newStart, + timezone: "UTC", + ifNotExists: true, + oldAfter: "7 days", + oldScheduleInterval: "12 hours", + oldInitialStart: oldStart, + oldTimezone: "Europe/Berlin", + oldIfNotExists: false); + + // Assert + AlterCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("14 days", op.After); + Assert.Null(op.CreatedBefore); + Assert.Equal("6 hours", op.ScheduleInterval); + Assert.Equal(newStart, op.InitialStart); + Assert.Equal("UTC", op.Timezone); + Assert.True(op.IfNotExists); + Assert.Equal("7 days", op.OldAfter); + Assert.Null(op.OldCreatedBefore); + Assert.Equal("12 hours", op.OldScheduleInterval); + Assert.Equal(oldStart, op.OldInitialStart); + Assert.Equal("Europe/Berlin", op.OldTimezone); + Assert.False(op.OldIfNotExists); + } + + #endregion + + #region AlterCompressionPolicy_Switches_From_After_To_CreatedBefore + + [Fact] + public void AlterCompressionPolicy_Switches_From_After_To_CreatedBefore() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterCompressionPolicy( + tableName: "sensor_data", + createdBefore: "30 days", + oldAfter: "7 days"); + + // Assert + AlterCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Null(op.After); + Assert.Equal("30 days", op.CreatedBefore); + Assert.Equal("7 days", op.OldAfter); + Assert.Null(op.OldCreatedBefore); + } + + #endregion + + #region AlterCompressionPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void AlterCompressionPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.AlterCompressionPolicy(tableName: "sensor_data", schema: null, after: "14 days"); + + // Assert + AlterCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion + + #region DropCompressionPolicy_MapsTableAndSchema + + [Fact] + public void DropCompressionPolicy_MapsTableAndSchema() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropCompressionPolicy(tableName: "sensor_data", schema: "public"); + + // Assert + DropCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal("sensor_data", op.TableName); + Assert.Equal("public", op.Schema); + } + + #endregion + + #region DropCompressionPolicy_NullSchema_CoalescesToEmpty + + [Fact] + public void DropCompressionPolicy_NullSchema_CoalescesToEmpty() + { + // Arrange + MigrationBuilder mb = new(activeProvider: null); + + // Act + mb.DropCompressionPolicy(tableName: "sensor_data", schema: null); + + // Assert + DropCompressionPolicyOperation op = Assert.IsType(Assert.Single(mb.Operations)); + Assert.Equal(string.Empty, op.Schema); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateCompressionMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateCompressionMigrationExtensionsTests.cs new file mode 100644 index 0000000..df0c7dc --- /dev/null +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregateCompressionMigrationExtensionsTests.cs @@ -0,0 +1,205 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.MigrationExtensions; + +public class ContinuousAggregateCompressionMigrationExtensionsTests +{ + private static MigrationBuilder CreateBuilder() => new("Npgsql"); + + // ── CreateContinuousAggregate ───────────────────────────────────────────── + + #region CreateContinuousAggregate_EnableCompression_MapsToOperation + + [Fact] + public void CreateContinuousAggregate_EnableCompression_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.CreateContinuousAggregate( + "comp_cagg", + "metrics", + enableCompression: true); + + // Assert + CreateContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.True(op.EnableCompression); + } + + #endregion + + #region CreateContinuousAggregate_CompressionSegmentBy_MapsToOperation + + [Fact] + public void CreateContinuousAggregate_CompressionSegmentBy_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.CreateContinuousAggregate( + "seg_cagg", + "metrics", + enableCompression: true, + compressionSegmentBy: ["device_id", "region"]); + + // Assert + CreateContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.Equal(["device_id", "region"], op.CompressionSegmentBy); + } + + #endregion + + #region CreateContinuousAggregate_CompressionOrderBy_MapsToOperation + + [Fact] + public void CreateContinuousAggregate_CompressionOrderBy_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.CreateContinuousAggregate( + "ord_cagg", + "metrics", + enableCompression: true, + compressionOrderBy: ["time DESC"]); + + // Assert + CreateContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.Equal(["time DESC"], op.CompressionOrderBy); + } + + #endregion + + #region CreateContinuousAggregate_NoCompression_DefaultsToFalse + + [Fact] + public void CreateContinuousAggregate_NoCompression_DefaultsToFalse() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.CreateContinuousAggregate("plain_cagg", "metrics"); + + // Assert + CreateContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.False(op.EnableCompression); + Assert.Null(op.CompressionSegmentBy); + Assert.Null(op.CompressionOrderBy); + } + + #endregion + + // ── AlterContinuousAggregate ────────────────────────────────────────────── + + #region AlterContinuousAggregate_EnableCompression_MapsToOperation + + [Fact] + public void AlterContinuousAggregate_EnableCompression_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.AlterContinuousAggregate( + "alter_comp_cagg", + enableCompression: true, + oldEnableCompression: false); + + // Assert + AlterContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.True(op.EnableCompression); + Assert.False(op.OldEnableCompression); + } + + #endregion + + #region AlterContinuousAggregate_CompressionSegmentBy_MapsToOperation + + [Fact] + public void AlterContinuousAggregate_CompressionSegmentBy_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.AlterContinuousAggregate( + "alter_seg_cagg", + compressionSegmentBy: ["device_id"], + oldCompressionSegmentBy: ["region"]); + + // Assert + AlterContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.Equal(["device_id"], op.CompressionSegmentBy); + Assert.Equal(["region"], op.OldCompressionSegmentBy); + } + + #endregion + + #region AlterContinuousAggregate_CompressionOrderBy_MapsToOperation + + [Fact] + public void AlterContinuousAggregate_CompressionOrderBy_MapsToOperation() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.AlterContinuousAggregate( + "alter_ord_cagg", + compressionOrderBy: ["bucket DESC"], + oldCompressionOrderBy: ["bucket ASC"]); + + // Assert + AlterContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.Equal(["bucket DESC"], op.CompressionOrderBy); + Assert.Equal(["bucket ASC"], op.OldCompressionOrderBy); + } + + #endregion + + #region AlterContinuousAggregate_AllCompressionParams_MappedCorrectly + + [Fact] + public void AlterContinuousAggregate_AllCompressionParams_MappedCorrectly() + { + // Arrange + MigrationBuilder builder = CreateBuilder(); + + // Act + builder.AlterContinuousAggregate( + "full_alter_cagg", + schema: "analytics", + enableCompression: true, + compressionSegmentBy: ["device_id"], + compressionOrderBy: ["time DESC"], + oldEnableCompression: false, + oldCompressionSegmentBy: null, + oldCompressionOrderBy: null); + + // Assert + AlterContinuousAggregateOperation op = Assert.Single( + builder.Operations.OfType()); + Assert.Equal("full_alter_cagg", op.MaterializedViewName); + Assert.Equal("analytics", op.Schema); + Assert.True(op.EnableCompression); + Assert.Equal(["device_id"], op.CompressionSegmentBy); + Assert.Equal(["time DESC"], op.CompressionOrderBy); + Assert.False(op.OldEnableCompression); + Assert.Null(op.OldCompressionSegmentBy); + Assert.Null(op.OldCompressionOrderBy); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs index b41714e..bb6aad5 100644 --- a/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs +++ b/tests/Eftdb.Tests/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensionsTests.cs @@ -16,7 +16,7 @@ public void AddContinuousAggregatePolicy_DefaultValues_MapExactly() // Arrange MigrationBuilder mb = new(activeProvider: null); - // Act — only required argument; everything else falls to the signature defaults. + // Act mb.AddContinuousAggregatePolicy(materializedViewName: "hourly"); // Assert diff --git a/tests/Eftdb.Tests/Scaffolding/CompressionPolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/CompressionPolicyAnnotationApplierTests.cs new file mode 100644 index 0000000..395c281 --- /dev/null +++ b/tests/Eftdb.Tests/Scaffolding/CompressionPolicyAnnotationApplierTests.cs @@ -0,0 +1,365 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.CompressionPolicyScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class CompressionPolicyAnnotationApplierTests +{ + private readonly CompressionPolicyAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestTable", string schema = "public") + => new() { Name = name, Schema = schema }; + + // ── Guard ────────────────────────────────────────────────────────────────── + + #region Should_Throw_ArgumentException_For_Invalid_Info_Type + + [Fact] + public void Should_Throw_ArgumentException_For_Invalid_Info_Type() + { + // Arrange + DatabaseTable table = CreateTable(); + object invalidInfo = new { After = "7 days" }; + + // Act & Assert + ArgumentException ex = Assert.Throws( + () => _applier.ApplyAnnotations(table, invalidInfo)); + + Assert.Equal("featureInfo", ex.ParamName); + Assert.Contains("CompressionPolicyInfo", ex.Message); + } + + #endregion + + // ── HasCompressionPolicy ─────────────────────────────────────────────────── + + #region Should_Set_HasCompressionPolicy_True + + [Fact] + public void Should_Set_HasCompressionPolicy_True() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[CompressionPolicyAnnotations.HasCompressionPolicy]); + } + + #endregion + + // ── After ───────────────────────────────────────────────────────────────── + + #region Should_Apply_After_When_Set + + [Fact] + public void Should_Apply_After_When_Set() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("7 days", table[CompressionPolicyAnnotations.After]); + } + + #endregion + + #region Should_Not_Apply_After_When_Null + + [Fact] + public void Should_Not_Apply_After_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: null, + CreatedBefore: "30 days", + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[CompressionPolicyAnnotations.After]); + } + + #endregion + + // ── CreatedBefore ───────────────────────────────────────────────────────── + + #region Should_Apply_CreatedBefore_When_Set + + [Fact] + public void Should_Apply_CreatedBefore_When_Set() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: null, + CreatedBefore: "30 days", + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("30 days", table[CompressionPolicyAnnotations.CreatedBefore]); + } + + #endregion + + // ── InitialStart ────────────────────────────────────────────────────────── + + #region Should_Apply_InitialStart_When_Set + + [Fact] + public void Should_Apply_InitialStart_When_Set() + { + // Arrange + DatabaseTable table = CreateTable(); + DateTime start = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: start, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(start, table[CompressionPolicyAnnotations.InitialStart]); + } + + #endregion + + #region Should_Not_Apply_InitialStart_When_Null + + [Fact] + public void Should_Not_Apply_InitialStart_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[CompressionPolicyAnnotations.InitialStart]); + } + + #endregion + + // ── ScheduleInterval ────────────────────────────────────────────────────── + + #region Should_Apply_ScheduleInterval_When_Differs_From_Computed_Default + + [Fact] + public void Should_Apply_ScheduleInterval_When_Differs_From_Computed_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + table[HypertableAnnotations.ChunkTimeInterval] = "7 days"; + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: "6 hours", + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("6 hours", table[CompressionPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Not_Apply_ScheduleInterval_When_Matches_Computed_Default + + [Fact] + public void Should_Not_Apply_ScheduleInterval_When_Matches_Computed_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + table[HypertableAnnotations.ChunkTimeInterval] = "7 days"; + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: "12 hours", + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[CompressionPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + // ── Timezone ────────────────────────────────────────────────────────────── + + #region Should_Apply_Timezone_When_Set + + [Fact] + public void Should_Apply_Timezone_When_Set() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: "Europe/Berlin", + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("Europe/Berlin", table[CompressionPolicyAnnotations.Timezone]); + } + + #endregion + + #region Should_Not_Apply_Timezone_When_Null + + [Fact] + public void Should_Not_Apply_Timezone_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[CompressionPolicyAnnotations.Timezone]); + } + + #endregion + + // ── IfNotExists ─────────────────────────────────────────────────────────── + + #region Should_Apply_IfNotExists_When_True + + [Fact] + public void Should_Apply_IfNotExists_When_True() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: true); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[CompressionPolicyAnnotations.IfNotExists]); + } + + #endregion + + #region Should_Apply_IfNotExists_When_False + + [Fact] + public void Should_Apply_IfNotExists_When_False() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: false); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(false, table[CompressionPolicyAnnotations.IfNotExists]); + } + + #endregion + + #region Should_Not_Apply_IfNotExists_When_Null + + [Fact] + public void Should_Not_Apply_IfNotExists_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + CompressionPolicyInfo info = new( + After: "7 days", + CreatedBefore: null, + InitialStart: null, + ScheduleInterval: null, + Timezone: null, + IfNotExists: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[CompressionPolicyAnnotations.IfNotExists]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Scaffolding/CompressionSettingsScaffoldingHelperTests.cs b/tests/Eftdb.Tests/Scaffolding/CompressionSettingsScaffoldingHelperTests.cs new file mode 100644 index 0000000..83a7239 --- /dev/null +++ b/tests/Eftdb.Tests/Scaffolding/CompressionSettingsScaffoldingHelperTests.cs @@ -0,0 +1,198 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class CompressionSettingsScaffoldingHelperTests +{ + // ── Unquoted column, no direction → defaults to ASC ── + + #region Should_Parse_Unquoted_Column_With_No_Direction_As_Asc + + [Fact] + public void Should_Parse_Unquoted_Column_With_No_Direction_As_Asc() + { + // Arrange + string token = "ts"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("ts ASC", result); + } + + #endregion + + // ── Unquoted column with explicit DESC ── + + #region Should_Parse_Unquoted_Column_With_Desc + + [Fact] + public void Should_Parse_Unquoted_Column_With_Desc() + { + // Arrange + string token = "ts DESC"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("ts DESC", result); + } + + #endregion + + // ── Quoted mixed-case column with DESC ── + + #region Should_Parse_Quoted_MixedCase_Column_With_Desc + + [Fact] + public void Should_Parse_Quoted_MixedCase_Column_With_Desc() + { + // Arrange + string token = "\"Timestamp\" DESC"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("Timestamp DESC", result); + } + + #endregion + + // ── Quoted mixed-case column with DESC NULLS LAST (non-default for DESC) ── + + #region Should_Parse_Quoted_Column_With_Desc_Nulls_Last + + [Fact] + public void Should_Parse_Quoted_Column_With_Desc_Nulls_Last() + { + // Arrange + string token = "\"Timestamp\" DESC NULLS LAST"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("Timestamp DESC NULLS LAST", result); + } + + #endregion + + // ── Doubled-quote escape inside identifier ── + + #region Should_Unescape_Doubled_Quote_In_Identifier + + [Fact] + public void Should_Unescape_Doubled_Quote_In_Identifier() + { + // Arrange + string token = "\"It\"\"s\" DESC"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("It\"s DESC", result); + } + + #endregion + + // ── Bare column name with no suffix at all ── + + #region Should_Parse_Bare_Column_With_No_Suffix + + [Fact] + public void Should_Parse_Bare_Column_With_No_Suffix() + { + // Arrange + string token = "value"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("value ASC", result); + } + + #endregion + + // ── Empty string token ── + + #region Should_Parse_Empty_Token_As_Asc + + [Fact] + public void Should_Parse_Empty_Token_As_Asc() + { + // Arrange + string token = string.Empty; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal(" ASC", result); + } + + #endregion + + // ── Quoted column with ASC (explicit direction) ── + + #region Should_Parse_Quoted_Column_With_Explicit_Asc + + [Fact] + public void Should_Parse_Quoted_Column_With_Explicit_Asc() + { + // Arrange + string token = "\"DeviceId\" ASC"; + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal("DeviceId ASC", result); + } + + #endregion + + // ── Output matches BuildOrderByEntry for ASC column (canonical equivalence) ── + + #region Should_Produce_Same_Output_As_BuildOrderByEntry_For_Asc + + [Fact] + public void Should_Produce_Same_Output_As_BuildOrderByEntry_For_Asc() + { + // Arrange + string token = "\"MyColumn\" ASC"; + string expected = CompressionSettingsScaffoldingHelper.BuildOrderByEntry("MyColumn", isAscending: true, isNullsFirst: false); + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + // ── Output matches BuildOrderByEntry for DESC column (canonical equivalence) ── + + #region Should_Produce_Same_Output_As_BuildOrderByEntry_For_Desc + + [Fact] + public void Should_Produce_Same_Output_As_BuildOrderByEntry_For_Desc() + { + // Arrange + string token = "\"MyColumn\" DESC"; + string expected = CompressionSettingsScaffoldingHelper.BuildOrderByEntry("MyColumn", isAscending: false, isNullsFirst: true); + + // Act + string result = CompressionSettingsScaffoldingHelper.ParseColumnstoreOrderByToken(token); + + // Assert + Assert.Equal(expected, result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs index b5c5d6e..438fec5 100644 --- a/tests/Eftdb.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/ContinuousAggregateAnnotationApplierTests.cs @@ -34,13 +34,11 @@ public void Should_Apply_Minimal_ContinuousAggregate_Annotations() // Act _applier.ApplyAnnotations(table, info); - // Assert - verify all mandatory annotations are set + // Assert Assert.Equal("hourly_metrics", table[ContinuousAggregateAnnotations.MaterializedViewName]); Assert.Equal("metrics", table[ContinuousAggregateAnnotations.ParentName]); Assert.Equal(false, table[ContinuousAggregateAnnotations.MaterializedOnly]); Assert.Equal("SELECT time_bucket('1 hour', timestamp) AS bucket, COUNT(*) FROM metrics GROUP BY 1", table["TimescaleDB:ViewDefinition"]); - - // ChunkInterval should NOT be set when null Assert.Null(table[ContinuousAggregateAnnotations.ChunkInterval]); } @@ -323,7 +321,7 @@ FROM transactions // Act _applier.ApplyAnnotations(table, info); - // Assert - verify ALL annotations are applied + // Assert Assert.Equal("daily_summary", table[ContinuousAggregateAnnotations.MaterializedViewName]); Assert.Equal("transactions", table[ContinuousAggregateAnnotations.ParentName]); Assert.Equal(true, table[ContinuousAggregateAnnotations.MaterializedOnly]); @@ -410,12 +408,10 @@ public void Should_Preserve_Existing_Table_Properties() // Act _applier.ApplyAnnotations(table, info); - // Assert - table properties should be preserved + // Assert Assert.Equal("existing_view", table.Name); Assert.Equal("custom_schema", table.Schema); Assert.Equal("Pre-existing comment", table.Comment); - - // And annotations should still be applied Assert.Equal("hourly_metrics", table[ContinuousAggregateAnnotations.MaterializedViewName]); } @@ -477,7 +473,7 @@ public void Should_Handle_SourceHypertableName_From_Different_Schema() // Act _applier.ApplyAnnotations(table, info); - // Assert - ParentName should be the table name, not including schema + // Assert Assert.Equal("raw_events", table[ContinuousAggregateAnnotations.ParentName]); } diff --git a/tests/Eftdb.Tests/Scaffolding/ContinuousAggregatePolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/ContinuousAggregatePolicyAnnotationApplierTests.cs index 888fecb..aff92fe 100644 --- a/tests/Eftdb.Tests/Scaffolding/ContinuousAggregatePolicyAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/ContinuousAggregatePolicyAnnotationApplierTests.cs @@ -276,7 +276,7 @@ public void Should_Not_Apply_BucketsPerBatch_When_Default() ScheduleInterval: "1 hour", InitialStart: null, IncludeTieredData: null, - BucketsPerBatch: 1, // default value + BucketsPerBatch: 1, MaxBatchesPerExecution: null, RefreshNewestFirst: null ); @@ -328,7 +328,7 @@ public void Should_Not_Apply_MaxBatchesPerExecution_When_Default() InitialStart: null, IncludeTieredData: null, BucketsPerBatch: null, - MaxBatchesPerExecution: 0, // default value + MaxBatchesPerExecution: 0, RefreshNewestFirst: null ); @@ -380,7 +380,7 @@ public void Should_Not_Apply_RefreshNewestFirst_When_Default() IncludeTieredData: null, BucketsPerBatch: null, MaxBatchesPerExecution: null, - RefreshNewestFirst: true // default value + RefreshNewestFirst: true ); _applier.ApplyAnnotations(table, info); @@ -459,9 +459,9 @@ public void Should_Apply_Only_Non_Default_Annotations() ScheduleInterval: "1 hour", InitialStart: null, IncludeTieredData: null, - BucketsPerBatch: 1, // default - should NOT be applied - MaxBatchesPerExecution: 0, // default - should NOT be applied - RefreshNewestFirst: true // default - should NOT be applied + BucketsPerBatch: 1, + MaxBatchesPerExecution: 0, + RefreshNewestFirst: true ); _applier.ApplyAnnotations(table, info); diff --git a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs index b5d3421..097ba01 100644 --- a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs @@ -30,19 +30,19 @@ public void Should_Apply_Minimal_Hypertable_Annotations() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act _applier.ApplyAnnotations(table, info); - // Assert - verify all annotations are set correctly + // Assert Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); Assert.Equal("Timestamp", table[HypertableAnnotations.HypertableTimeColumn]); Assert.Equal("604800000000", table[HypertableAnnotations.ChunkTimeInterval]); Assert.Equal(false, table[HypertableAnnotations.EnableCompression]); - - // ChunkSkipColumns and AdditionalDimensions should NOT be set when empty Assert.Null(table[HypertableAnnotations.ChunkSkipColumns]); Assert.Null(table[HypertableAnnotations.AdditionalDimensions]); } @@ -63,7 +63,9 @@ public void Should_Apply_TimeColumn_Annotation() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -89,7 +91,9 @@ public void Should_Apply_ChunkTimeInterval_Annotation() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -115,7 +119,9 @@ public void Should_Apply_Compression_Enabled_True() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -141,7 +147,9 @@ public void Should_Apply_Compression_Enabled_False() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -164,10 +172,12 @@ public void Should_Apply_CompressionSegmentBy() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: true, - CompressionSegmentBy: ["TenantId", "DeviceId"], // Set segment columns + CompressionSegmentBy: ["TenantId", "DeviceId"], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -175,11 +185,7 @@ public void Should_Apply_CompressionSegmentBy() // Assert Assert.NotNull(table[HypertableAnnotations.CompressionSegmentBy]); - - // Expect comma+space separated string Assert.Equal("TenantId, DeviceId", table[HypertableAnnotations.CompressionSegmentBy]); - - // Ensure compression enabled is passed through Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); } @@ -197,9 +203,11 @@ public void Should_Apply_CompressionOrderBy() ChunkTimeInterval: "604800000000", CompressionEnabled: true, CompressionSegmentBy: [], - CompressionOrderBy: ["Timestamp DESC", "Value ASC NULLS LAST"], // Set order rules + CompressionOrderBy: ["Timestamp DESC", "Value ASC NULLS LAST"], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -207,8 +215,6 @@ public void Should_Apply_CompressionOrderBy() // Assert Assert.NotNull(table[HypertableAnnotations.CompressionOrderBy]); - - // Expect comma+space separated string Assert.Equal("Timestamp DESC, Value ASC NULLS LAST", table[HypertableAnnotations.CompressionOrderBy]); } @@ -227,8 +233,10 @@ public void Should_Apply_Full_Compression_Configuration() CompressionEnabled: true, CompressionSegmentBy: ["DeviceId"], CompressionOrderBy: ["Timestamp DESC"], - ChunkSkipColumns: ["DeviceId"], // Chunk skipping often overlaps with segment by - AdditionalDimensions: [] + ChunkSkipColumns: ["DeviceId"], + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -257,14 +265,15 @@ public void Should_Not_Apply_Compression_Annotations_When_Lists_Empty() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act _applier.ApplyAnnotations(table, info); // Assert - // Compression is enabled, but specific segment/order annotations should be null Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); Assert.Null(table[HypertableAnnotations.CompressionSegmentBy]); Assert.Null(table[HypertableAnnotations.CompressionOrderBy]); @@ -286,7 +295,9 @@ public void Should_Apply_Single_ChunkSkipColumn() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: ["DeviceId"], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -313,7 +324,9 @@ public void Should_Apply_Multiple_ChunkSkipColumns() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: ["DeviceId", "Location", "SensorType"], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -340,7 +353,9 @@ public void Should_Not_Apply_ChunkSkipColumns_When_Empty() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -367,7 +382,9 @@ public void Should_Apply_Single_Hash_Dimension() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [hashDimension] + AdditionalDimensions: [hashDimension], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -406,7 +423,9 @@ public void Should_Apply_Single_Range_Dimension() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [rangeDimension] + AdditionalDimensions: [rangeDimension], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -446,7 +465,9 @@ public void Should_Apply_Multiple_Dimensions() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [hashDimension, rangeDimension] + AdditionalDimensions: [hashDimension, rangeDimension], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -461,12 +482,10 @@ public void Should_Apply_Multiple_Dimensions() Assert.NotNull(dimensions); Assert.Equal(2, dimensions.Count); - // First dimension (hash) Assert.Equal("DeviceId", dimensions[0].ColumnName); Assert.Equal(EDimensionType.Hash, dimensions[0].Type); Assert.Equal(8, dimensions[0].NumberOfPartitions); - // Second dimension (range) Assert.Equal("Region", dimensions[1].ColumnName); Assert.Equal(EDimensionType.Range, dimensions[1].Type); Assert.Equal("86400000000", dimensions[1].Interval); @@ -488,7 +507,9 @@ public void Should_Not_Apply_AdditionalDimensions_When_Empty() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -516,20 +537,21 @@ public void Should_Apply_All_Annotations_For_Fully_Configured_Hypertable() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: ["device_id", "sensor_type", "region_code"], - AdditionalDimensions: [hashDimension, rangeDimension] + AdditionalDimensions: [hashDimension, rangeDimension], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act _applier.ApplyAnnotations(table, info); - // Assert - verify ALL annotations are applied + // Assert Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); Assert.Equal("recorded_at", table[HypertableAnnotations.HypertableTimeColumn]); Assert.Equal("86400000000", table[HypertableAnnotations.ChunkTimeInterval]); Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); Assert.Equal("device_id,sensor_type,region_code", table[HypertableAnnotations.ChunkSkipColumns]); - // Verify dimensions JSON string? dimensionsJson = table[HypertableAnnotations.AdditionalDimensions] as string; Assert.NotNull(dimensionsJson); List? dimensions = JsonSerializer.Deserialize>(dimensionsJson); @@ -614,13 +636,15 @@ public void Should_Apply_IsHypertable_Always_True() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act _applier.ApplyAnnotations(table, info); - // Assert - IsHypertable should always be set to true + // Assert object? value = table[HypertableAnnotations.IsHypertable]; Assert.NotNull(value); Assert.IsType(value); @@ -644,18 +668,18 @@ public void Should_Preserve_Existing_Table_Properties() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act _applier.ApplyAnnotations(table, info); - // Assert - table properties should be preserved + // Assert Assert.Equal("PreservedTable", table.Name); Assert.Equal("custom_schema", table.Schema); Assert.Equal("This is a test table", table.Comment); - - // And annotations should still be applied Assert.Equal(true, table[HypertableAnnotations.IsHypertable]); Assert.Equal("Timestamp", table[HypertableAnnotations.HypertableTimeColumn]); } @@ -676,7 +700,9 @@ public void Should_Handle_Special_Characters_In_Column_Names() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: ["device_id", "sensor_type_v2"], - AdditionalDimensions: [] + AdditionalDimensions: [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); // Act @@ -698,7 +724,9 @@ public void Should_Handle_Special_Characters_In_Column_Names() CompressionSegmentBy: [], CompressionOrderBy: [], ChunkSkipColumns: [], - AdditionalDimensions: dimensions ?? [] + AdditionalDimensions: dimensions ?? [], + CompressionSparseIndex: null, + CompressChunkTimeInterval: null ); private static DatabaseIndex AddIndex(DatabaseTable table, string name, bool isUnique, params string[] columns) diff --git a/tests/Eftdb.Tests/Scaffolding/HypertableColumnstoreAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/HypertableColumnstoreAnnotationApplierTests.cs new file mode 100644 index 0000000..b203d33 --- /dev/null +++ b/tests/Eftdb.Tests/Scaffolding/HypertableColumnstoreAnnotationApplierTests.cs @@ -0,0 +1,172 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.HypertableScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class HypertableColumnstoreAnnotationApplierTests +{ + private readonly HypertableAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestTable", string schema = "public") + => new() { Name = name, Schema = schema }; + + private static HypertableInfo MinimalInfoWith( + string? compressionSparseIndex = null, + string? compressChunkTimeInterval = null) => + new( + TimeColumnName: "ts", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: [], + ChunkSkipColumns: [], + AdditionalDimensions: [], + CompressionSparseIndex: compressionSparseIndex, + CompressChunkTimeInterval: compressChunkTimeInterval + ); + + // ── SparseIndex: null → annotation not set ── + + #region Should_Not_Set_SparseIndex_Annotation_When_Null + + [Fact] + public void Should_Not_Set_SparseIndex_Annotation_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressionSparseIndex: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[HypertableAnnotations.CompressionSparseIndex]); + } + + #endregion + + // ── SparseIndex: "" → annotation set as empty string ── + + #region Should_Set_SparseIndex_Annotation_As_Empty_String + + [Fact] + public void Should_Set_SparseIndex_Annotation_As_Empty_String() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressionSparseIndex: string.Empty); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(string.Empty, table[HypertableAnnotations.CompressionSparseIndex]); + } + + #endregion + + // ── SparseIndex: value → annotation set ── + + #region Should_Set_SparseIndex_Annotation_With_Value + + [Fact] + public void Should_Set_SparseIndex_Annotation_With_Value() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressionSparseIndex: "bloom(device_id)"); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("bloom(device_id)", table[HypertableAnnotations.CompressionSparseIndex]); + } + + #endregion + + // ── CompressChunkTimeInterval: null → annotation not set ── + + #region Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Null + + [Fact] + public void Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressChunkTimeInterval: null); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion + + // ── CompressChunkTimeInterval: whitespace → annotation not set ── + + #region Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Whitespace + + [Fact] + public void Should_Not_Set_CompressChunkTimeInterval_Annotation_When_Whitespace() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressChunkTimeInterval: " "); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion + + // ── CompressChunkTimeInterval: value → annotation set ── + + #region Should_Set_CompressChunkTimeInterval_Annotation_With_Value + + [Fact] + public void Should_Set_CompressChunkTimeInterval_Annotation_With_Value() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith(compressChunkTimeInterval: "24 hours"); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("24 hours", table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion + + // ── Both settings set together ── + + #region Should_Set_Both_Columnstore_Annotations_When_Both_Provided + + [Fact] + public void Should_Set_Both_Columnstore_Annotations_When_Both_Provided() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = MinimalInfoWith( + compressionSparseIndex: "bloom(device_id)", + compressChunkTimeInterval: "7 days"); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("bloom(device_id)", table[HypertableAnnotations.CompressionSparseIndex]); + Assert.Equal("7 days", table[HypertableAnnotations.CompressChunkTimeInterval]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs index f89ef6f..d6697fa 100644 --- a/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/ReorderPolicyAnnotationApplierTests.cs @@ -33,11 +33,9 @@ public void Should_Apply_Minimal_ReorderPolicy_Annotations() // Act _applier.ApplyAnnotations(table, info); - // Assert - verify mandatory annotations are set + // Assert Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("metrics_device_timestamp_idx", table[ReorderPolicyAnnotations.IndexName]); - - // Optional annotations should NOT be set when using defaults Assert.Null(table[ReorderPolicyAnnotations.InitialStart]); Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); Assert.Null(table[ReorderPolicyAnnotations.MaxRuntime]); @@ -186,7 +184,7 @@ public void Should_Not_Apply_ScheduleInterval_When_Default() ReorderPolicyInfo info = new( IndexName: "test_idx", InitialStart: null, - ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, // "1 day" + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, MaxRetries: DefaultValues.ReorderPolicyMaxRetries, RetryPeriod: DefaultValues.ReorderPolicyScheduleInterval @@ -267,7 +265,7 @@ public void Should_Not_Apply_MaxRuntime_When_Default() IndexName: "test_idx", InitialStart: null, ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, - MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, // "00:00:00" + MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, MaxRetries: DefaultValues.ReorderPolicyMaxRetries, RetryPeriod: DefaultValues.ReorderPolicyScheduleInterval ); @@ -318,7 +316,7 @@ public void Should_Not_Apply_MaxRetries_When_Default() InitialStart: null, ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, MaxRuntime: DefaultValues.ReorderPolicyMaxRuntime, - MaxRetries: DefaultValues.ReorderPolicyMaxRetries, // -1 + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, RetryPeriod: DefaultValues.ReorderPolicyScheduleInterval ); @@ -350,7 +348,7 @@ public void Should_Apply_MaxRetries_Zero() // Act _applier.ApplyAnnotations(table, info); - // Assert - 0 is different from default (-1), so it should be applied + // Assert Assert.Equal(0, table[ReorderPolicyAnnotations.MaxRetries]); } @@ -484,7 +482,7 @@ public void Should_Apply_All_Annotations_For_Fully_Configured_ReorderPolicy() // Act _applier.ApplyAnnotations(table, info); - // Assert - verify ALL annotations are applied + // Assert Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("sensor_readings_device_timestamp_idx", table[ReorderPolicyAnnotations.IndexName]); Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); @@ -507,10 +505,10 @@ public void Should_Apply_Only_Non_Default_Annotations() ReorderPolicyInfo info = new( IndexName: "test_idx", InitialStart: initialStart, - ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, // default - should NOT be applied - MaxRuntime: "01:30:00", // non-default - should be applied - MaxRetries: DefaultValues.ReorderPolicyMaxRetries, // default - should NOT be applied - RetryPeriod: "00:15:00" // non-default - should be applied + ScheduleInterval: DefaultValues.ReorderPolicyScheduleInterval, + MaxRuntime: "01:30:00", + MaxRetries: DefaultValues.ReorderPolicyMaxRetries, + RetryPeriod: "00:15:00" ); // Act @@ -520,9 +518,9 @@ public void Should_Apply_Only_Non_Default_Annotations() Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); Assert.Equal(initialStart, table[ReorderPolicyAnnotations.InitialStart]); - Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); // default + Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); Assert.Equal("01:30:00", table[ReorderPolicyAnnotations.MaxRuntime]); - Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); // default + Assert.Null(table[ReorderPolicyAnnotations.MaxRetries]); Assert.Equal("00:15:00", table[ReorderPolicyAnnotations.RetryPeriod]); } @@ -604,12 +602,10 @@ public void Should_Preserve_Existing_Table_Properties() // Act _applier.ApplyAnnotations(table, info); - // Assert - table properties should be preserved + // Assert Assert.Equal("existing_table", table.Name); Assert.Equal("custom_schema", table.Schema); Assert.Equal("Pre-existing table comment", table.Comment); - - // And annotations should still be applied Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); } @@ -695,12 +691,9 @@ public void Should_Apply_Annotations_With_Null_Optional_Values() // Act _applier.ApplyAnnotations(table, info); - // Assert - mandatory annotations should be applied + // Assert Assert.Equal(true, table[ReorderPolicyAnnotations.HasReorderPolicy]); Assert.Equal("test_idx", table[ReorderPolicyAnnotations.IndexName]); - - // Optional annotations with null should not be applied (comparison with default fails) - // Note: null != DefaultValues.X, so annotations won't be set Assert.Null(table[ReorderPolicyAnnotations.InitialStart]); Assert.Null(table[ReorderPolicyAnnotations.ScheduleInterval]); Assert.Null(table[ReorderPolicyAnnotations.MaxRuntime]); diff --git a/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs index 53207d5..8f7eec9 100644 --- a/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs @@ -34,12 +34,10 @@ public void Should_Apply_Minimal_RetentionPolicy_With_DropAfter_And_All_Defaults // Act _applier.ApplyAnnotations(table, info); - // Assert - verify mandatory annotations are set + // Assert Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); Assert.Equal("30 days", table[RetentionPolicyAnnotations.DropAfter]); Assert.Null(table[RetentionPolicyAnnotations.DropCreatedBefore]); - - // Optional annotations should NOT be set when using defaults Assert.Null(table[RetentionPolicyAnnotations.InitialStart]); Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); Assert.Null(table[RetentionPolicyAnnotations.MaxRuntime]); @@ -196,7 +194,7 @@ public void Should_Not_Apply_ScheduleInterval_When_Default() DropAfter: "30 days", DropCreatedBefore: null, InitialStart: null, - ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, // "1 day" + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, MaxRetries: DefaultValues.RetentionPolicyMaxRetries, RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval @@ -249,7 +247,7 @@ public void Should_Not_Apply_MaxRuntime_When_Default() DropCreatedBefore: null, InitialStart: null, ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, - MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, // "00:00:00" + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, MaxRetries: DefaultValues.RetentionPolicyMaxRetries, RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval ); @@ -302,7 +300,7 @@ public void Should_Not_Apply_MaxRetries_When_Default() InitialStart: null, ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, - MaxRetries: DefaultValues.RetentionPolicyMaxRetries, // -1 + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval ); @@ -335,7 +333,7 @@ public void Should_Apply_MaxRetries_Zero() // Act _applier.ApplyAnnotations(table, info); - // Assert - 0 is different from default (-1), so it should be applied + // Assert Assert.Equal(0, table[RetentionPolicyAnnotations.MaxRetries]); } @@ -381,7 +379,7 @@ public void Should_Not_Apply_RetryPeriod_When_Default() ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, MaxRetries: DefaultValues.RetentionPolicyMaxRetries, - RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval // "1 day" + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval ); // Act @@ -414,7 +412,7 @@ public void Should_Apply_All_Annotations_For_Fully_Configured_RetentionPolicy() // Act _applier.ApplyAnnotations(table, info); - // Assert - verify ALL annotations are applied + // Assert Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); Assert.Equal("90 days", table[RetentionPolicyAnnotations.DropAfter]); Assert.Null(table[RetentionPolicyAnnotations.DropCreatedBefore]); @@ -439,10 +437,10 @@ public void Should_Apply_Only_Non_Default_Annotations() DropAfter: "60 days", DropCreatedBefore: null, InitialStart: initialStart, - ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, // default - should NOT be applied - MaxRuntime: "01:30:00", // non-default - should be applied - MaxRetries: DefaultValues.RetentionPolicyMaxRetries, // default - should NOT be applied - RetryPeriod: "00:15:00" // non-default - should be applied + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: "01:30:00", + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: "00:15:00" ); // Act @@ -452,9 +450,9 @@ public void Should_Apply_Only_Non_Default_Annotations() Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); Assert.Equal("60 days", table[RetentionPolicyAnnotations.DropAfter]); Assert.Equal(initialStart, table[RetentionPolicyAnnotations.InitialStart]); - Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); // default + Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); Assert.Equal("01:30:00", table[RetentionPolicyAnnotations.MaxRuntime]); - Assert.Null(table[RetentionPolicyAnnotations.MaxRetries]); // default + Assert.Null(table[RetentionPolicyAnnotations.MaxRetries]); Assert.Equal("00:15:00", table[RetentionPolicyAnnotations.RetryPeriod]); } @@ -501,12 +499,10 @@ public void Should_Preserve_Existing_Table_Properties() // Act _applier.ApplyAnnotations(table, info); - // Assert - table properties should be preserved + // Assert Assert.Equal("existing_table", table.Name); Assert.Equal("custom_schema", table.Schema); Assert.Equal("Pre-existing table comment", table.Comment); - - // And annotations should still be applied Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); Assert.Equal("30 days", table[RetentionPolicyAnnotations.DropAfter]); } diff --git a/tests/Eftdb.Tests/TypeBuilders/CompressionPolicyStringBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/CompressionPolicyStringBuilderTests.cs new file mode 100644 index 0000000..cf68d96 --- /dev/null +++ b/tests/Eftdb.Tests/TypeBuilders/CompressionPolicyStringBuilderTests.cs @@ -0,0 +1,686 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests for CompressionPolicyStringBuilder and the scaffold-targeting +/// WithCompressionPolicy overloads on CompressionPolicyTypeBuilder. +/// +public class CompressionPolicyStringBuilderTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + // ── CompressionPolicyStringBuilder.WithInitialStart ────────────────────── + + // ── CompressionPolicyStringBuilder.EntityTypeBuilder accessor ──────────── + + #region StringBuilder_EntityTypeBuilder_Returns_Same_Instance + + private class SbAccessorEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class SbAccessorContext : DbContext + { + public DbSet Items => Set(); + public EntityTypeBuilder? CapturedEntityTypeBuilder { get; private set; } + public CompressionPolicyStringBuilder? CapturedStringBuilder { get; private 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("cp_sb_accessor"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + CapturedEntityTypeBuilder = entity; + CapturedStringBuilder = entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void StringBuilder_EntityTypeBuilder_Returns_Same_Instance() + { + // Arrange + using SbAccessorContext context = new(); + + // Act + GetModel(context); + + // Assert + Assert.NotNull(context.CapturedStringBuilder); + Assert.Same(context.CapturedEntityTypeBuilder, context.CapturedStringBuilder.EntityTypeBuilder); + } + + #endregion + + #region StringBuilder_WithInitialStart_Sets_Annotation + + private class SbInitialStartEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class SbInitialStartContext : DbContext + { + public DbSet Items => 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("cp_sb_initial_start"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null) + .WithInitialStart(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void StringBuilder_WithInitialStart_Sets_Annotation() + { + // Arrange & Act + using SbInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SbInitialStartEntity))!; + + // Assert + object? value = entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(value); + Assert.IsType(value); + Assert.Equal(new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), (DateTime)value); + } + + #endregion + + #region StringBuilder_WithInitialStart_Returns_Builder_For_Chaining + + private class SbChainEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class SbChainContext : DbContext + { + public DbSet Items => 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("cp_sb_chain"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + + CompressionPolicyStringBuilder builder = + entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + + CompressionPolicyStringBuilder chainResult = + builder.WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Same(builder, chainResult); + }); + } + } + + [Fact] + public void StringBuilder_WithInitialStart_Returns_Builder_For_Chaining() + { + // Arrange & Act + using SbChainContext context = new(); + GetModel(context); + } + + #endregion + + // ── EntityTypeBuilder scaffold overload ────────────────────────────────── + + #region ScaffoldOverload_EntityTypeBuilder_Should_Set_HasCompressionPolicy_And_After + + private class EtbAfterEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class EtbAfterContext : DbContext + { + public DbSet Items => 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("cp_etb_after"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Should_Set_HasCompressionPolicy_And_After() + { + // Arrange & Act + using EtbAfterContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EtbAfterEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Should_Set_CreatedBefore + + private class EtbCreatedBeforeEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class EtbCreatedBeforeContext : DbContext + { + public DbSet Items => 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("cp_etb_created_before"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithCompressionPolicy( + after: null, + createdBefore: "30 days", + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Should_Set_CreatedBefore() + { + // Arrange & Act + using EtbCreatedBeforeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EtbCreatedBeforeEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(CompressionPolicyAnnotations.CreatedBefore)?.Value); + Assert.Null(entityType.FindAnnotation(CompressionPolicyAnnotations.After)); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Should_Set_Optional_Annotations + + private class EtbAllParamsEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class EtbAllParamsContext : DbContext + { + public DbSet Items => 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("cp_etb_all_params"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: "1 day", + timezone: "UTC", + ifNotExists: true); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Should_Set_Optional_Annotations() + { + // Arrange & Act + using EtbAllParamsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EtbAllParamsEntity))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(CompressionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("UTC", entityType.FindAnnotation(CompressionPolicyAnnotations.Timezone)?.Value); + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.IfNotExists)?.Value); + } + + #endregion + + #region ScaffoldOverload_EntityTypeBuilder_Should_Return_CompressionPolicyStringBuilder + + private class EtbReturnsBuilderEntity { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class EtbReturnsBuilderContext : DbContext + { + public DbSet Items => 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("cp_etb_returns_builder"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null) + .WithInitialStart(new DateTime(2025, 3, 15, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_EntityTypeBuilder_Should_Return_CompressionPolicyStringBuilder() + { + // Arrange & Act + using EtbReturnsBuilderContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EtbReturnsBuilderEntity))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)); + } + + #endregion + + // ── RetentionPolicyStringBuilder scaffold overload ──────────────────────── + + #region ScaffoldOverload_RetentionPolicyStringBuilder_Should_Set_CompressionPolicy + + private class RpsbSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class RpsbContext : DbContext + { + public DbSet Items => 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("cp_rpsb"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "90 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null) + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionPolicyStringBuilder_Should_Set_CompressionPolicy() + { + // Arrange & Act + using RpsbContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RpsbSource))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + } + + #endregion + + #region ScaffoldOverload_RetentionPolicyStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart + + private class RpsbInitialStartSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + + private class RpsbInitialStartContext : DbContext + { + public DbSet Items => 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("cp_rpsb_initial_start"); + entity.Property(x => x.Timestamp).HasColumnName("timestamp"); + entity.WithRetentionPolicy( + dropAfter: "90 days", + dropCreatedBefore: null, + scheduleInterval: null, + maxRuntime: null, + maxRetries: null, + retryPeriod: null) + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null) + .WithInitialStart(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_RetentionPolicyStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart() + { + // Arrange & Act + using RpsbInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RpsbInitialStartSource))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + (DateTime)entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)!.Value!); + } + + #endregion + + // ── ContinuousAggregateStringBuilder scaffold overload ──────────────────── + + #region ScaffoldOverload_CaggStringBuilder_Should_Set_CompressionPolicy + + private class CaggCpSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class CaggCpView { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class CaggCpContext : 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("cp_cagg_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cp_cagg_view"); + entity.IsContinuousAggregate( + "cp_cagg_view", + "cp_cagg_source", + "1 hour", + "Timestamp") + .WithCompression() + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_CaggStringBuilder_Should_Set_CompressionPolicy() + { + // Arrange & Act + using CaggCpContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggCpView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + } + + #endregion + + #region ScaffoldOverload_CaggStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart + + private class CaggCpInitialStartSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class CaggCpInitialStartView { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class CaggCpInitialStartContext : 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("cp_cagg_is_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cp_cagg_is_view"); + entity.IsContinuousAggregate( + "cp_cagg_is_view", + "cp_cagg_is_source", + "1 hour", + "Timestamp") + .WithCompression() + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null) + .WithInitialStart(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_CaggStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart() + { + // Arrange & Act + using CaggCpInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggCpInitialStartView))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)); + Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + (DateTime)entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)!.Value!); + } + + #endregion + + // ── ContinuousAggregatePolicyStringBuilder scaffold overload ───────────── + + #region ScaffoldOverload_CaggPolicyStringBuilder_Should_Set_CompressionPolicy + + private class CaggPolCpSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class CaggPolCpView { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class CaggPolCpContext : 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("cp_caggpol_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cp_caggpol_view"); + entity.IsContinuousAggregate( + "cp_caggpol_view", + "cp_caggpol_source", + "1 hour", + "Timestamp") + .WithCompression() + .WithRefreshPolicy( + startOffset: "1 month", + endOffset: null, + scheduleInterval: null) + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null); + }); + } + } + + [Fact] + public void ScaffoldOverload_CaggPolicyStringBuilder_Should_Set_CompressionPolicy() + { + // Arrange & Act + using CaggPolCpContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggPolCpView))!; + + // Assert + Assert.Equal(true, entityType.FindAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(CompressionPolicyAnnotations.After)?.Value); + } + + #endregion + + #region ScaffoldOverload_CaggPolicyStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart + + private class CaggPolCpIsSource { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class CaggPolCpIsView { public DateTime Bucket { get; set; } public double AvgValue { get; set; } } + + private class CaggPolCpIsContext : 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("cp_caggpolis_source"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("cp_caggpolis_view"); + entity.IsContinuousAggregate( + "cp_caggpolis_view", + "cp_caggpolis_source", + "1 hour", + "Timestamp") + .WithCompression() + .WithRefreshPolicy( + startOffset: "1 month", + endOffset: null, + scheduleInterval: null) + .WithCompressionPolicy( + after: "7 days", + createdBefore: null, + scheduleInterval: null, + timezone: null, + ifNotExists: null) + .WithInitialStart(new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public void ScaffoldOverload_CaggPolicyStringBuilder_Returns_CompressionPolicyStringBuilder_With_InitialStart() + { + // Arrange & Act + using CaggPolCpIsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(CaggPolCpIsView))!; + + // Assert + Assert.NotNull(entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)); + Assert.Equal(new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc), + (DateTime)entityType.FindAnnotation(CompressionPolicyAnnotations.InitialStart)!.Value!); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs index 940da3d..456477a 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateBuilderTests.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; @@ -2470,8 +2471,7 @@ public void GetPropertyName_Should_Throw_When_Body_Is_Binary_Expression() // Act & Assert ArgumentException ex = Assert.Throws((Action)(() => { - _ = ContinuousAggregateBuilder - .GetPropertyName(expression); + _ = ExpressionHelper.GetPropertyName(expression); })); Assert.Contains("simple property access expression", ex.Message); diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateCompressionBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateCompressionBuilderTests.cs new file mode 100644 index 0000000..425ea45 --- /dev/null +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregateCompressionBuilderTests.cs @@ -0,0 +1,462 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +public class ContinuousAggregateCompressionBuilderTests +{ + private static IModel GetModel(DbContext context) + => context.GetService().Model; + + private const string ConnStr = "Host=localhost;Database=test;Username=test;Password=test"; + + // ── WithCompression fluent API ──────────────────────────────────────────── + + #region WithCompression_SetsEnableCompressionAnnotation + + private class WCSource1 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class WCAgg1 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class WithCompressionContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("wc_src1"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("wc_cagg1", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(); + }); + } + } + + [Fact] + public void WithCompression_SetsEnableCompressionAnnotation() + { + // Arrange + using WithCompressionContext1 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(WCAgg1))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region WithCompression_False_SetsAnnotationFalse + + private class WCFSource2 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class WCFAgg2 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class WithCompressionFalseContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("wcf_src2"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("wcf_cagg2", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompression(false); + }); + } + } + + [Fact] + public void WithCompression_False_SetsAnnotationFalse() + { + // Arrange + using WithCompressionFalseContext2 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(WCFAgg2))!; + + // Assert + Assert.Equal(false, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region WithCompressionSegmentBy_SetsAnnotationAndImpliesEnable + + private class SegSrc3 { public DateTime Timestamp { get; set; } public double Value { get; set; } public string Region { get; set; } = ""; } + private class SegAgg3 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } public string Region { get; set; } = ""; } + + private class SegmentByContext3 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("seg_src3"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("seg_cagg3", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.Region); + }); + } + } + + [Fact] + public void WithCompressionSegmentBy_SetsAnnotationAndImpliesEnable() + { + // Arrange + using SegmentByContext3 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(SegAgg3))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? segBy = et.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.NotNull(segBy); + Assert.Contains("Region", segBy); + } + + #endregion + + #region WithCompressionSegmentBy_MultipleColumns_JoinedWithComma + + private class MultiSegSrc4 { public DateTime Timestamp { get; set; } public double Value { get; set; } public string Region { get; set; } = ""; public string Device { get; set; } = ""; } + private class MultiSegAgg4 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } public string Region { get; set; } = ""; public string Device { get; set; } = ""; } + + private class MultiSegmentByContext4 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("multiseg_src4"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("multiseg_cagg4", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionSegmentBy(x => x.Region, x => x.Device); + }); + } + } + + [Fact] + public void WithCompressionSegmentBy_MultipleColumns_JoinedWithComma() + { + // Arrange + using MultiSegmentByContext4 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(MultiSegAgg4))!; + + // Assert + string? segBy = et.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.NotNull(segBy); + Assert.Contains("Region", segBy); + Assert.Contains("Device", segBy); + Assert.Contains(",", segBy); + } + + #endregion + + #region WithCompressionOrderBy_SetsAnnotationAndImpliesEnable + + private class OrdSrc5 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class OrdAgg5 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class OrderByContext5 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("ord_src5"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("ord_cagg5", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => s.By(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_SetsAnnotationAndImpliesEnable() + { + // Arrange + using OrderByContext5 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(OrdAgg5))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? orderBy = et.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.NotNull(orderBy); + Assert.Contains("TimeBucket", orderBy); + } + + #endregion + + #region WithCompressionOrderBy_Desc_ContainsDesc + + private class OrdDescSrc6 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class OrdDescAgg6 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class OrderByDescContext6 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("ord_desc_src6"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("ord_desc_cagg6", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(s => s.ByDescending(x => x.TimeBucket)); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_Desc_ContainsDesc() + { + // Arrange + using OrderByDescContext6 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(OrdDescAgg6))!; + + // Assert + string? orderBy = et.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.NotNull(orderBy); + Assert.Contains("DESC", orderBy); + } + + #endregion + + #region WithCompressionOrderBy_RawString_Variant_SetsAnnotation + + private class StrOrdSrc7 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class StrOrdAgg7 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class StringOrderByContext7 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("str_ord_src7"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + e.IsContinuousAggregate("str_ord_cagg7", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithCompressionOrderBy(new OrderBy("time_bucket", isAscending: false)); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_RawString_Variant_SetsAnnotation() + { + // Arrange + using StringOrderByContext7 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(StrOrdAgg7))!; + + // Assert + string? orderBy = et.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.NotNull(orderBy); + Assert.Contains("time_bucket", orderBy); + Assert.Contains("DESC", orderBy); + } + + #endregion + + // ── ContinuousAggregateStringBuilder (scaffolded code path) ────────────── + + #region StringBuilder_WithCompression_SetsEnableAnnotation + + private class SBSrc8 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class SBAgg8 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class StringBuilderCompressionContext8 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("sb_src8"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + ContinuousAggregateTypeBuilder.IsContinuousAggregate( + e, "sb_cagg8", nameof(SBSrc8), "1 hour", "Timestamp") + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .WithCompression(); + }); + } + } + + [Fact] + public void StringBuilder_WithCompression_SetsEnableAnnotation() + { + // Arrange + using StringBuilderCompressionContext8 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(SBAgg8))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region StringBuilder_WithCompressionSegmentBy_SetsAnnotation + + private class SBSegSrc9 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class SBSegAgg9 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class StringBuilderSegContext9 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("sb_seg_src9"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + ContinuousAggregateTypeBuilder.IsContinuousAggregate( + e, "sb_seg_cagg9", nameof(SBSegSrc9), "1 hour", "Timestamp") + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .WithCompressionSegmentBy("device_id"); + }); + } + } + + [Fact] + public void StringBuilder_WithCompressionSegmentBy_SetsAnnotation() + { + // Arrange + using StringBuilderSegContext9 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(SBSegAgg9))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? segBy = et.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + Assert.Equal("device_id", segBy); + } + + #endregion + + #region StringBuilder_WithCompressionOrderBy_SetsAnnotation + + private class SBOrdSrc10 { public DateTime Timestamp { get; set; } public double Value { get; set; } } + private class SBOrdAgg10 { public DateTime TimeBucket { get; set; } public double AvgValue { get; set; } } + + private class StringBuilderOrderContext10 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Hourly => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder b) + => b.UseNpgsql(ConnStr).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity(e => { e.HasNoKey(); e.ToTable("sb_ord_src10"); e.IsHypertable(x => x.Timestamp); }); + m.Entity(e => + { + e.HasNoKey(); + ContinuousAggregateTypeBuilder.IsContinuousAggregate( + e, "sb_ord_cagg10", nameof(SBOrdSrc10), "1 hour", "Timestamp") + .AddAggregateFunction("AvgValue", "Value", EAggregateFunction.Avg) + .WithCompressionOrderBy("time_bucket DESC"); + }); + } + } + + [Fact] + public void StringBuilder_WithCompressionOrderBy_SetsAnnotation() + { + // Arrange + using StringBuilderOrderContext10 ctx = new(); + + // Act + IModel model = GetModel(ctx); + IEntityType et = model.FindEntityType(typeof(SBOrdAgg10))!; + + // Assert + Assert.Equal(true, et.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + string? orderBy = et.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + Assert.Equal("time_bucket DESC", orderBy); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs index e126af6..674cb98 100644 --- a/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/ContinuousAggregatePolicyBuilderTests.cs @@ -256,7 +256,6 @@ public void WithInitialStart_Should_Set_Annotation() [InlineData(-100)] public void WithBucketsPerBatch_Should_Throw_When_LessThan_One(int bucketsPerBatch) { - // Build a real context and builder to call WithBucketsPerBatch on MetricSource5 dummySource = new(); Assert.Throws(() => { @@ -1354,7 +1353,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [InlineData(-1)] public void StringBuilder_WithBucketsPerBatch_Should_Throw_When_Zero(int bucketsPerBatch) { - // Arrange, Act, Assert + // Arrange / Act / Assert Assert.Throws(() => { using StringBuilderBucketsPerBatchInvalidContext23 context = new(bucketsPerBatch); @@ -1410,7 +1409,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) [InlineData(-100)] public void StringBuilder_WithMaxBatchesPerExecution_Should_Throw_When_Negative(int maxBatches) { - // Arrange, Act, Assert + // Arrange / Act / Assert Assert.Throws(() => { using StringBuilderMaxBatchesNegativeContext24 context = new(maxBatches); diff --git a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs index e0743f0..af7fe58 100644 --- a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -367,10 +367,7 @@ public void WithCompressionSegmentBy_Should_Set_Annotation_And_Enable_Compressio IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(SegmentByEntity))!; - // Verify Annotation Value (comma separated) Assert.Equal("TenantId, DeviceId", entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value); - - // Verify Implicit Compression Enablement Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); } @@ -414,10 +411,7 @@ public void WithCompressionOrderBy_Builder_Syntax_Should_Set_Annotation() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(OrderByBuilderEntity))!; - // Verify Annotation Value Assert.Equal("Timestamp DESC, Value ASC NULLS FIRST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); - - // Verify Implicit Compression Enablement Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); } @@ -461,7 +455,6 @@ public void WithCompressionOrderBy_Selector_Syntax_Should_Set_Annotation() IModel model = GetModel(context); IEntityType entityType = model.FindEntityType(typeof(OrderBySelectorEntity))!; - // Verify Annotation Value matches the builder syntax result Assert.Equal("Timestamp DESC, Value ASC NULLS FIRST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); } @@ -495,7 +488,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithChunkTimeInterval("7 days") .WithCompressionSegmentBy(x => x.DeviceId) .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]) - .WithChunkSkipping(x => x.DeviceId); // Often same as segment by + .WithChunkSkipping(x => x.DeviceId); }); } } @@ -1297,7 +1290,7 @@ public void WithChunkSkipping_Should_Throw_For_Non_Member_Expression() _ = GetModel(context); }); - Assert.Contains("not a valid property expression", exception.Message); + Assert.Contains("simple property access expression", exception.Message); } #endregion @@ -1339,7 +1332,7 @@ public void WithCompressionSegmentBy_Should_Throw_For_Converted_Non_Member_Expre _ = GetModel(context); }); - Assert.Contains("not a valid property expression", exception.Message); + Assert.Contains("simple property access expression", exception.Message); } #endregion diff --git a/tests/Eftdb.Tests/Utils/SqlHelper.cs b/tests/Eftdb.Tests/Utils/SqlHelper.cs index 185c5a7..7081f39 100644 --- a/tests/Eftdb.Tests/Utils/SqlHelper.cs +++ b/tests/Eftdb.Tests/Utils/SqlHelper.cs @@ -8,12 +8,10 @@ internal static class SqlHelper /// public static string NormalizeSql(string sql) { - // Split into lines, trim each line, and filter out empty ones IEnumerable lines = sql.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .Select(line => line.Trim()) .Where(line => !string.IsNullOrWhiteSpace(line)); - // Join back with a consistent newline character return string.Join("\n", lines); } }