diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 04ca03f..145b68f 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -86,8 +86,8 @@ private class TestContext(string connectionString) : DbContext { }
| Service Registration | `UseTimescaleDb()` configures all services | `reference/patterns.md` |
| Convention System | `IEntityTypeAddedConvention` processes attributes | `reference/patterns.md` |
| Dual Configuration | Annotations + Fluent API → same annotations | `reference/patterns.md` |
-| IFeatureDiffer | Per-feature differ with model extractor | `reference/patterns.md` |
-| Runtime vs Design-Time | `isDesignTime` parameter changes quote escaping | `reference/patterns.md` |
+| IFeatureDiffer | Per-feature differ with model extractor + `FeatureDiffContext` | `reference/patterns.md` |
+| Runtime vs Design-Time | `*SqlGenerator` (SQL) vs `*CSharpGenerator` (typed migration calls) | `reference/patterns.md` |
| Column Name Resolution | Always use `StoreObjectIdentifier` + `GetColumnName()` | `reference/patterns.md` |
## Agent Workflow
diff --git a/.claude/agents/eftdb-bug-fixer.md b/.claude/agents/eftdb-bug-fixer.md
index 2a37b13..c0fe457 100644
--- a/.claude/agents/eftdb-bug-fixer.md
+++ b/.claude/agents/eftdb-bug-fixer.md
@@ -1,6 +1,6 @@
---
name: eftdb-bug-fixer
-description: Use this agent when bugs are discovered in existing runtime or design-time code within the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes:\n\n\nContext: User discovers a bug in the HypertableDiffer.\nuser: "The HypertableDiffer is not detecting changes to chunk time interval"\nassistant: "I'll use the eftdb-bug-fixer agent to analyze and fix the HypertableDiffer issue."\n\n\n\n\nContext: SQL generation is incorrect for reorder policies.\nuser: "The ReorderPolicyOperationGenerator is generating invalid SQL with wrong schema qualification"\nassistant: "I'll launch the eftdb-bug-fixer agent to fix the SQL generation bug in ReorderPolicyOperationGenerator."\n\n\n\n\nContext: Scaffolding extractor query is failing.\nuser: "The ContinuousAggregateScaffoldingExtractor is throwing NullReferenceException when extracting aggregate functions"\nassistant: "Let me use the eftdb-bug-fixer agent to debug and fix the scaffolding extractor."\n\n\n\n\nContext: Another agent reports a bug during its work.\nuser: "The eftdb-scaffold-support agent reported a mismatch between runtime annotations and scaffolding expectations"\nassistant: "I'll use the eftdb-bug-fixer agent to resolve the annotation mismatch issue reported by the scaffolding agent."\n\n
+description: Use this agent when bugs are discovered in existing runtime or design-time code within the CmdScale.EntityFrameworkCore.TimescaleDB library. This includes:\n\n\nContext: User discovers a bug in the HypertableDiffer.\nuser: "The HypertableDiffer is not detecting changes to chunk time interval"\nassistant: "I'll use the eftdb-bug-fixer agent to analyze and fix the HypertableDiffer issue."\n\n\n\n\nContext: SQL generation is incorrect for reorder policies.\nuser: "The ReorderPolicySqlGenerator is generating invalid SQL with wrong schema qualification"\nassistant: "I'll launch the eftdb-bug-fixer agent to fix the SQL generation bug in ReorderPolicySqlGenerator."\n\n\n\n\nContext: Scaffolding extractor query is failing.\nuser: "The ContinuousAggregateScaffoldingExtractor is throwing NullReferenceException when extracting aggregate functions"\nassistant: "Let me use the eftdb-bug-fixer agent to debug and fix the scaffolding extractor."\n\n\n\n\nContext: Another agent reports a bug during its work.\nuser: "The eftdb-scaffold-support agent reported a mismatch between runtime annotations and scaffolding expectations"\nassistant: "I'll use the eftdb-bug-fixer agent to resolve the annotation mismatch issue reported by the scaffolding agent."\n\n
model: sonnet
color: red
---
@@ -38,7 +38,9 @@ You are an elite debugging and code quality specialist for the CmdScale.EntityFr
- Identify which component is affected:
- Model Extractor (reads annotations from EF model)
- Differ (compares models and generates operations)
- - Operation Generator (generates SQL/C# code)
+ - SQL Generator (`Generators/[Feature]SqlGenerator.cs` — runtime SQL)
+ - C# Generator (`Design/Generators/[Feature]CSharpGenerator.cs` — typed migration calls)
+ - Migration Extensions (`MigrationExtensions/[Feature]MigrationExtensions.cs`)
- Scaffolding Extractor (queries TimescaleDB catalog)
- Scaffolding Applier (applies annotations to scaffolded model)
- Convention (converts attributes to annotations)
@@ -72,10 +74,10 @@ Before fixing, understand WHY the bug exists:
- Hard-coded column names instead of convention-aware resolution
3. **SQL Generation Bugs:**
- - Quote string not respected (`isDesignTime` parameter ignored)
+ - Identifiers not quoted via `SqlBuilderHelper` (`Regclass`/`QualifiedIdentifier`/`QuoteIdentifier`)
- Schema qualification missing or incorrect
- SQL syntax errors for specific TimescaleDB functions
- - Parameter escaping issues
+ - Missing `suppressTransaction` for DDL that cannot run in a transaction (continuous aggregates)
4. **Null Reference Issues:**
- Missing null checks for optional properties
@@ -88,9 +90,9 @@ Before fixing, understand WHY the bug exists:
- Type conversion issues (string vs long for intervals)
6. **Design-Time vs Runtime Confusion:**
- - Generator not handling `isDesignTime` parameter correctly
- - Quote escaping wrong for C# string generation
- - Operation registered in runtime but not in design-time generator
+ - Operation registered in the runtime `TimescaleDbMigrationsSqlGenerator` switch but not in the design-time `TimescaleCSharpMigrationOperationGenerator` switch (or vice versa)
+ - Missing `MigrationExtensions` method so generated migrations cannot call the operation
+ - Runtime SQL and design-time typed call producing inconsistent results
### Phase 3: Fix Implementation
@@ -160,12 +162,12 @@ string columnName = property.GetColumnName(storeIdentifier);
```
```csharp
-// Bug: Quote string not used in SQL generation
+// Bug: identifier not quoted via the helper
// INCORRECT FIX - Hard-coded quotes
string sql = $"SELECT * FROM \"{schema}\".\"{table}\"";
-// CORRECT FIX - Use quote string field and SqlBuilderHelper
-string qualifiedName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString);
+// CORRECT FIX - Use SqlBuilderHelper
+string qualifiedName = SqlBuilderHelper.QualifiedIdentifier(table, schema);
string sql = $"SELECT * FROM {qualifiedName}";
```
@@ -212,14 +214,11 @@ if (annotation.Value is not string expectedValue)
### For SQL Generation Issues:
```csharp
-// Verify quote string usage
-System.Diagnostics.Debug.WriteLine($"Quote string: '{_quoteString}'");
-System.Diagnostics.Debug.WriteLine($"Generated SQL: {sql}");
-
-// Check if isDesignTime is propagated correctly
-if (isDesignTime && !sql.Contains("\"\""))
+// Inspect the statements returned by the feature SqlGenerator
+List statements = HypertableSqlGenerator.Generate(operation);
+foreach (string statement in statements)
{
- // Likely bug: design-time should have doubled quotes
+ System.Diagnostics.Debug.WriteLine($"Generated SQL: {statement}");
}
```
diff --git a/.claude/agents/eftdb-feature-implementer.md b/.claude/agents/eftdb-feature-implementer.md
index e0cf6c9..46a651b 100644
--- a/.claude/agents/eftdb-feature-implementer.md
+++ b/.claude/agents/eftdb-feature-implementer.md
@@ -11,7 +11,7 @@ You are an elite Entity Framework Core migrations architect specializing in the
**PROJECT SCOPE RESTRICTION**: You MUST NOT modify code in any project except:
- CmdScale.EntityFrameworkCore.TimescaleDB (primary work area)
-- CmdScale.EntityFrameworkCore.TimescaleDB.Design (ONLY the TimescaleCSharpMigrationOperationGenerator.cs file)
+- CmdScale.EntityFrameworkCore.TimescaleDB.Design (the `Generators/[Feature]CSharpGenerator.cs` file and `TimescaleCSharpMigrationOperationGenerator.cs`)
Any attempt to modify other projects should result in immediate rejection with explanation.
@@ -48,105 +48,58 @@ Implement the following components in this exact order:
#### 2. Feature Differ (Internals/Features/[Feature]Differ.cs)
-- Implement `IFeatureDiffer` interface
-- Use the extractor to compare source and target models
-- Generate appropriate operations (Create, Alter, Drop) based on differences
-- Return `IEnumerable` with proper priority values:
- - Priority 0: Standard EF operations
- - Priority 10: CreateHypertableOperation
- - Priority 20: Reorder policies
- - Priority 30: Create continuous aggregates
- - Priority 40: Alter/Drop continuous aggregates
- - Choose appropriate priority for your feature based on dependencies
-- Follow existing patterns from HypertableDiffer, ReorderPolicyDiffer, or ContinuousAggregateDiffer
+- Implement `IFeatureDiffer`: `IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)`
+- Normalize `context ??= FeatureDiffContext.Empty;` and use it to resolve renames (`ResolveTable`, `ResolveColumn`, `ResolveIndex`) so a rename is not treated as drop-and-create
+- Use the extractor to compare source and target models, generating Create/Alter/Drop operations
+- Operation ordering is handled centrally by `GetOperationPriority()` (see step 3) — the differ does not set priorities itself
+- Follow existing patterns from HypertableDiffer, ReorderPolicyDiffer, RetentionPolicyDiffer, or ContinuousAggregateDiffer
#### 3. Update TimescaleMigrationsModelDiffer (Internals/TimescaleMigrationsModelDiffer.cs)
-- Register your new differ in the constructor's `_featureDiffers` list
-- Ensure it's positioned correctly based on dependency order
-- No other changes needed to this file
+- Invoke your new differ in `GetDifferences()`, passing the shared `FeatureDiffContext`
+- Add a `case` for each new operation type in `GetOperationPriority()` (drops negative, adds/alters positive; pick values matching the feature's dependency order — see the priority table in `reference/architecture.md`)
-#### 4. Operation Generator (Generators/[Feature]OperationGenerator.cs)
+#### 4. Runtime SQL Generator (Generators/[Feature]SqlGenerator.cs)
-- Create a class that handles both SQL generation and C# code generation
-- **CRITICAL**: Constructor MUST have `isDesignTime` parameter with default value `false`:
- ```csharp
- public FeatureOperationGenerator(bool isDesignTime = false)
- {
- _quoteString = isDesignTime ? "\"\"" : "\"";
- }
- ```
-- Use `_quoteString` for all string literals in SQL generation
-- Implement these methods for each operation type:
- - `Generate([Operation] operation, IModel? model, MigrationCommandListBuilder builder, bool isDesignTime)` - Runtime SQL
- - `Generate([Operation] operation, CSharpMigrationOperationBuilder builder)` - Design-time C# code
-- Use `SqlBuilderHelper` static methods for table names, schema handling, and identifier quoting:
- - `GetQualifiedTableName(schema, table, quoteString)` for fully qualified names
- - `GetSchemaPrefix(schema, quoteString)` for schema prefixing
- - Always pass your `_quoteString` to these methods
-- Follow SQL generation patterns from existing generators (HypertableOperationGenerator, ReorderPolicyOperationGenerator)
-
-#### 5. Update TimescaleDbMigrationsSqlGenerator (TimescaleDbMigrationsSqlGenerator.cs)
-
-- Add method to handle your operation type:
- ```csharp
- protected virtual void Generate([YourOperation] operation, IModel? model, MigrationCommandListBuilder builder)
- {
- var generator = new YourOperationGenerator(_isDesignTime);
- generator.Generate(operation, model, builder, _isDesignTime);
- }
- ```
-- Store `isDesignTime` parameter in a field: `private readonly bool _isDesignTime;`
-- Pass it through to generators
+- Static class exposing `static List Generate(XxxOperation operation)` per operation type, returning TimescaleDB SQL statements
+- Build identifiers with `SqlBuilderHelper.Regclass()`, `SqlBuilderHelper.QualifiedIdentifier()`, `SqlBuilderHelper.QuoteIdentifier()`
+- For policy scheduling SQL (`alter_job` clauses), reuse `PolicyJobSqlBuilder`
+- Follow existing generators (HypertableSqlGenerator, RetentionPolicySqlGenerator)
-#### 6. Update TimescaleCSharpMigrationOperationGenerator (Design Project - ONLY FILE ALLOWED)
+#### 5. Typed Migration Extensions (MigrationExtensions/[Feature]MigrationExtensions.cs)
-- Add C# code generation method:
- ```csharp
- protected virtual void Generate([YourOperation] operation, CSharpMigrationOperationBuilder builder)
- {
- var generator = new YourOperationGenerator(isDesignTime: true);
- generator.Generate(operation, builder);
- }
- ```
-- This is the ONLY file in the Design project you may modify
+- Add extension methods on `MigrationBuilder` (declared in namespace `Microsoft.EntityFrameworkCore.Migrations`) that construct the operation and `migrationBuilder.Operations.Add(operation)`
+- Return an `OperationBuilder`
+- These are the methods generated migrations call (e.g. `migrationBuilder.CreateHypertable(...)`)
+
+#### 6. Register in TimescaleDbMigrationsSqlGenerator (TimescaleDbMigrationsSqlGenerator.cs)
+
+- Add a `case XxxOperation op:` to the `Generate` switch that calls `[Feature]SqlGenerator.Generate(op)` and assigns `statements`
+- Set `suppressTransaction = true` for operations whose DDL cannot run in a transaction (e.g. continuous-aggregate creation)
+
+#### 7. Design-Time C# Generator (Design/Generators/[Feature]CSharpGenerator.cs + register)
+
+- `Generate(XxxOperation operation, IndentedStringBuilder builder)` emits the typed `migrationBuilder.[Method](...)` call using `MigrationCallWriter` and `CSharpGeneratorHelper`
+- Emit a named `call.Arg("argName", code.Literal(...))` for each value, skipping defaults/empties
+- Register the operation type in the `switch` in `TimescaleCSharpMigrationOperationGenerator.cs`
## Critical Technical Requirements
-### Quote String Handling
+### Runtime vs Design-Time Split
-**This is ABSOLUTELY CRITICAL for runtime vs design-time duality:**
+The two paths are independent and consume the same operation types:
-- **Runtime Migrations** (`dotnet ef database update`):
- - Quote string: `"` (single quote)
- - Generates raw SQL that executes against database
-
-- **Design-Time Migrations** (`dotnet ef migrations add`):
- - Quote string: `""` (doubled quotes)
- - Generates C# code with escaped strings for migration files
+- **Runtime** (`dotnet ef database update`): `TimescaleDbMigrationsSqlGenerator` → `[Feature]SqlGenerator.Generate(operation)` → SQL statements.
+- **Design-time** (`dotnet ef migrations add`): `TimescaleCSharpMigrationOperationGenerator` → `[Feature]CSharpGenerator.Generate(operation, builder)` → typed `migrationBuilder.[Method](...)` calls.
-**Implementation Pattern:**
-```csharp
-public class YourOperationGenerator
-{
- private readonly string _quoteString;
-
- public YourOperationGenerator(bool isDesignTime = false)
- {
- _quoteString = isDesignTime ? "\"\"" : "\"";
- }
-
- // Use _quoteString in all SQL generation
- var tableName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString);
-}
-```
+Generators carry no `isDesignTime` flag and do no quote-doubling.
### SqlBuilderHelper Usage
-ALWAYS use SqlBuilderHelper for:
-- Table name qualification: `GetQualifiedTableName(schema, table, quoteString)`
-- Schema prefixing: `GetSchemaPrefix(schema, quoteString)`
-- Identifier quoting: Methods handle this internally when you pass quoteString
+In `[Feature]SqlGenerator`, build identifiers with:
+- `SqlBuilderHelper.Regclass(table, schema)` → `'schema."table"'` (for `create_hypertable` and other regclass arguments)
+- `SqlBuilderHelper.QualifiedIdentifier(table, schema)` → `"schema"."table"` (for `ALTER TABLE` etc.)
+- `SqlBuilderHelper.QuoteIdentifier(column)` → `"column"`
NEVER manually construct qualified names or handle quoting yourself.
@@ -243,10 +196,12 @@ Once the feature initializer completes, relaunch this agent to implement the mig
Implemented Components:
- Internals/Features/[Feature]/[Feature]ModelExtractor.cs
- Internals/Features/[Feature]/[Feature]Differ.cs
-- Generators/[Feature]OperationGenerator.cs
-- Updated: Internals/TimescaleMigrationsModelDiffer.cs
-- Updated: TimescaleDbMigrationsSqlGenerator.cs
-- Updated: Design/TimescaleCSharpMigrationOperationGenerator.cs
+- Generators/[Feature]SqlGenerator.cs
+- MigrationExtensions/[Feature]MigrationExtensions.cs
+- Design/Generators/[Feature]CSharpGenerator.cs
+- Updated: Internals/TimescaleMigrationsModelDiffer.cs (differ invocation + GetOperationPriority cases)
+- Updated: TimescaleDbMigrationsSqlGenerator.cs (Generate switch case)
+- Updated: Design/TimescaleCSharpMigrationOperationGenerator.cs (Generate switch case)
Operation Priority: [X] (rationale: [explanation])
diff --git a/.claude/agents/eftdb-feature-initializer.md b/.claude/agents/eftdb-feature-initializer.md
index 5b6ad74..1ee0da6 100644
--- a/.claude/agents/eftdb-feature-initializer.md
+++ b/.claude/agents/eftdb-feature-initializer.md
@@ -141,7 +141,7 @@ Created Files:
NEXT STEPS:
→ Use eftdb-feature-implementer agent to implement migration logic
- (Creates: Differ, ModelExtractor, OperationGenerator)
+ (Creates: Differ, ModelExtractor, SqlGenerator, MigrationExtensions, CSharpGenerator)
→ Then use eftdb-scaffold-support agent for db-first scaffolding
(Creates: ScaffoldingExtractor, AnnotationApplier)
diff --git a/.claude/agents/eftdb-scaffold-support.md b/.claude/agents/eftdb-scaffold-support.md
index 3cf2241..5df2753 100644
--- a/.claude/agents/eftdb-scaffold-support.md
+++ b/.claude/agents/eftdb-scaffold-support.md
@@ -16,7 +16,7 @@ You are ONLY permitted to work within:
You are ABSOLUTELY FORBIDDEN from:
- Modifying any files in other projects (Runtime, Tests, Example, etc.)
- Fixing bugs you discover in other projects
-- Changing operation generators, differs, or migration code
+- Changing SQL/C# generators, migration extensions, differs, or migration code
- Altering the core runtime library
If you encounter bugs or missing functionality in other projects, you MUST:
diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md
index 5455968..941c01c 100644
--- a/.claude/agents/pr-code-reviewer.md
+++ b/.claude/agents/pr-code-reviewer.md
@@ -34,10 +34,12 @@ You are an expert code reviewer specializing in Entity Framework Core extensions
4. **Critical Pattern Verification**
- **StoreObjectIdentifier Usage**: Confirm `GetColumnName(storeIdentifier)` is used for column name resolution to support naming conventions
- - **Quote Escaping**: Verify `isDesignTime` parameter is correctly passed to SQL generators
+ - **Generator Split**: Verify runtime SQL lives in `Generators/[Feature]SqlGenerator.cs` and design-time output in `Design/Generators/[Feature]CSharpGenerator.cs`; identifiers use `SqlBuilderHelper` (`Regclass`/`QualifiedIdentifier`/`QuoteIdentifier`)
+ - **Migration Extensions**: Confirm `MigrationExtensions/[Feature]MigrationExtensions.cs` adds the operation to `migrationBuilder.Operations`
+ - **Diff Context**: Verify differs accept `FeatureDiffContext` and resolve renames via it
- **Annotation Storage**: Check that feature metadata uses centralized annotation constants
- **Default Values**: Ensure `DefaultValues.cs` constants are referenced instead of hardcoded values
- - **Continuous Aggregate Encoding**: Validate colon-delimited aggregate function strings follow the correct format
+ - **Continuous Aggregate Encoding**: Validate `ContinuousAggregateFunction` values and the colon-delimited annotation format follow the correct format
5. **Project Structure Compliance**
- Verify files are in correct namespaces and directories
diff --git a/.claude/agents/test-coverage-planner.md b/.claude/agents/test-coverage-planner.md
index e6a8a0d..d91b257 100644
--- a/.claude/agents/test-coverage-planner.md
+++ b/.claude/agents/test-coverage-planner.md
@@ -193,18 +193,18 @@ For detailed test writing patterns and anti-patterns, see the `test-writer` agen
Key principles for coverage analysis:
- Tests should verify EF Core provider integration, not TimescaleDB itself
- Prioritize migration lifecycle simulation over raw SQL execution
-- Cover both design-time (C# string escaping) and runtime (SQL) code paths
+- Cover both design-time (typed migration call) and runtime (SQL) code paths
- Ensure naming convention support (snake_case, PascalCase, custom)
## Technical Considerations
- **Naming Convention Support**: Ensure tests cover snake_case, camelCase, PascalCase, and custom conventions
-- **Design-Time vs Runtime**: Test both `isDesignTime: true` and `isDesignTime: false` code paths
+- **Design-Time vs Runtime**: Test both the runtime `[Feature]SqlGenerator` (SQL) and the design-time `[Feature]CSharpGenerator` (typed migration calls) paths
- **Edge Cases**: Null values, empty collections, invalid configurations, malformed SQL
- **Error Handling**: Exception scenarios, validation failures, database errors
- **Cross-Feature**: Interactions between hypertables, continuous aggregates, and reorder policies
-- **Quote Escaping**: Verify correct escaping for both SQL (`"table"`) and C# strings (`""table""`)
-- **Schema Qualification**: Test `regclass()` formatting and qualified table names
+- **Identifier Quoting**: Verify `SqlBuilderHelper` quoting (`"table"`, `'schema."table"'`) in generated SQL
+- **Schema Qualification**: Test `Regclass()` formatting and qualified table names
- **Column Name Resolution**: Test `StoreObjectIdentifier` and `GetColumnName()` with various naming conventions
## Your Constraints
diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md
index 5e79925..5109e76 100644
--- a/.claude/agents/test-writer.md
+++ b/.claude/agents/test-writer.md
@@ -201,17 +201,16 @@ Assert.NotNull(result);
Assert.Equal(expectedValue, result.Property);
```
-**Testing SQL Generation** (Functional Tests):
+**Testing SQL Generation**:
```csharp
// Arrange
-using TimescaleDbTestContainer container = new();
CreateHypertableOperation operation = new() { /* ... */ };
// Act
-string sql = generator.Generate(operation, model, builder, isDesignTime: false);
+List statements = HypertableSqlGenerator.Generate(operation);
// Assert
-Assert.Contains("SELECT create_hypertable", sql);
+Assert.Contains(statements, s => s.Contains("SELECT create_hypertable"));
```
### Column Name Convention Support
diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md
index 65619f7..604040f 100644
--- a/.claude/reference/architecture.md
+++ b/.claude/reference/architecture.md
@@ -54,6 +54,12 @@ This document provides detailed architectural information for the CmdScale.Entit
- `ReorderPolicyAnnotations.cs` - Annotation constants
- `ReorderPolicyTypeBuilder.cs` - Fluent API: `WithReorderPolicy()`
+#### RetentionPolicy/ (4 files)
+- `RetentionPolicyAttribute.cs` - Data annotation: `[RetentionPolicy(DropAfter = "30 days")]`
+- `RetentionPolicyConvention.cs` - IEntityTypeAddedConvention implementation
+- `RetentionPolicyAnnotations.cs` - Annotation constants
+- `RetentionPolicyTypeBuilder.cs` - Fluent API: `WithRetentionPolicy()`
+
#### ContinuousAggregate/ (8 files)
- `ContinuousAggregateAttribute.cs` - Entity-level attribute defining materialized view
- `TimeBucketAttribute.cs` - Property-level attribute for time bucketing
@@ -76,7 +82,8 @@ 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` |W
+| `EAggregateFunction.cs` | Enum: `Avg`, `Sum`, `Min`, `Max`, `Count`, `First`, `Last` |
+| `ContinuousAggregateFunction.cs` | Strongly-typed `(Alias, Function, SourceColumn)` for continuous-aggregate columns; `ToAnnotationValue()` serializes to the `alias:Function:sourceColumn` wire format |
### Operations/ - Migration Operations
@@ -84,6 +91,7 @@ All inherit `MigrationOperation` and contain feature-specific properties:
- `CreateHypertableOperation.cs` / `AlterHypertableOperation.cs`
- `AddReorderPolicyOperation.cs` / `AlterReorderPolicyOperation.cs` / `DropReorderPolicyOperation.cs`
+- `AddRetentionPolicyOperation.cs` / `AlterRetentionPolicyOperation.cs` / `DropRetentionPolicyOperation.cs`
- `CreateContinuousAggregateOperation.cs` / `AlterContinuousAggregateOperation.cs` / `DropContinuousAggregateOperation.cs`
- `AddContinuousAggregatePolicyOperation.cs` / `RemoveContinuousAggregatePolicyOperation.cs`
@@ -101,26 +109,53 @@ These are runtime-only — they have no in-memory implementation and throw when
The plugin is registered in `TimescaleDbServiceCollectionExtensions.AddEntityFrameworkTimescaleDb()` via `.TryAdd()`.
-### Generators/ - SQL and C# Code Generation
+### Generators/ - Runtime SQL Generation
+
+Each `*SqlGenerator` exposes `static List Generate(XxxOperation operation)` and returns TimescaleDB SQL statements. `TimescaleDbMigrationsSqlGenerator` switches on the operation type, calls the matching generator, and passes the statements to `SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform)`. `CreateContinuousAggregateOperation` is emitted with `suppressTransaction: true` (continuous-aggregate DDL cannot run inside a transaction block).
| File | Purpose |
|------|---------|
-| `HypertableOperationGenerator.cs` | Generates `create_hypertable()`, `set_chunk_time_interval()`, etc. |
-| `ReorderPolicyOperationGenerator.cs` | Generates `add_reorder_policy()`, `remove_reorder_policy()`, etc. |
-| `ContinuousAggregateOperationGenerator.cs` | Generates materialized view SQL |
-| `SqlBuilderHelper.cs` | Quote handling utilities (`isDesignTime` parameter critical) |
+| `HypertableSqlGenerator.cs` | `create_hypertable()`, `set_chunk_time_interval()`, `add_dimension()`, compression/chunk-skipping SQL |
+| `ReorderPolicySqlGenerator.cs` | `add_reorder_policy()`, `remove_reorder_policy()`, `alter_job` tuning |
+| `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()` |
+| `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 |
+
+### MigrationExtensions/ - Typed migrationBuilder API
+
+Generated migrations call strongly-typed extension methods that construct a `MigrationOperation` and add it to `migrationBuilder.Operations`. Methods are declared in the `Microsoft.EntityFrameworkCore.Migrations` namespace so they are available in migration files without extra `using` directives.
+
+| File | Methods |
+|------|---------|
+| `HypertableMigrationExtensions.cs` | `CreateHypertable(...)`, `AlterHypertable(...)` |
+| `ReorderPolicyMigrationExtensions.cs` | `AddReorderPolicy(...)`, `AlterReorderPolicy(...)`, `DropReorderPolicy(...)` |
+| `RetentionPolicyMigrationExtensions.cs` | `AddRetentionPolicy(...)`, `AlterRetentionPolicy(...)`, `DropRetentionPolicy(...)` |
+| `ContinuousAggregateMigrationExtensions.cs` | `CreateContinuousAggregate(...)`, `AlterContinuousAggregate(...)`, `DropContinuousAggregate(...)` |
+| `ContinuousAggregatePolicyMigrationExtensions.cs` | `AddContinuousAggregatePolicy(...)`, `RemoveContinuousAggregatePolicy(...)` |
### Internals/ - Core Diffing Logic
-- `TimescaleMigrationsModelDiffer.cs` - Extends EF Core's MigrationsModelDiffer, implements `GetOperationPriority()`
-- `Features/IFeatureDiffer.cs` - Interface: `GetDifferences(IRelationalModel? source, IRelationalModel? target)`
+- `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
**Feature-specific:**
- `Features/Hypertables/` - `HypertableDiffer.cs`, `HypertableModelExtractor.cs`
- `Features/ReorderPolicies/` - `ReorderPolicyDiffer.cs`, `ReorderPolicyModelExtractor.cs`
+- `Features/RetentionPolicies/` - `RetentionPolicyDiffer.cs`, `RetentionPolicyModelExtractor.cs`
- `Features/ContinuousAggregates/` - `ContinuousAggregateDiffer.cs`, `ContinuousAggregateModelExtractor.cs`
- `Features/ContinuousAggregatePolicies/` - `ContinuousAggregatePolicyDiffer.cs`, `ContinuousAggregatePolicyModelExtractor.cs`
+#### FeatureDiffContext
+
+`TimescaleMigrationsModelDiffer` runs EF Core's base differ first, builds a `FeatureDiffContext` from the resulting operations, and passes it to every feature differ. It carries:
+
+- **TableRenames / IndexRenames / ColumnRenames** - maps built from EF's `RenameTableOperation` / `RenameIndexOperation` / `RenameColumnOperation` so feature differs treat a rename as a rename rather than drop-and-create. Schemas are normalized to `DefaultValues.DefaultSchema`. Resolve via `ResolveTable()`, `ResolveIndex()`, `ResolveColumn()`.
+- **RecreatedAggregates** - continuous aggregates being dropped and recreated in this diff, populated by `PopulateRecreatedAggregates` after the continuous-aggregate differ runs. Recreating a continuous aggregate cascades to drop its refresh and retention policies, so dependent policy differs re-add those policies even when their config is unchanged.
+- `FeatureDiffContext.Empty` - identity context used when a differ runs without orchestration (e.g. unit tests).
+
### DefaultValues.cs - Centralized Constants
```csharp
@@ -143,8 +178,22 @@ ReorderPolicyMaxRuntime = "00:00:00" // no limit
### TimescaleCSharpMigrationOperationGenerator.cs
- Generates C# code for `dotnet ef migrations add`
-- Calls operation generators with `isDesignTime: true`
-- Outputs `.Sql(@"...")` calls in migration Up/Down methods
+- Switches on the operation type and delegates to the matching `*CSharpGenerator` (constructed with `Dependencies.CSharpHelper`)
+- Emits typed `migrationBuilder.CreateHypertable(...)` / `AddRetentionPolicy(...)` / etc. calls in migration Up/Down methods
+
+### Generators/ - Design-Time C# Generation
+
+Each `*CSharpGenerator.Generate(XxxOperation, IndentedStringBuilder)` emits one typed `migrationBuilder` call, with one named argument per line.
+
+| File | Purpose |
+|------|---------|
+| `HypertableCSharpGenerator.cs` | Emits `CreateHypertable(...)` / `AlterHypertable(...)` |
+| `ReorderPolicyCSharpGenerator.cs` | Emits `AddReorderPolicy(...)` / `AlterReorderPolicy(...)` / `DropReorderPolicy(...)` |
+| `RetentionPolicyCSharpGenerator.cs` | Emits `AddRetentionPolicy(...)` / `AlterRetentionPolicy(...)` / `DropRetentionPolicy(...)` |
+| `ContinuousAggregateCSharpGenerator.cs` | Emits `CreateContinuousAggregate(...)` / `AlterContinuousAggregate(...)` / `DropContinuousAggregate(...)` |
+| `ContinuousAggregatePolicyCSharpGenerator.cs` | Emits `AddContinuousAggregatePolicy(...)` / `RemoveContinuousAggregatePolicy(...)` |
+| `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 |
### TimescaleDatabaseModelFactory.cs
@@ -172,15 +221,22 @@ Orchestrates db-first scaffolding with extractor/applier pairs:
## Migration Operation Priority Ordering
-Custom operations are prioritized by `TimescaleMigrationsModelDiffer.GetOperationPriority()`:
-
-| Priority | Operation Type | Reason |
-|----------|---------------|--------|
-| 0 | Standard EF operations | CreateTable, AddColumn, DropColumn, etc. |
-| 10 | `CreateHypertableOperation` | Tables must exist first |
-| 20 | Reorder policy operations | Hypertables must exist |
-| 30 | `CreateContinuousAggregateOperation` | Source hypertables must exist |
-| 40 | Alter/Drop continuous aggregate | Last to ensure dependencies exist |
+Custom operations are sorted by `TimescaleMigrationsModelDiffer.GetOperationPriority()`. Drop operations get negative priorities (run before standard EF table drops, in reverse dependency order); add/alter operations get positive priorities (run after standard EF table creation, in dependency order).
+
+| Priority | Operation Type |
+|----------|---------------|
+| -60 | `DropRetentionPolicyOperation` |
+| -50 | `RemoveContinuousAggregatePolicyOperation` |
+| -40 | `DropContinuousAggregateOperation` |
+| -20 | `DropReorderPolicyOperation` |
+| 0 | Standard EF operations (CreateTable, AddColumn, DropTable, …) |
+| 10 | `CreateHypertableOperation` |
+| 15 | `AlterHypertableOperation` |
+| 20 | `AddReorderPolicyOperation` / `AlterReorderPolicyOperation` |
+| 30 | `CreateContinuousAggregateOperation` |
+| 40 | `AlterContinuousAggregateOperation` |
+| 50 | `AddContinuousAggregatePolicyOperation` |
+| 60 | `AddRetentionPolicyOperation` / `AlterRetentionPolicyOperation` |
## Continuous Aggregates Implementation Details
@@ -190,9 +246,9 @@ Continuous aggregates are materialized views that automatically refresh:
- **ParentName:** Entity name of source hypertable (resolved to table name via EF metadata)
- **TimeBucketWidth:** Time interval for bucketing (e.g., "1 day", "1 hour")
- **TimeBucketSourceColumn:** Time column to bucket on (resolved to database column name)
-- **AggregateFunctions:** Colon-delimited strings (see patterns.md)
+- **AggregateFunctions:** `ContinuousAggregateFunction` values in the typed API; stored as colon-delimited strings on the operation (see patterns.md)
- **GroupByColumns:** Column names for GROUP BY
-- **WhereClause:** Raw SQL for filtering (partially implemented)
+- **WhereClause:** Raw SQL for filtering, emitted verbatim into the materialized view's `WHERE`. Identifiers are passed through unchanged, so quoted column references must match the resolved database column names.
**SQL Generation Special Cases:**
- `first()`/`last()` functions require time ordering column: `first(price, timestamp ORDER BY timestamp)`
diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md
index 5d92cd3..be8c448 100644
--- a/.claude/reference/file-organization.md
+++ b/.claude/reference/file-organization.md
@@ -24,7 +24,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Configuration/Hypertable/HypertableConvention.cs` | Convention processing |
| `Internals/Features/Hypertables/HypertableDiffer.cs` | Diffing logic |
| `Internals/Features/Hypertables/HypertableModelExtractor.cs` | Model extraction |
-| `Generators/HypertableOperationGenerator.cs` | SQL/C# generation |
+| `Generators/HypertableSqlGenerator.cs` | Runtime SQL generation |
+| `MigrationExtensions/HypertableMigrationExtensions.cs` | Typed migrationBuilder methods |
| `Operations/CreateHypertableOperation.cs` | Migration operation |
| `Operations/AlterHypertableOperation.cs` | Migration operation |
@@ -38,11 +39,28 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Configuration/ReorderPolicy/ReorderPolicyConvention.cs` | Convention processing |
| `Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs` | Diffing logic |
| `Internals/Features/ReorderPolicies/ReorderPolicyModelExtractor.cs` | Model extraction |
-| `Generators/ReorderPolicyOperationGenerator.cs` | SQL/C# generation |
+| `Generators/ReorderPolicySqlGenerator.cs` | Runtime SQL generation |
+| `MigrationExtensions/ReorderPolicyMigrationExtensions.cs` | Typed migrationBuilder methods |
| `Operations/AddReorderPolicyOperation.cs` | Migration operation |
| `Operations/AlterReorderPolicyOperation.cs` | Migration operation |
| `Operations/DropReorderPolicyOperation.cs` | Migration operation |
+### Retention Policy
+
+| File | Purpose |
+|------|---------|
+| `Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs` | Fluent API |
+| `Configuration/RetentionPolicy/RetentionPolicyAnnotations.cs` | Annotation constants |
+| `Configuration/RetentionPolicy/RetentionPolicyAttribute.cs` | Data annotation |
+| `Configuration/RetentionPolicy/RetentionPolicyConvention.cs` | Convention processing |
+| `Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs` | Diffing logic |
+| `Internals/Features/RetentionPolicies/RetentionPolicyModelExtractor.cs` | Model extraction |
+| `Generators/RetentionPolicySqlGenerator.cs` | Runtime SQL generation |
+| `MigrationExtensions/RetentionPolicyMigrationExtensions.cs` | Typed migrationBuilder methods |
+| `Operations/AddRetentionPolicyOperation.cs` | Migration operation |
+| `Operations/AlterRetentionPolicyOperation.cs` | Migration operation |
+| `Operations/DropRetentionPolicyOperation.cs` | Migration operation |
+
### Continuous Aggregate
| File | Purpose |
@@ -56,7 +74,9 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Configuration/ContinuousAggregate/ContinuousAggregateConvention.cs` | Convention processing |
| `Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs` | Diffing logic |
| `Internals/Features/ContinuousAggregates/ContinuousAggregateModelExtractor.cs` | Model extraction |
-| `Generators/ContinuousAggregateOperationGenerator.cs` | SQL/C# generation |
+| `Generators/ContinuousAggregateSqlGenerator.cs` | Runtime SQL generation |
+| `MigrationExtensions/ContinuousAggregateMigrationExtensions.cs` | Typed migrationBuilder methods |
+| `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value |
| `Operations/CreateContinuousAggregateOperation.cs` | Migration operation |
| `Operations/AlterContinuousAggregateOperation.cs` | Migration operation |
| `Operations/DropContinuousAggregateOperation.cs` | Migration operation |
@@ -72,6 +92,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs` | Builder extensions |
| `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs` | Diffing logic |
| `Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyModelExtractor.cs` | Model extraction |
+| `Generators/ContinuousAggregatePolicySqlGenerator.cs` | Runtime SQL generation |
+| `MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs` | Typed migrationBuilder methods |
| `Operations/AddContinuousAggregatePolicyOperation.cs` | Migration operation |
| `Operations/RemoveContinuousAggregatePolicyOperation.cs` | Migration operation |
@@ -88,19 +110,29 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| File | Purpose |
|------|---------|
-| `Internals/TimescaleMigrationsModelDiffer.cs` | Operation prioritization |
+| `Internals/TimescaleMigrationsModelDiffer.cs` | Differ orchestration, context building, operation prioritization |
| `Internals/Features/IFeatureDiffer.cs` | Differ interface |
-| `Generators/SqlBuilderHelper.cs` | Quote handling, regclass |
+| `Internals/Features/FeatureDiffContext.cs` | Cross-cutting diff state (renames, recreated aggregates) |
+| `Generators/SqlBuilderHelper.cs` | Identifier quoting, regclass, command grouping, SELECT→PERFORM |
+| `Generators/PolicyJobSqlBuilder.cs` | Shared `alter_job` clause builder for policies |
| `DefaultValues.cs` | Centralized defaults |
| `Abstractions/Dimension.cs` | Range/hash partitioning |
| `Abstractions/EAggregateFunction.cs` | Aggregate function enum |
+| `Abstractions/ContinuousAggregateFunction.cs` | Typed aggregate-function value |
## Design Library Key Files
| File | Purpose |
|------|---------|
| `TimescaleDBDesignTimeServices.cs` | Register design-time services |
-| `TimescaleCSharpMigrationOperationGenerator.cs` | C# code generation for migrations |
+| `TimescaleCSharpMigrationOperationGenerator.cs` | Dispatches operations to the `*CSharpGenerator` classes |
+| `Generators/HypertableCSharpGenerator.cs` | Emits `CreateHypertable`/`AlterHypertable` calls |
+| `Generators/ReorderPolicyCSharpGenerator.cs` | Emits reorder-policy calls |
+| `Generators/RetentionPolicyCSharpGenerator.cs` | Emits retention-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 |
+| `Generators/CSharpGeneratorHelper.cs` | Collection-expression and static-call literal helpers |
| `TimescaleDatabaseModelFactory.cs` | Db-first scaffolding orchestration |
| `Scaffolding/ITimescaleFeatureExtractor.cs` | Extractor interface |
| `Scaffolding/IAnnotationApplier.cs` | Applier interface |
@@ -108,6 +140,8 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Scaffolding/HypertableAnnotationApplier.cs` | Apply hypertable annotations |
| `Scaffolding/ReorderPolicyScaffoldingExtractor.cs` | Query reorder policies from database |
| `Scaffolding/ReorderPolicyAnnotationApplier.cs` | Apply reorder policy annotations |
+| `Scaffolding/RetentionPolicyScaffoldingExtractor.cs` | Query retention policies from database |
+| `Scaffolding/RetentionPolicyAnnotationApplier.cs` | Apply retention policy annotations |
| `Scaffolding/ContinuousAggregateScaffoldingExtractor.cs` | Query continuous aggregates |
| `Scaffolding/ContinuousAggregateAnnotationApplier.cs` | Apply continuous aggregate annotations |
| `build/CmdScale.EntityFrameworkCore.TimescaleDB.Design.targets` | MSBuild integration |
@@ -137,20 +171,24 @@ src/
│ │ ├── ContinuousAggregate/
│ │ ├── ContinuousAggregatePolicy/
│ │ ├── Hypertable/
-│ │ └── ReorderPolicy/
-│ ├── Generators/ # SQL and C# code generation
+│ │ ├── ReorderPolicy/
+│ │ └── RetentionPolicy/
+│ ├── Generators/ # Runtime SQL generation
+│ ├── MigrationExtensions/ # Typed migrationBuilder.* methods
│ ├── Internals/ # Core diffing logic
│ │ └── Features/
│ │ ├── ContinuousAggregates/
│ │ ├── ContinuousAggregatePolicies/
│ │ ├── Hypertables/
-│ │ └── ReorderPolicies/
+│ │ ├── ReorderPolicies/
+│ │ └── RetentionPolicies/
│ ├── Operations/ # Migration operations
│ ├── Query/ # EF.Functions extensions and LINQ translators
│ │ └── Internal/ # EF Core query pipeline integration
│ └── *.cs # Entry points, extensions
│
└── Eftdb.Design/ # Design-time library (CmdScale.EntityFrameworkCore.TimescaleDB.Design)
+ ├── Generators/ # Design-time C# (typed migration call) generation
├── Scaffolding/ # Extractors and appliers
├── build/ # MSBuild targets
└── *.cs # Design-time services
diff --git a/.claude/reference/patterns.md b/.claude/reference/patterns.md
index 814a621..24b1060 100644
--- a/.claude/reference/patterns.md
+++ b/.claude/reference/patterns.md
@@ -44,33 +44,36 @@ Both approaches store identical annotation values in entity type metadata.
## 4. IFeatureDiffer Pattern
-Each TimescaleDB feature has a dedicated differ implementing `IFeatureDiffer`. The differ uses a corresponding `*ModelExtractor` static class to read annotations from the source and target models, then compares them to generate appropriate migration operations (Create, Alter, Drop).
+Each TimescaleDB feature has a dedicated differ implementing `IFeatureDiffer`. The differ uses a corresponding `*ModelExtractor` static class to read annotations from the source and target models, then compares them to generate appropriate migration operations (Create, Alter, Drop). A `FeatureDiffContext` carries rename maps and recreated-aggregate state the differ cannot derive on its own (see architecture.md).
Example (`HypertableDiffer`):
```csharp
public class HypertableDiffer : IFeatureDiffer
{
- public IEnumerable GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
HypertableInfo? sourceInfo = HypertableModelExtractor.Extract(source);
HypertableInfo? targetInfo = HypertableModelExtractor.Extract(target);
- return CompareDifferences(sourceInfo, targetInfo);
+ return CompareDifferences(sourceInfo, targetInfo, context);
}
}
```
-All differs are registered in `TimescaleMigrationsModelDiffer`'s `_featureDiffers` list.
+`TimescaleMigrationsModelDiffer.GetDifferences()` runs EF Core's base differ, builds the `FeatureDiffContext`, then invokes each feature differ with it.
**Location:** `Internals/Features/{Feature}/` — check the source for the full list of feature differs.
## 5. Runtime vs Design-Time Duality
-| Context | Generator | Quote String | isDesignTime |
-|---------|-----------|--------------|--------------|
-| Runtime (`dotnet ef database update`) | `TimescaleDbMigrationsSqlGenerator` | `"` | `false` |
-| Design-time (`dotnet ef migrations add`) | `TimescaleCSharpMigrationOperationGenerator` | `""` | `true` |
+The same custom `MigrationOperation` types feed two independent code paths:
-Both use the same operation generators with different `isDesignTime` parameter values.
+| Context | Entry point | Generators | Output |
+|---------|-------------|------------|--------|
+| Runtime (`dotnet ef database update`) | `TimescaleDbMigrationsSqlGenerator` | `Generators/*SqlGenerator` | TimescaleDB SQL statements |
+| Design-time (`dotnet ef migrations add`) | `TimescaleCSharpMigrationOperationGenerator` | `Design/Generators/*CSharpGenerator` | Typed `migrationBuilder.*` calls |
+
+The design-time path emits typed calls (e.g. `migrationBuilder.CreateHypertable(...)`) from `MigrationExtensions/`; those operations are turned into SQL by the runtime path at `database update` time.
## 6. Annotation-Based Metadata Storage
@@ -114,44 +117,39 @@ This automatically handles snake_case, camelCase, PascalCase, and custom naming
**Location:** `Internals/Features/{Feature}/{Feature}ModelExtractor.cs`
-## 8. SQL Generation with Quote Escaping
+## 8. SQL Building Helpers
-**SqlBuilderHelper** provides utilities for proper quoting:
+`*SqlGenerator` classes build identifiers and table references through `SqlBuilderHelper`:
```csharp
-// Runtime SQL (isDesignTime = false)
-string sql = SqlBuilderHelper.BuildQueryString("SELECT * FROM \"my_table\"", builder, isDesignTime: false);
-// Output: SELECT * FROM "my_table"
-
-// Design-time C# (isDesignTime = true)
-string csharp = SqlBuilderHelper.BuildQueryString("SELECT * FROM \"my_table\"", builder, isDesignTime: true);
-// Output: SELECT * FROM ""my_table"" (quotes doubled for C# string escaping)
+SqlBuilderHelper.Regclass("my_table", "custom_schema"); // 'custom_schema."my_table"'
+SqlBuilderHelper.QualifiedIdentifier("my_table", "custom_schema"); // "custom_schema"."my_table"
+SqlBuilderHelper.QuoteIdentifier("my_column"); // "my_column"
```
-**Critical:** Always pass `isDesignTime` parameter correctly to operation generators.
+`SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform)` groups the generated statements into commands and appends them to the `MigrationCommandListBuilder`. When `usePerform` is set (idempotent scripts), leading `SELECT` keywords are rewritten to `PERFORM` so the SQL is valid inside a PL/pgSQL block.
-**Location:** `Generators/SqlBuilderHelper.cs`
+**Location:** `Generators/SqlBuilderHelper.cs`, `Generators/PolicyJobSqlBuilder.cs`
-## 9. Continuous Aggregate String Encoding
+## 9. Continuous Aggregate Function Encoding
-Aggregate functions are stored as colon-delimited strings in annotations:
+The typed API uses `Abstractions/ContinuousAggregateFunction` — `(Alias, Function, SourceColumn)` — for each aggregate column:
-**Format:**
-- Basic: `"alias:function:sourceColumn"`
-- First/Last: `"alias:function:sourceColumn:timeColumn"`
-
-**Examples:**
```csharp
-// Avg aggregate
-"avg_price:Avg:price"
-
-// Last aggregate with time column
-"last_price:Last:price:timestamp"
+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"`
+
+**Examples:** `"average_price:Avg:price"`, `"last_price:Last:price:timestamp"`
+
**Parsing:** Split by `:` and validate array length (3 or 4 elements).
-**Location:** `ContinuousAggregateModelExtractor.cs`, `ContinuousAggregateOperationGenerator.cs`
+**Location:** `Abstractions/ContinuousAggregateFunction.cs`, `ContinuousAggregateModelExtractor.cs`, `Generators/ContinuousAggregateSqlGenerator.cs`
## 10. Expression-Based Configuration
@@ -186,19 +184,19 @@ Lambda expressions are parsed to extract property names (via `LambdaExpression.B
## 11. DRY Principle Implementation
-- Extract common logic into helper methods (`SqlBuilderHelper`)
+- Extract common logic into helper methods (`SqlBuilderHelper`, `PolicyJobSqlBuilder`)
- Centralize constants in `DefaultValues.cs` and annotation name classes
- Use `StoreObjectIdentifier` pattern consistently across extractors
-- Avoid duplicating SQL generation logic - use operation generators consistently
+- Avoid duplicating SQL generation logic - route it through the `*SqlGenerator` classes
```csharp
// Correct - Centralized helper
-string tableName = SqlBuilderHelper.GetQualifiedTableName(schema, table, _quoteString);
+string qualifiedName = SqlBuilderHelper.QualifiedIdentifier(table, schema);
// Incorrect - Duplicated logic
-string tableName = string.IsNullOrEmpty(schema)
- ? $"{_quoteString}{table}{_quoteString}"
- : $"{_quoteString}{schema}{_quoteString}.{_quoteString}{table}{_quoteString}";
+string qualifiedName = string.IsNullOrEmpty(schema)
+ ? $"\"{table}\""
+ : $"\"{schema}\".\"{table}\"";
```
## 12. Separation of Concerns
@@ -210,8 +208,10 @@ Keep each class focused on a single responsibility:
| Configuration | User-facing APIs | Attributes, Fluent API, Conventions |
| Model Extraction | Read from EF metadata | `*ModelExtractor` classes |
| Diffing | Compare models, generate operations | `*Differ` classes |
-| Generation | Convert operations to SQL/C# | `*OperationGenerator` classes |
-| Design-time | Reverse engineer from database | Scaffolding extractors/appliers |
+| Runtime SQL | Convert operations to SQL | `Generators/*SqlGenerator` classes |
+| Design-time C# | Convert operations to typed migration calls | `Design/Generators/*CSharpGenerator` classes |
+| Migration API | Construct operations from migration files | `MigrationExtensions/*MigrationExtensions` classes |
+| Scaffolding | Reverse engineer from database | Scaffolding extractors/appliers |
**Never mix concerns:** Extractors should not generate SQL, differs should not read databases.
@@ -219,12 +219,12 @@ Keep each class focused on a single responsibility:
// Correct - Separation of concerns
public class HypertableDiffer : IFeatureDiffer
{
- public IEnumerable GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
// Only diffing logic - delegates extraction to HypertableModelExtractor
HypertableInfo? sourceInfo = HypertableModelExtractor.Extract(source);
HypertableInfo? targetInfo = HypertableModelExtractor.Extract(target);
- return CompareDifferences(sourceInfo, targetInfo);
+ return CompareDifferences(sourceInfo, targetInfo, context ?? FeatureDiffContext.Empty);
}
}
```
diff --git a/.editorconfig b/.editorconfig
index 6757b53..a770ddc 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,3 +5,10 @@ csharp_style_prefer_switch_expression = false
# IDE0079: Remove unnecessary suppression
dotnet_diagnostic.IDE0079.severity = none
+
+# MigrationBuilder extension methods intentionally live in the EF Core namespace
+# (Microsoft.EntityFrameworkCore.Migrations) so generated migrations resolve them
+# without an extra using. The namespace deliberately does not match the folder,
+# so suppress IDE0130 for these files.
+[**/MigrationExtensions/*.cs]
+dotnet_diagnostic.IDE0130.severity = none
diff --git a/docs/01-dotnet-tools.md b/docs/01-dotnet-tools.md
index 2ad2782..87c55dc 100644
--- a/docs/01-dotnet-tools.md
+++ b/docs/01-dotnet-tools.md
@@ -17,7 +17,7 @@ dotnet add package CmdScale.EntityFrameworkCore.TimescaleDB.Design --version 0.2
## Code-First: Generating Migrations
-When using the code-first approach, the design package ensures that your hypertable configurations, compression settings, and policies are correctly translated into SQL within your migration files.
+When using the code-first approach, the design package ensures that your hypertable configurations, compression settings, and policies are correctly translated into TimescaleDB migration calls within your migration files.
### Usage
@@ -28,7 +28,7 @@ dotnet ef migrations add --project --startup-p
```
### Example
-The generated 'Up' method will contain standard table creation logic, followed by a 'migrationBuilder.Sql()' call containing all the necessary Sql-statements to configure the hypertable and its features in TimescaleDB.
+The generated 'Up' method will contain standard table creation logic, followed by typed `migrationBuilder` extension calls (such as `CreateHypertable`) that configure the hypertable and its features. These calls are translated into the required TimescaleDB SQL when the migration is applied.
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
@@ -49,32 +49,33 @@ protected override void Up(MigrationBuilder migrationBuilder)
table.PrimaryKey("PK_DeviceReadings", x => new { x.Id, x.Time });
});
- // SQL generated by CmdScale.EntityFrameworkCore.TimescaleDB.Design
- migrationBuilder.Sql(@"
- SELECT create_hypertable('""DeviceReadings""', 'Time');
- SELECT set_chunk_time_interval('""DeviceReadings""', INTERVAL '1 day');
- ALTER TABLE ""DeviceReadings"" SET (timescaledb.compress = true);
- SET timescaledb.enable_chunk_skipping = 'ON';
- SELECT enable_chunk_skipping('""DeviceReadings""', 'Time');
- ");
+ // TimescaleDB configuration generated by CmdScale.EntityFrameworkCore.TimescaleDB.Design
+ migrationBuilder.CreateHypertable(
+ tableName: "DeviceReadings",
+ timeColumnName: "Time",
+ chunkTimeInterval: "1 day",
+ enableCompression: true,
+ chunkSkipColumns: ["Time"]);
}
```
-The tool also correctly generates `Down` migrations to revert changes, such as modifying a chunk time interval.
+The tool also correctly generates `Down` migrations to revert changes, such as modifying a chunk time interval. An altered hypertable emits an `AlterHypertable` call that carries both the new value and the previous one (`oldChunkTimeInterval`) so the change is fully reversible.
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
- migrationBuilder.Sql(@"
- SELECT set_chunk_time_interval('""TradesWithId""', INTERVAL '7 days');
- ");
+ migrationBuilder.AlterHypertable(
+ tableName: "TradesWithId",
+ chunkTimeInterval: "7 days",
+ oldChunkTimeInterval: "2 day");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
- migrationBuilder.Sql(@"
- SELECT set_chunk_time_interval('""TradesWithId""', INTERVAL '2 day');
- ");
+ migrationBuilder.AlterHypertable(
+ tableName: "TradesWithId",
+ chunkTimeInterval: "2 day",
+ oldChunkTimeInterval: "7 days");
}
```
diff --git a/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs b/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs
new file mode 100644
index 0000000..270b343
--- /dev/null
+++ b/src/Eftdb.Design/Generators/CSharpGeneratorHelper.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators
+{
+ ///
+ /// Shared helpers for emitting C# literals into migration files.
+ ///
+ internal static class CSharpGeneratorHelper
+ {
+ ///
+ /// Formats a string collection as a C# collection expression, e.g. ["a", "b"].
+ ///
+ public static string LiteralStringList(ICSharpHelper code, IReadOnlyList items)
+ {
+ string elements = string.Join(", ", items.Select(code.Literal));
+ return $"[{elements}]";
+ }
+
+ ///
+ /// Formats a static method call like TypeRef.Method(arg1, arg2), literalizing
+ /// each argument via so primitive types are
+ /// rendered correctly without per-call Literal boilerplate.
+ ///
+ public static string StaticCall(ICSharpHelper code, string typeRef, string method, params object?[] args)
+ {
+ string argList = string.Join(", ", args.Select(code.UnknownLiteral));
+ return $"{typeRef}.{method}({argList})";
+ }
+ }
+}
diff --git a/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs
new file mode 100644
index 0000000..50a6f10
--- /dev/null
+++ b/src/Eftdb.Design/Generators/ContinuousAggregateCSharpGenerator.cs
@@ -0,0 +1,128 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
+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 ContinuousAggregateCSharpGenerator(ICSharpHelper code)
+ {
+ private readonly ICSharpHelper code = code;
+
+ public void Generate(CreateContinuousAggregateOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "CreateContinuousAggregate");
+
+ call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName));
+ call.Arg("parentName", code.Literal(operation.ParentName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.ChunkInterval))
+ call.Arg("chunkInterval", code.Literal(operation.ChunkInterval));
+
+ if (operation.WithNoData)
+ call.Arg("withNoData", code.Literal(true));
+
+ if (operation.CreateGroupIndexes)
+ call.Arg("createGroupIndexes", code.Literal(true));
+
+ if (operation.MaterializedOnly)
+ call.Arg("materializedOnly", code.Literal(true));
+
+ if (!string.IsNullOrEmpty(operation.TimeBucketWidth))
+ call.Arg("timeBucketWidth", code.Literal(operation.TimeBucketWidth));
+
+ if (!string.IsNullOrEmpty(operation.TimeBucketSourceColumn))
+ call.Arg("timeBucketSourceColumn", code.Literal(operation.TimeBucketSourceColumn));
+
+ // timeBucketGroupBy defaults to true — only emit when explicitly disabled.
+ if (!operation.TimeBucketGroupBy)
+ call.Arg("timeBucketGroupBy", code.Literal(false));
+
+ if (operation.AggregateFunctions is { Count: > 0 })
+ call.Arg("aggregateFunctions", b => AppendAggregateFunctionList(b, operation.AggregateFunctions));
+
+ if (operation.GroupByColumns is { Count: > 0 })
+ call.Arg("groupByColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.GroupByColumns));
+
+ if (!string.IsNullOrEmpty(operation.WhereClause))
+ call.Arg("whereClause", code.Literal(operation.WhereClause));
+
+ if (!string.IsNullOrEmpty(operation.ViewDefinition))
+ call.Arg("viewDefinition", code.Literal(operation.ViewDefinition));
+ }
+
+ public void Generate(AlterContinuousAggregateOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AlterContinuousAggregate");
+
+ call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.ChunkInterval))
+ call.Arg("chunkInterval", code.Literal(operation.ChunkInterval));
+
+ if (operation.CreateGroupIndexes)
+ call.Arg("createGroupIndexes", code.Literal(true));
+
+ if (operation.MaterializedOnly)
+ call.Arg("materializedOnly", code.Literal(true));
+
+ // Old* values — emitted for Down() reversibility, only when non-default.
+ if (!string.IsNullOrEmpty(operation.OldChunkInterval))
+ call.Arg("oldChunkInterval", code.Literal(operation.OldChunkInterval));
+
+ if (operation.OldCreateGroupIndexes)
+ call.Arg("oldCreateGroupIndexes", code.Literal(true));
+
+ if (operation.OldMaterializedOnly)
+ call.Arg("oldMaterializedOnly", code.Literal(true));
+ }
+
+ public void Generate(DropContinuousAggregateOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "DropContinuousAggregate");
+
+ call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+ }
+
+ // Writes the aggregate functions as a collection expression
+ private void AppendAggregateFunctionList(IndentedStringBuilder builder, IReadOnlyList aggregateFunctions)
+ {
+ string typeRef = typeof(ContinuousAggregateFunction).FullName!;
+ string enumRef = typeof(EAggregateFunction).FullName!;
+
+ List entries = [];
+ foreach (string aggregateFunction in aggregateFunctions)
+ {
+ string[] parts = aggregateFunction.Split(':');
+ if (parts.Length != 3)
+ {
+ continue;
+ }
+
+ entries.Add($"new {typeRef}({code.Literal(parts[0])}, {enumRef}.{parts[1]}, {code.Literal(parts[2])})");
+ }
+
+ builder.AppendLine("[");
+ using (builder.Indent())
+ {
+ for (int i = 0; i < entries.Count; i++)
+ {
+ builder.AppendLine(i < entries.Count - 1 ? entries[i] + "," : entries[i]);
+ }
+ }
+ builder.Append("]");
+ }
+ }
+}
diff --git a/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs
new file mode 100644
index 0000000..19acdac
--- /dev/null
+++ b/src/Eftdb.Design/Generators/ContinuousAggregatePolicyCSharpGenerator.cs
@@ -0,0 +1,65 @@
+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 ContinuousAggregatePolicyCSharpGenerator(ICSharpHelper code)
+ {
+ private readonly ICSharpHelper code = code;
+
+ public void Generate(AddContinuousAggregatePolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AddContinuousAggregatePolicy");
+
+ call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.StartOffset))
+ call.Arg("startOffset", code.Literal(operation.StartOffset));
+
+ if (!string.IsNullOrEmpty(operation.EndOffset))
+ call.Arg("endOffset", code.Literal(operation.EndOffset));
+
+ 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 (operation.IfNotExists)
+ call.Arg("ifNotExists", code.Literal(true));
+
+ if (operation.IncludeTieredData.HasValue)
+ call.Arg("includeTieredData", code.Literal(operation.IncludeTieredData.Value));
+
+ if (operation.BucketsPerBatch != 1)
+ call.Arg("bucketsPerBatch", code.Literal(operation.BucketsPerBatch));
+
+ if (operation.MaxBatchesPerExecution != 0)
+ call.Arg("maxBatchesPerExecution", code.Literal(operation.MaxBatchesPerExecution));
+
+ // refreshNewestFirst defaults to true — only emit when explicitly disabled.
+ if (!operation.RefreshNewestFirst)
+ call.Arg("refreshNewestFirst", code.Literal(false));
+ }
+
+ public void Generate(RemoveContinuousAggregatePolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "RemoveContinuousAggregatePolicy");
+
+ call.Arg("materializedViewName", code.Literal(operation.MaterializedViewName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (operation.IfExists)
+ call.Arg("ifExists", code.Literal(true));
+ }
+ }
+}
diff --git a/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs
new file mode 100644
index 0000000..2d8ab81
--- /dev/null
+++ b/src/Eftdb.Design/Generators/HypertableCSharpGenerator.cs
@@ -0,0 +1,118 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
+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 HypertableCSharpGenerator(ICSharpHelper code)
+ {
+ private readonly ICSharpHelper code = code;
+
+ public void Generate(CreateHypertableOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "CreateHypertable");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+ call.Arg("timeColumnName", code.Literal(operation.TimeColumnName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.ChunkTimeInterval))
+ call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval));
+
+ if (operation.EnableCompression)
+ call.Arg("enableCompression", code.Literal(true));
+
+ if (operation.MigrateData)
+ call.Arg("migrateData", code.Literal(true));
+
+ if (operation.ChunkSkipColumns is { Count: > 0 })
+ call.Arg("chunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.ChunkSkipColumns));
+
+ if (operation.AdditionalDimensions is { Count: > 0 })
+ call.Arg("additionalDimensions", b => AppendDimensionList(b, operation.AdditionalDimensions));
+
+ 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(AlterHypertableOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AlterHypertable");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.ChunkTimeInterval))
+ call.Arg("chunkTimeInterval", code.Literal(operation.ChunkTimeInterval));
+
+ if (operation.EnableCompression)
+ call.Arg("enableCompression", code.Literal(true));
+
+ if (operation.ChunkSkipColumns is { Count: > 0 })
+ call.Arg("chunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.ChunkSkipColumns));
+
+ if (operation.AdditionalDimensions is { Count: > 0 })
+ call.Arg("additionalDimensions", b => AppendDimensionList(b, operation.AdditionalDimensions));
+
+ 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));
+
+ // Old* values — emitted for Down() reversibility, only when non-default.
+ if (!string.IsNullOrEmpty(operation.OldChunkTimeInterval))
+ call.Arg("oldChunkTimeInterval", code.Literal(operation.OldChunkTimeInterval));
+
+ if (operation.OldEnableCompression)
+ call.Arg("oldEnableCompression", code.Literal(true));
+
+ if (operation.OldChunkSkipColumns is { Count: > 0 })
+ call.Arg("oldChunkSkipColumns", CSharpGeneratorHelper.LiteralStringList(code, operation.OldChunkSkipColumns));
+
+ if (operation.OldAdditionalDimensions is { Count: > 0 })
+ call.Arg("oldAdditionalDimensions", b => AppendDimensionList(b, operation.OldAdditionalDimensions));
+
+ 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));
+ }
+
+ // Writes the dimension list directly into the builder so each entry is on its own
+ // line at the correct indent level.
+ private void AppendDimensionList(IndentedStringBuilder builder, IReadOnlyList dimensions)
+ {
+ // Migration files only import Microsoft.EntityFrameworkCore.Migrations.
+ // Fully qualify Dimension so generated code compiles without extra usings.
+ string typeRef = typeof(Dimension).FullName!;
+
+ builder.AppendLine("[");
+ using (builder.Indent())
+ {
+ for (int i = 0; i < dimensions.Count; i++)
+ {
+ Dimension d = dimensions[i];
+ string entry = d.Type == EDimensionType.Hash
+ ? CSharpGeneratorHelper.StaticCall(code, typeRef, nameof(Dimension.CreateHash), d.ColumnName, d.NumberOfPartitions ?? 0)
+ : CSharpGeneratorHelper.StaticCall(code, typeRef, nameof(Dimension.CreateRange), d.ColumnName, d.Interval ?? string.Empty);
+
+ builder.AppendLine(i < dimensions.Count - 1 ? entry + "," : entry);
+ }
+ }
+ builder.Append("]");
+ }
+ }
+}
diff --git a/src/Eftdb.Design/Generators/MigrationCallWriter.cs b/src/Eftdb.Design/Generators/MigrationCallWriter.cs
new file mode 100644
index 0000000..ab57b94
--- /dev/null
+++ b/src/Eftdb.Design/Generators/MigrationCallWriter.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators
+{
+ ///
+ /// Writes a typed migrationBuilder call with one named argument per line.
+ ///
+ internal sealed class MigrationCallWriter : IDisposable
+ {
+ private readonly IndentedStringBuilder builder;
+ private readonly IDisposable indent;
+ private bool hasArgs;
+
+ public MigrationCallWriter(IndentedStringBuilder builder, string methodName)
+ {
+ this.builder = builder;
+ builder.AppendLine($".{methodName}(");
+ indent = builder.Indent();
+ }
+
+ /// Adds a named argument whose value is the given pre-rendered literal.
+ public void Arg(string name, string renderedValue)
+ {
+ WriteName(name);
+ builder.Append(renderedValue);
+ }
+
+ ///
+ /// Adds a named argument whose value is written directly into the builder, for
+ /// multi-line values.
+ ///
+ public void Arg(string name, Action writeValue)
+ {
+ WriteName(name);
+ writeValue(builder);
+ }
+
+ private void WriteName(string name)
+ {
+ if (hasArgs)
+ {
+ builder.AppendLine(",");
+ }
+
+ hasArgs = true;
+ builder.Append(name).Append(": ");
+ }
+
+ public void Dispose()
+ {
+ builder.Append(")");
+ indent.Dispose();
+ }
+ }
+}
diff --git a/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs
new file mode 100644
index 0000000..eb0983e
--- /dev/null
+++ b/src/Eftdb.Design/Generators/ReorderPolicyCSharpGenerator.cs
@@ -0,0 +1,95 @@
+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 ReorderPolicyCSharpGenerator(ICSharpHelper code)
+ {
+ private readonly ICSharpHelper code = code;
+
+ public void Generate(AddReorderPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AddReorderPolicy");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+ call.Arg("indexName", code.Literal(operation.IndexName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (operation.InitialStart.HasValue)
+ call.Arg("initialStart", code.Literal(operation.InitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.ScheduleInterval))
+ call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.MaxRuntime))
+ call.Arg("maxRuntime", code.Literal(operation.MaxRuntime));
+
+ if (operation.MaxRetries.HasValue)
+ call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.RetryPeriod))
+ call.Arg("retryPeriod", code.Literal(operation.RetryPeriod));
+ }
+
+ public void Generate(AlterReorderPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AlterReorderPolicy");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+ call.Arg("indexName", code.Literal(operation.IndexName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (operation.InitialStart.HasValue)
+ call.Arg("initialStart", code.Literal(operation.InitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.ScheduleInterval))
+ call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.MaxRuntime))
+ call.Arg("maxRuntime", code.Literal(operation.MaxRuntime));
+
+ if (operation.MaxRetries.HasValue)
+ call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.RetryPeriod))
+ call.Arg("retryPeriod", code.Literal(operation.RetryPeriod));
+
+ // Old* values — emitted for Down() reversibility, only when non-default.
+ if (!string.IsNullOrEmpty(operation.OldIndexName))
+ call.Arg("oldIndexName", code.Literal(operation.OldIndexName));
+
+ if (operation.OldInitialStart.HasValue)
+ call.Arg("oldInitialStart", code.Literal(operation.OldInitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.OldScheduleInterval))
+ call.Arg("oldScheduleInterval", code.Literal(operation.OldScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.OldMaxRuntime))
+ call.Arg("oldMaxRuntime", code.Literal(operation.OldMaxRuntime));
+
+ if (operation.OldMaxRetries.HasValue)
+ call.Arg("oldMaxRetries", code.Literal(operation.OldMaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.OldRetryPeriod))
+ call.Arg("oldRetryPeriod", code.Literal(operation.OldRetryPeriod));
+ }
+
+ public void Generate(DropReorderPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "DropReorderPolicy");
+
+ 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/RetentionPolicyCSharpGenerator.cs b/src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs
new file mode 100644
index 0000000..1651d4f
--- /dev/null
+++ b/src/Eftdb.Design/Generators/RetentionPolicyCSharpGenerator.cs
@@ -0,0 +1,108 @@
+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 RetentionPolicyCSharpGenerator(ICSharpHelper code)
+ {
+ private readonly ICSharpHelper code = code;
+
+ public void Generate(AddRetentionPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AddRetentionPolicy");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.DropAfter))
+ call.Arg("dropAfter", code.Literal(operation.DropAfter));
+
+ if (!string.IsNullOrEmpty(operation.DropCreatedBefore))
+ call.Arg("dropCreatedBefore", code.Literal(operation.DropCreatedBefore));
+
+ if (operation.InitialStart.HasValue)
+ call.Arg("initialStart", code.Literal(operation.InitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.ScheduleInterval))
+ call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.MaxRuntime))
+ call.Arg("maxRuntime", code.Literal(operation.MaxRuntime));
+
+ if (operation.MaxRetries.HasValue)
+ call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.RetryPeriod))
+ call.Arg("retryPeriod", code.Literal(operation.RetryPeriod));
+ }
+
+ public void Generate(AlterRetentionPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "AlterRetentionPolicy");
+
+ call.Arg("tableName", code.Literal(operation.TableName));
+
+ if (!string.IsNullOrEmpty(operation.Schema))
+ call.Arg("schema", code.Literal(operation.Schema));
+
+ if (!string.IsNullOrEmpty(operation.DropAfter))
+ call.Arg("dropAfter", code.Literal(operation.DropAfter));
+
+ if (!string.IsNullOrEmpty(operation.DropCreatedBefore))
+ call.Arg("dropCreatedBefore", code.Literal(operation.DropCreatedBefore));
+
+ if (operation.InitialStart.HasValue)
+ call.Arg("initialStart", code.Literal(operation.InitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.ScheduleInterval))
+ call.Arg("scheduleInterval", code.Literal(operation.ScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.MaxRuntime))
+ call.Arg("maxRuntime", code.Literal(operation.MaxRuntime));
+
+ if (operation.MaxRetries.HasValue)
+ call.Arg("maxRetries", code.Literal(operation.MaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.RetryPeriod))
+ call.Arg("retryPeriod", code.Literal(operation.RetryPeriod));
+
+ // Old* values — emitted for Down() reversibility, only when non-default.
+ if (!string.IsNullOrEmpty(operation.OldDropAfter))
+ call.Arg("oldDropAfter", code.Literal(operation.OldDropAfter));
+
+ if (!string.IsNullOrEmpty(operation.OldDropCreatedBefore))
+ call.Arg("oldDropCreatedBefore", code.Literal(operation.OldDropCreatedBefore));
+
+ if (operation.OldInitialStart.HasValue)
+ call.Arg("oldInitialStart", code.Literal(operation.OldInitialStart.Value));
+
+ if (!string.IsNullOrEmpty(operation.OldScheduleInterval))
+ call.Arg("oldScheduleInterval", code.Literal(operation.OldScheduleInterval));
+
+ if (!string.IsNullOrEmpty(operation.OldMaxRuntime))
+ call.Arg("oldMaxRuntime", code.Literal(operation.OldMaxRuntime));
+
+ if (operation.OldMaxRetries.HasValue)
+ call.Arg("oldMaxRetries", code.Literal(operation.OldMaxRetries.Value));
+
+ if (!string.IsNullOrEmpty(operation.OldRetryPeriod))
+ call.Arg("oldRetryPeriod", code.Literal(operation.OldRetryPeriod));
+ }
+
+ public void Generate(DropRetentionPolicyOperation operation, IndentedStringBuilder builder)
+ {
+ using MigrationCallWriter call = new(builder, "DropRetentionPolicy");
+
+ 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/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs
index 306cb1f..1801030 100644
--- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs
+++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs
@@ -1,4 +1,4 @@
-using CmdScale.EntityFrameworkCore.TimescaleDB.Generators;
+using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Generators;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations.Design;
@@ -13,90 +13,76 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(builder);
- HypertableOperationGenerator? hypertableOperationGenerator = null;
- ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null;
- RetentionPolicyOperationGenerator? retentionPolicyOperationGenerator = null;
- ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null;
- ContinuousAggregatePolicyOperationGenerator? continuousAggregatePolicyOperationGenerator = null;
-
- List statements;
- bool suppressTransaction = false;
+ HypertableCSharpGenerator? hypertableCSharpGenerator = null;
+ ReorderPolicyCSharpGenerator? reorderPolicyCSharpGenerator = null;
+ RetentionPolicyCSharpGenerator? retentionPolicyCSharpGenerator = null;
+ ContinuousAggregateCSharpGenerator? continuousAggregateCSharpGenerator = null;
+ ContinuousAggregatePolicyCSharpGenerator? continuousAggregatePolicyCSharpGenerator = null;
switch (operation)
{
case CreateHypertableOperation create:
- hypertableOperationGenerator ??= new(isDesignTime: true);
- statements = hypertableOperationGenerator.Generate(create);
- break;
+ hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ hypertableCSharpGenerator.Generate(create, builder);
+ return;
case AlterHypertableOperation alter:
- hypertableOperationGenerator ??= new(isDesignTime: true);
- statements = hypertableOperationGenerator.Generate(alter);
- break;
+ hypertableCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ hypertableCSharpGenerator.Generate(alter, builder);
+ return;
case AddReorderPolicyOperation addReorder:
- reorderPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = reorderPolicyOperationGenerator.Generate(addReorder);
- break;
+ reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ reorderPolicyCSharpGenerator.Generate(addReorder, builder);
+ return;
case AlterReorderPolicyOperation alterReorder:
- reorderPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = reorderPolicyOperationGenerator.Generate(alterReorder);
- break;
+ reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ reorderPolicyCSharpGenerator.Generate(alterReorder, builder);
+ return;
case DropReorderPolicyOperation dropReorder:
- reorderPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = reorderPolicyOperationGenerator.Generate(dropReorder);
- break;
+ reorderPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ reorderPolicyCSharpGenerator.Generate(dropReorder, builder);
+ return;
case AddRetentionPolicyOperation addRetention:
- retentionPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = retentionPolicyOperationGenerator.Generate(addRetention);
- break;
+ retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ retentionPolicyCSharpGenerator.Generate(addRetention, builder);
+ return;
case AlterRetentionPolicyOperation alterRetention:
- retentionPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = retentionPolicyOperationGenerator.Generate(alterRetention);
- break;
+ retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ retentionPolicyCSharpGenerator.Generate(alterRetention, builder);
+ return;
case DropRetentionPolicyOperation dropRetention:
- retentionPolicyOperationGenerator ??= new(isDesignTime: true);
- statements = retentionPolicyOperationGenerator.Generate(dropRetention);
- break;
+ retentionPolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ retentionPolicyCSharpGenerator.Generate(dropRetention, builder);
+ return;
case CreateContinuousAggregateOperation createContinuousAggregate:
- continuousAggregateOperationGenerator ??= new(isDesignTime: true);
- statements = continuousAggregateOperationGenerator.Generate(createContinuousAggregate);
- suppressTransaction = true;
- break;
+ continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ continuousAggregateCSharpGenerator.Generate(createContinuousAggregate, builder);
+ return;
case AlterContinuousAggregateOperation alterContinuousAggregate:
- continuousAggregateOperationGenerator ??= new(isDesignTime: true);
- statements = continuousAggregateOperationGenerator.Generate(alterContinuousAggregate);
- break;
+ continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ continuousAggregateCSharpGenerator.Generate(alterContinuousAggregate, builder);
+ return;
case DropContinuousAggregateOperation dropContinuousAggregate:
- continuousAggregateOperationGenerator ??= new(isDesignTime: true);
- statements = continuousAggregateOperationGenerator.Generate(dropContinuousAggregate);
- break;
+ continuousAggregateCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ continuousAggregateCSharpGenerator.Generate(dropContinuousAggregate, builder);
+ return;
case AddContinuousAggregatePolicyOperation addContinuousAggregatePolicy:
- continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: true);
- statements = continuousAggregatePolicyOperationGenerator.Generate(addContinuousAggregatePolicy);
- break;
+ continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ continuousAggregatePolicyCSharpGenerator.Generate(addContinuousAggregatePolicy, builder);
+ return;
case RemoveContinuousAggregatePolicyOperation removeContinuousAggregatePolicy:
- continuousAggregatePolicyOperationGenerator ??= new(isDesignTime: true);
- statements = continuousAggregatePolicyOperationGenerator.Generate(removeContinuousAggregatePolicy);
- break;
+ continuousAggregatePolicyCSharpGenerator ??= new(Dependencies.CSharpHelper);
+ continuousAggregatePolicyCSharpGenerator.Generate(removeContinuousAggregatePolicy, builder);
+ return;
default:
base.Generate(operation, builder);
return;
}
-
- // Guard: if no statements were generated, output a no-op SQL comment to maintain valid C# syntax.
- if (statements.Count == 0)
- {
- builder.Append(".Sql(@\"-- No SQL generated for this operation\")");
- return;
- }
-
- SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction);
}
-
}
}
diff --git a/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs b/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs
new file mode 100644
index 0000000..a1e34fe
--- /dev/null
+++ b/src/Eftdb/Abstractions/ContinuousAggregateFunction.cs
@@ -0,0 +1,25 @@
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions
+{
+ ///
+ /// Maps a column on a continuous aggregate to an aggregate function applied to a source
+ /// hypertable column — e.g. average_temperature = AVG(temperature).
+ /// Used as the strongly typed vocabulary for the aggregateFunctions argument.
+ ///
+ public sealed class ContinuousAggregateFunction(string alias, EAggregateFunction function, string sourceColumn)
+ {
+ /// The name of the resulting column on the continuous aggregate.
+ public string Alias { get; } = alias;
+
+ /// The aggregate function to apply.
+ public EAggregateFunction Function { get; } = function;
+
+ /// The source hypertable column to aggregate.
+ public string SourceColumn { get; } = sourceColumn;
+
+ ///
+ /// Serializes to the alias:Function:sourceColumn wire format stored on
+ /// CreateContinuousAggregateOperation.AggregateFunctions.
+ ///
+ public string ToAnnotationValue() => $"{Alias}:{Function}:{SourceColumn}";
+ }
+}
diff --git a/src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs
similarity index 78%
rename from src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs
rename to src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs
index abe08a8..4a411fc 100644
--- a/src/Eftdb/Generators/ContinuousAggregatePolicyOperationGenerator.cs
+++ b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs
@@ -6,33 +6,16 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
///
/// Generates SQL for continuous aggregate refresh policy operations.
///
- public class ContinuousAggregatePolicyOperationGenerator
+ public class ContinuousAggregatePolicySqlGenerator
{
- private readonly string quoteString = "\"";
- private readonly SqlBuilderHelper sqlHelper;
-
- ///
- /// Initializes a new instance of the ContinuousAggregatePolicyOperationGenerator class.
- ///
- /// Whether this generator is being used at design-time (for C# code generation) or runtime (for SQL execution).
- public ContinuousAggregatePolicyOperationGenerator(bool isDesignTime = false)
- {
- if (isDesignTime)
- {
- quoteString = "\"\"";
- }
-
- sqlHelper = new SqlBuilderHelper(quoteString);
- }
-
///
/// Generates SQL statements for adding a continuous aggregate refresh policy.
///
/// The add policy operation.
/// A list of SQL statements to execute.
- public List Generate(AddContinuousAggregatePolicyOperation operation)
+ public static List Generate(AddContinuousAggregatePolicyOperation operation)
{
- string qualifiedViewName = sqlHelper.Regclass(operation.MaterializedViewName, operation.Schema);
+ string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema);
List arguments = [];
@@ -119,9 +102,9 @@ public List Generate(AddContinuousAggregatePolicyOperation operation)
///
/// The remove policy operation.
/// A list of SQL statements to execute.
- public List Generate(RemoveContinuousAggregatePolicyOperation operation)
+ public static List Generate(RemoveContinuousAggregatePolicyOperation operation)
{
- string qualifiedViewName = sqlHelper.Regclass(operation.MaterializedViewName, operation.Schema);
+ string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema);
List arguments = [qualifiedViewName];
diff --git a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs
similarity index 83%
rename from src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
rename to src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs
index 9871555..b354af9 100644
--- a/src/Eftdb/Generators/ContinuousAggregateOperationGenerator.cs
+++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs
@@ -3,26 +3,12 @@
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
{
- public class ContinuousAggregateOperationGenerator
+ public class ContinuousAggregateSqlGenerator
{
- private readonly string quoteString = "\"";
- private readonly SqlBuilderHelper sqlHelper;
-
- public ContinuousAggregateOperationGenerator(bool isDesignTime = false)
- {
- if (isDesignTime)
- {
- quoteString = "\"\"";
- }
-
- sqlHelper = new SqlBuilderHelper(quoteString);
-
- }
-
- public List Generate(CreateContinuousAggregateOperation operation)
+ public static List Generate(CreateContinuousAggregateOperation operation)
{
- string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
- string parentQualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.ParentName, operation.Schema);
+ string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
+ string parentQualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.ParentName, operation.Schema);
List statements = [];
@@ -48,7 +34,7 @@ public List Generate(CreateContinuousAggregateOperation operation)
rawSqlBuilder.AppendLine();
rawSqlBuilder.Append($"WITH ({string.Join(", ", withOptions)}) AS");
rawSqlBuilder.AppendLine();
- rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';').Replace("\"", quoteString));
+ rawSqlBuilder.Append(operation.ViewDefinition!.Trim().TrimEnd(';'));
if (operation.WithNoData)
{
rawSqlBuilder.AppendLine();
@@ -63,7 +49,7 @@ public List Generate(CreateContinuousAggregateOperation operation)
List selectList = [];
// Add time_bucket column
- string timeBucketColumn = $"{quoteString}{operation.TimeBucketSourceColumn}{quoteString}";
+ string timeBucketColumn = $"{SqlBuilderHelper.QuoteIdentifier(operation.TimeBucketSourceColumn)}";
string timeBucketWidthSql = $"'{operation.TimeBucketWidth}'";
selectList.Add($"time_bucket({timeBucketWidthSql}, {timeBucketColumn}) AS time_bucket");
@@ -74,7 +60,7 @@ public List Generate(CreateContinuousAggregateOperation operation)
bool isRawSqlExpression = groupByColumn.Contains(',') || groupByColumn.Contains('(') || groupByColumn.Contains(' ');
if (!isRawSqlExpression)
{
- selectList.Add($"{quoteString}{groupByColumn}{quoteString}");
+ selectList.Add($"{SqlBuilderHelper.QuoteIdentifier(groupByColumn)}");
}
}
@@ -93,8 +79,8 @@ public List Generate(CreateContinuousAggregateOperation operation)
string sourceColumn = parts[2];
string sqlFunction = GetSqlAggregateFunction(functionEnumString);
- string quotedSourceColumn = $"{quoteString}{sourceColumn}{quoteString}";
- string quotedAlias = $"{quoteString}{alias}{quoteString}";
+ string quotedSourceColumn = $"{SqlBuilderHelper.QuoteIdentifier(sourceColumn)}";
+ string quotedAlias = $"{SqlBuilderHelper.QuoteIdentifier(alias)}";
string aggregateExpression;
// Handle special TimescaleDB aggregates 'first' and 'last'
@@ -129,7 +115,7 @@ public List Generate(CreateContinuousAggregateOperation operation)
else
{
// It's a column name, quote it
- groupByList.Add($"{quoteString}{groupByColumn}{quoteString}");
+ groupByList.Add($"{SqlBuilderHelper.QuoteIdentifier(groupByColumn)}");
}
}
@@ -146,7 +132,7 @@ public List Generate(CreateContinuousAggregateOperation operation)
// Add WHERE clause if specified
if (!string.IsNullOrWhiteSpace(operation.WhereClause))
{
- string whereClause = operation.WhereClause.Replace("\"", quoteString);
+ string whereClause = operation.WhereClause;
sqlBuilder.AppendLine();
sqlBuilder.Append($"WHERE {whereClause}");
}
@@ -171,9 +157,9 @@ public List Generate(CreateContinuousAggregateOperation operation)
return statements;
}
- public List Generate(AlterContinuousAggregateOperation operation)
+ public static List Generate(AlterContinuousAggregateOperation operation)
{
- string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
+ string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
List statements = [];
// Check for ChunkInterval change
@@ -213,9 +199,9 @@ public List Generate(AlterContinuousAggregateOperation operation)
return statements;
}
- public List Generate(DropContinuousAggregateOperation operation)
+ public static List Generate(DropContinuousAggregateOperation operation)
{
- string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
+ string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema);
List statements = [];
statements.Add($"DROP MATERIALIZED VIEW IF EXISTS {qualifiedIdentifier};");
diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs
similarity index 77%
rename from src/Eftdb/Generators/HypertableOperationGenerator.cs
rename to src/Eftdb/Generators/HypertableSqlGenerator.cs
index cd2ac0f..3b401ee 100644
--- a/src/Eftdb/Generators/HypertableOperationGenerator.cs
+++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs
@@ -1,48 +1,31 @@
-using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
+using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using System.Text;
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
{
- public class HypertableOperationGenerator
+ public class HypertableSqlGenerator
{
- private readonly string quoteString = "\"";
- private readonly SqlBuilderHelper sqlHelper;
-
- public HypertableOperationGenerator(bool isDesignTime = false)
- {
- if (isDesignTime)
- {
- quoteString = "\"\"";
- }
-
- sqlHelper = new SqlBuilderHelper(quoteString);
- }
-
- public List Generate(CreateHypertableOperation operation)
+ public static List Generate(CreateHypertableOperation operation)
{
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
- string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema);
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+ string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema);
List statements = [];
List communityStatements = [];
- // Build create_hypertable with chunk_time_interval if provided
StringBuilder createHypertableCall = new();
createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'");
createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : "");
if (!string.IsNullOrEmpty(operation.ChunkTimeInterval))
{
- // Check if the interval is a plain number (e.g., for microseconds).
if (long.TryParse(operation.ChunkTimeInterval, out _))
{
- // If it's a number, don't wrap it in quotes.
createHypertableCall.Append($", chunk_time_interval => {operation.ChunkTimeInterval}::bigint");
}
else
{
- // If it's a string like '7 days', wrap it in quotes.
createHypertableCall.Append($", chunk_time_interval => INTERVAL '{operation.ChunkTimeInterval}'");
}
}
@@ -65,7 +48,7 @@ public List Generate(CreateHypertableOperation operation)
if (hasSegmentBy)
{
- string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(QuoteIdentifier));
+ string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(SqlBuilderHelper.QuoteIdentifier));
compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'");
}
@@ -75,13 +58,11 @@ public List Generate(CreateHypertableOperation operation)
compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'");
}
- // If there are compression settings, add the ALTER TABLE SET (...) statement
if (compressionSettings.Count > 0)
{
communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});");
}
- // ChunkSkipColumns (Community Edition only)
if (operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0)
{
communityStatements.Add("SET timescaledb.enable_chunk_skipping = 'ON';");
@@ -92,14 +73,12 @@ public List Generate(CreateHypertableOperation operation)
}
}
- // AdditionalDimensions (Available in both editions)
if (operation.AdditionalDimensions != null && operation.AdditionalDimensions.Count > 0)
{
foreach (Dimension dimension in operation.AdditionalDimensions)
{
if (dimension.Type == EDimensionType.Range)
{
- // Detect if interval is numeric (integer range) or time-based (timestamp range)
bool isIntegerRange = long.TryParse(dimension.Interval, out _);
string intervalExpression = isIntegerRange
? dimension.Interval!
@@ -114,7 +93,6 @@ public List Generate(CreateHypertableOperation operation)
}
}
- // Add wrapped community statements if any exist
if (communityStatements.Count > 0)
{
statements.Add(WrapCommunityFeatures(communityStatements));
@@ -122,29 +100,25 @@ public List Generate(CreateHypertableOperation operation)
return statements;
}
- public List Generate(AlterHypertableOperation operation)
+ public static List Generate(AlterHypertableOperation operation)
{
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
- string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema);
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+ string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema);
List statements = [];
List communityStatements = [];
- // Check for ChunkTimeInterval change (Available in both editions)
if (operation.ChunkTimeInterval != operation.OldChunkTimeInterval)
{
StringBuilder setChunkTimeInterval = new();
setChunkTimeInterval.Append($"SELECT set_chunk_time_interval({qualifiedTableName}, ");
- // Check if the interval is a plain number (e.g., for microseconds).
if (long.TryParse(operation.ChunkTimeInterval, out _))
{
- // If it's a number, don't wrap it in quotes.
setChunkTimeInterval.Append($"{operation.ChunkTimeInterval}::bigint");
}
else
{
- // If it's a string like '7 days', wrap it in quotes.
setChunkTimeInterval.Append($"INTERVAL '{operation.ChunkTimeInterval}'");
}
@@ -177,7 +151,7 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy))
{
string val = (operation.CompressionSegmentBy?.Count > 0)
- ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(QuoteIdentifier))}'"
+ ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(SqlBuilderHelper.QuoteIdentifier))}'"
: "''";
compressionSettings.Add($"timescaledb.compress_segmentby = {val}");
}
@@ -190,13 +164,11 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
compressionSettings.Add($"timescaledb.compress_orderby = {val}");
}
- // If there are compression settings, add the ALTER TABLE SET (...) statement
if (compressionSettings.Count > 0)
{
communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});");
}
- // Handle ChunkSkipColumns (Community Edition only)
IReadOnlyList newColumns = operation.ChunkSkipColumns ?? [];
IReadOnlyList oldColumns = operation.OldChunkSkipColumns ?? [];
List addedColumns = [.. newColumns.Except(oldColumns)];
@@ -220,14 +192,11 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
}
}
- // Handle AdditionalDimensions - only add new dimensions
- // NOTE: TimescaleDB does NOT support removing dimensions from hypertables.
- // Once a dimension is added, it cannot be removed. Therefore, we only generate
- // SQL for adding new dimensions and ignore dimension removals.
+ // TimescaleDB does NOT support removing dimensions from hypertables.
+ // Once added, a dimension cannot be removed, so only additions are generated.
IReadOnlyList newDimensions = operation.AdditionalDimensions ?? [];
IReadOnlyList oldDimensions = operation.OldAdditionalDimensions ?? [];
- // Find dimensions that are in new but not in old (added dimensions)
foreach (Dimension newDim in newDimensions)
{
bool exists = oldDimensions.Any(oldDim =>
@@ -240,7 +209,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
{
if (newDim.Type == EDimensionType.Range)
{
- // Detect if interval is numeric (integer range) or time-based (timestamp range)
bool isIntegerRange = long.TryParse(newDim.Interval, out _);
string intervalExpression = isIntegerRange
? newDim.Interval!
@@ -255,7 +223,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
}
}
- // Warn if dimensions were removed (which cannot be reversed in TimescaleDB)
List removedDimensions = [.. oldDimensions
.Where(oldDim => !newDimensions.Any(newDim =>
oldDim.ColumnName == newDim.ColumnName &&
@@ -267,7 +234,6 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList?
statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}");
}
- // Add wrapped community statements if any exist
if (communityStatements.Count > 0)
{
statements.Add(WrapCommunityFeatures(communityStatements));
@@ -291,7 +257,6 @@ private static string WrapCommunityFeatures(List sqlStatements)
foreach (string sql in sqlStatements)
{
- // Remove trailing semicolon and escape single quotes for EXECUTE
string cleanSql = sql.TrimEnd(';').Replace("'", "''");
sb.AppendLine($" EXECUTE '{cleanSql}';");
}
@@ -304,21 +269,11 @@ private static string WrapCommunityFeatures(List sqlStatements)
return sb.ToString();
}
- ///
- /// Wraps an identifier in double quotes to preserve case-sensitivity in Postgres.
- /// Escapes existing double quotes.
- /// Example: TenantId -> "TenantId"
- ///
- private string QuoteIdentifier(string identifier)
- {
- return $"{quoteString}{identifier.Replace("\"", "\"\"")}{quoteString}";
- }
-
///
/// Quotes the column name within an ORDER BY clause while preserving direction/nulls.
/// Example: Timestamp DESC -> "Timestamp" DESC
///
- private string QuoteOrderByList(IEnumerable orderByClauses)
+ private static string QuoteOrderByList(IEnumerable orderByClauses)
{
return string.Join(", ", orderByClauses.Select(clause =>
{
@@ -326,8 +281,8 @@ private string QuoteOrderByList(IEnumerable orderByClauses)
string col = parts[0];
string suffix = parts.Length > 1 ? " " + parts[1] : "";
- return QuoteIdentifier(col) + suffix;
+ return SqlBuilderHelper.QuoteIdentifier(col) + suffix;
}));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Eftdb/Generators/PolicyJobSqlBuilder.cs b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs
new file mode 100644
index 0000000..8e77e42
--- /dev/null
+++ b/src/Eftdb/Generators/PolicyJobSqlBuilder.cs
@@ -0,0 +1,71 @@
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
+{
+ ///
+ /// Builds the SQL shared by TimescaleDB automation policies whose
+ /// scheduling is tuned through the common alter_job function.
+ ///
+ public static class PolicyJobSqlBuilder
+ {
+ ///
+ /// Builds alter_job tuning clauses for a newly added policy, including every value
+ /// that was explicitly provided.
+ ///
+ public static List BuildJobClauses(string? scheduleInterval, string? maxRuntime, int? maxRetries, string? retryPeriod)
+ {
+ List clauses = [];
+
+ if (!string.IsNullOrWhiteSpace(scheduleInterval))
+ clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'");
+
+ if (!string.IsNullOrWhiteSpace(maxRuntime))
+ clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'");
+
+ if (maxRetries != null)
+ clauses.Add($"max_retries => {maxRetries}");
+
+ if (!string.IsNullOrWhiteSpace(retryPeriod))
+ clauses.Add($"retry_period => INTERVAL '{retryPeriod}'");
+
+ return clauses;
+ }
+
+ ///
+ /// Builds alter_job tuning clauses for only the values that changed relative to the
+ /// previous policy state.
+ ///
+ public static List BuildChangedJobClauses(
+ string? scheduleInterval, string? oldScheduleInterval,
+ string? maxRuntime, string? oldMaxRuntime,
+ int? maxRetries, int? oldMaxRetries,
+ string? retryPeriod, string? oldRetryPeriod)
+ {
+ List clauses = [];
+
+ if (!string.IsNullOrWhiteSpace(scheduleInterval) && scheduleInterval != oldScheduleInterval)
+ clauses.Add($"schedule_interval => INTERVAL '{scheduleInterval}'");
+
+ if (!string.IsNullOrWhiteSpace(maxRuntime) && maxRuntime != oldMaxRuntime)
+ clauses.Add($"max_runtime => INTERVAL '{maxRuntime}'");
+
+ if (maxRetries != null && maxRetries != oldMaxRetries)
+ clauses.Add($"max_retries => {maxRetries}");
+
+ if (!string.IsNullOrWhiteSpace(retryPeriod) && retryPeriod != oldRetryPeriod)
+ clauses.Add($"retry_period => INTERVAL '{retryPeriod}'");
+
+ return clauses;
+ }
+
+ ///
+ /// Builds the alter_job statement that tunes the policy job identified by
+ /// for the given table.
+ ///
+ public static string BuildAlterJobSql(string tableName, string schema, string procName, IEnumerable clauses)
+ {
+ 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();
+ }
+ }
+}
diff --git a/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs b/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs
deleted file mode 100644
index 8306a47..0000000
--- a/src/Eftdb/Generators/ReorderPolicyOperationGenerator.cs
+++ /dev/null
@@ -1,165 +0,0 @@
-using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
-using System.Globalization;
-
-namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
-{
- public class ReorderPolicyOperationGenerator
- {
- private readonly string quoteString = "\"";
- private readonly SqlBuilderHelper sqlHelper;
-
- public ReorderPolicyOperationGenerator(bool isDesignTime = false)
- {
- if (isDesignTime)
- {
- quoteString = "\"\"";
- }
-
- sqlHelper = new SqlBuilderHelper(quoteString);
- }
-
- public List Generate(AddReorderPolicyOperation operation)
- {
- List statements =
- [
- BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart)
- ];
-
- List alterJobClauses = BuildAlterJobClauses(operation);
- if (alterJobClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, alterJobClauses));
- }
-
- return statements;
- }
-
- public List Generate(AlterReorderPolicyOperation operation)
- {
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
-
- List statements = [];
- bool needsRecreation = operation.IndexName != operation.OldIndexName || operation.InitialStart != operation.OldInitialStart;
-
- if (needsRecreation)
- {
- statements.Add($"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);");
- statements.Add(BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart));
-
- // Create a temporary "add" operation representing the final desired state to ensure existing settings are reapplied.
- AddReorderPolicyOperation finalStateOperation = new()
- {
- TableName = operation.TableName,
- IndexName = operation.IndexName,
- InitialStart = operation.InitialStart,
- ScheduleInterval = operation.ScheduleInterval,
- MaxRuntime = operation.MaxRuntime,
- MaxRetries = operation.MaxRetries,
- RetryPeriod = operation.RetryPeriod
- };
-
- List finalStateClauses = BuildAlterJobClauses(finalStateOperation);
- if (finalStateClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, finalStateClauses));
- }
- }
- else
- {
- List changedClauses = BuildAlterJobClauses(operation);
- if (changedClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, changedClauses));
- }
- }
-
- return statements;
- }
-
- public List Generate(DropReorderPolicyOperation operation)
- {
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
-
- List statements =
- [
- $"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);"
- ];
- return statements;
- }
-
- private static List BuildAlterJobClauses(AddReorderPolicyOperation operation)
- {
- List clauses = [];
-
- if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval))
- clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'");
-
- if (!string.IsNullOrWhiteSpace(operation.MaxRuntime))
- clauses.Add($"max_runtime => INTERVAL '{operation.MaxRuntime}'");
-
- if (operation.MaxRetries != null)
- clauses.Add($"max_retries => {operation.MaxRetries}");
-
- if (!string.IsNullOrWhiteSpace(operation.RetryPeriod))
- clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'");
-
- return clauses;
- }
-
- private static List BuildAlterJobClauses(AlterReorderPolicyOperation operation)
- {
- List clauses = [];
-
- if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval) && operation.ScheduleInterval != operation.OldScheduleInterval)
- clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'");
-
- if (!string.IsNullOrWhiteSpace(operation.MaxRuntime) && operation.MaxRuntime != operation.OldMaxRuntime)
- {
- string maxRuntimeValue = string.IsNullOrWhiteSpace(operation.MaxRuntime) ? "NULL" : $"INTERVAL '{operation.MaxRuntime}'";
- clauses.Add($"max_runtime => {maxRuntimeValue}");
- }
-
- if (operation.MaxRetries != null && operation.MaxRetries != operation.OldMaxRetries)
- clauses.Add($"max_retries => {operation.MaxRetries}");
-
- if (!string.IsNullOrWhiteSpace(operation.RetryPeriod) && operation.RetryPeriod != operation.OldRetryPeriod)
- clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'");
-
- return clauses;
- }
-
- private static string BuildAlterJobSql(string tableName, string schema, IEnumerable clauses)
- {
- // Note: hypertable_name is a varchar column, so it compares against a string literal, not a regclass.
- return $@"
- SELECT alter_job(job_id, {string.Join(", ", clauses)})
- FROM timescaledb_information.jobs
- WHERE proc_name = 'policy_reorder' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim();
- }
-
- private string BuildAddReorderPolicySql(string tableName, string schema, string indexName, DateTime? initialStart)
- {
- string qualifiedTableName = sqlHelper.Regclass(tableName, schema);
-
- string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{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}'");
- }
-
- if (optionalArgs.Count > 0)
- {
- baseSql += $", {string.Join(", ", optionalArgs)}";
- }
-
- baseSql += ");";
- return baseSql;
- }
- }
-}
diff --git a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs
new file mode 100644
index 0000000..151c7d5
--- /dev/null
+++ b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs
@@ -0,0 +1,99 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using System.Globalization;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
+{
+ public class ReorderPolicySqlGenerator
+ {
+ private const string ProcName = "policy_reorder";
+
+ public static List Generate(AddReorderPolicyOperation operation)
+ {
+ List statements =
+ [
+ BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart)
+ ];
+
+ List jobClauses = PolicyJobSqlBuilder.BuildJobClauses(
+ operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod);
+ if (jobClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses));
+ }
+
+ return statements;
+ }
+
+ public static List Generate(AlterReorderPolicyOperation operation)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+
+ List statements = [];
+ bool needsRecreation = operation.IndexName != operation.OldIndexName || operation.InitialStart != operation.OldInitialStart;
+
+ if (needsRecreation)
+ {
+ statements.Add($"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);");
+ statements.Add(BuildAddReorderPolicySql(operation.TableName, operation.Schema, operation.IndexName, operation.InitialStart));
+
+ // After recreation, reapply the full desired job configuration so existing settings are not lost.
+ List finalStateClauses = PolicyJobSqlBuilder.BuildJobClauses(
+ operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod);
+ if (finalStateClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, finalStateClauses));
+ }
+ }
+ else
+ {
+ List changedClauses = PolicyJobSqlBuilder.BuildChangedJobClauses(
+ operation.ScheduleInterval, operation.OldScheduleInterval,
+ operation.MaxRuntime, operation.OldMaxRuntime,
+ operation.MaxRetries, operation.OldMaxRetries,
+ operation.RetryPeriod, operation.OldRetryPeriod);
+ if (changedClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, changedClauses));
+ }
+ }
+
+ return statements;
+ }
+
+ public static List Generate(DropReorderPolicyOperation operation)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+
+ List statements =
+ [
+ $"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);"
+ ];
+ return statements;
+ }
+
+ private static string BuildAddReorderPolicySql(string tableName, string schema, string indexName, DateTime? initialStart)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema);
+
+ string baseSql = $"SELECT add_reorder_policy({qualifiedTableName}, '{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}'");
+ }
+
+ if (optionalArgs.Count > 0)
+ {
+ baseSql += $", {string.Join(", ", optionalArgs)}";
+ }
+
+ baseSql += ");";
+ return baseSql;
+ }
+ }
+}
diff --git a/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs b/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs
deleted file mode 100644
index ffb54fd..0000000
--- a/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs
+++ /dev/null
@@ -1,165 +0,0 @@
-using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
-using System.Globalization;
-
-namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
-{
- public class RetentionPolicyOperationGenerator
- {
- private readonly string quoteString = "\"";
- private readonly SqlBuilderHelper sqlHelper;
-
- public RetentionPolicyOperationGenerator(bool isDesignTime = false)
- {
- if (isDesignTime)
- {
- quoteString = "\"\"";
- }
-
- sqlHelper = new SqlBuilderHelper(quoteString);
- }
-
- public List Generate(AddRetentionPolicyOperation operation)
- {
- List statements =
- [
- BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart)
- ];
-
- List alterJobClauses = BuildAlterJobClauses(operation);
- if (alterJobClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, alterJobClauses));
- }
-
- return statements;
- }
-
- public List Generate(AlterRetentionPolicyOperation operation)
- {
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
-
- List statements = [];
- bool needsRecreation =
- operation.DropAfter != operation.OldDropAfter ||
- operation.DropCreatedBefore != operation.OldDropCreatedBefore ||
- operation.InitialStart != operation.OldInitialStart;
-
- if (needsRecreation)
- {
- statements.Add($"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);");
- statements.Add(BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart));
-
- // Create a temporary "add" operation representing the final desired state to ensure existing settings are reapplied.
- AddRetentionPolicyOperation finalStateOperation = new()
- {
- TableName = operation.TableName,
- Schema = operation.Schema,
- DropAfter = operation.DropAfter,
- DropCreatedBefore = operation.DropCreatedBefore,
- InitialStart = operation.InitialStart,
- ScheduleInterval = operation.ScheduleInterval,
- MaxRuntime = operation.MaxRuntime,
- MaxRetries = operation.MaxRetries,
- RetryPeriod = operation.RetryPeriod
- };
-
- List finalStateClauses = BuildAlterJobClauses(finalStateOperation);
- if (finalStateClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, finalStateClauses));
- }
- }
- else
- {
- List changedClauses = BuildAlterJobClauses(operation);
- if (changedClauses.Count != 0)
- {
- statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, changedClauses));
- }
- }
-
- return statements;
- }
-
- public List Generate(DropRetentionPolicyOperation operation)
- {
- string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
-
- List statements =
- [
- $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);"
- ];
- return statements;
- }
-
- private static List BuildAlterJobClauses(AddRetentionPolicyOperation operation)
- {
- List clauses = [];
-
- if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval))
- clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'");
-
- if (!string.IsNullOrWhiteSpace(operation.MaxRuntime))
- clauses.Add($"max_runtime => INTERVAL '{operation.MaxRuntime}'");
-
- if (operation.MaxRetries != null)
- clauses.Add($"max_retries => {operation.MaxRetries}");
-
- if (!string.IsNullOrWhiteSpace(operation.RetryPeriod))
- clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'");
-
- return clauses;
- }
-
- private static List BuildAlterJobClauses(AlterRetentionPolicyOperation operation)
- {
- List clauses = [];
-
- if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval) && operation.ScheduleInterval != operation.OldScheduleInterval)
- clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'");
-
- if (!string.IsNullOrWhiteSpace(operation.MaxRuntime) && operation.MaxRuntime != operation.OldMaxRuntime)
- {
- string maxRuntimeValue = string.IsNullOrWhiteSpace(operation.MaxRuntime) ? "NULL" : $"INTERVAL '{operation.MaxRuntime}'";
- clauses.Add($"max_runtime => {maxRuntimeValue}");
- }
-
- if (operation.MaxRetries != null && operation.MaxRetries != operation.OldMaxRetries)
- clauses.Add($"max_retries => {operation.MaxRetries}");
-
- if (!string.IsNullOrWhiteSpace(operation.RetryPeriod) && operation.RetryPeriod != operation.OldRetryPeriod)
- clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'");
-
- return clauses;
- }
-
- private static string BuildAlterJobSql(string tableName, string schema, IEnumerable clauses)
- {
- // Note: hypertable_name is a varchar column, so it compares against a string literal, not a regclass.
- return $@"
- SELECT alter_job(job_id, {string.Join(", ", clauses)})
- FROM timescaledb_information.jobs
- WHERE proc_name = 'policy_retention' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim();
- }
-
- private string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart)
- {
- string qualifiedTableName = sqlHelper.Regclass(tableName, schema);
-
- List args = [];
-
- if (!string.IsNullOrWhiteSpace(dropAfter))
- args.Add($"drop_after => INTERVAL '{dropAfter}'");
- else if (!string.IsNullOrWhiteSpace(dropCreatedBefore))
- args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'");
-
- if (initialStart.HasValue)
- {
- string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
- args.Add($"initial_start => '{timestamp}'");
- }
-
- return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});";
- }
- }
-}
diff --git a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs
new file mode 100644
index 0000000..d9e5d78
--- /dev/null
+++ b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs
@@ -0,0 +1,97 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using System.Globalization;
+
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
+{
+ public class RetentionPolicySqlGenerator
+ {
+ private const string ProcName = "policy_retention";
+
+ public static List Generate(AddRetentionPolicyOperation operation)
+ {
+ List statements =
+ [
+ BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart)
+ ];
+
+ List jobClauses = PolicyJobSqlBuilder.BuildJobClauses(
+ operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod);
+ if (jobClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses));
+ }
+
+ return statements;
+ }
+
+ public static List Generate(AlterRetentionPolicyOperation operation)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+
+ List statements = [];
+ bool needsRecreation =
+ operation.DropAfter != operation.OldDropAfter ||
+ operation.DropCreatedBefore != operation.OldDropCreatedBefore ||
+ operation.InitialStart != operation.OldInitialStart;
+
+ if (needsRecreation)
+ {
+ statements.Add($"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);");
+ statements.Add(BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart));
+
+ // After recreation, reapply the full desired job configuration so existing settings are not lost.
+ List finalStateClauses = PolicyJobSqlBuilder.BuildJobClauses(
+ operation.ScheduleInterval, operation.MaxRuntime, operation.MaxRetries, operation.RetryPeriod);
+ if (finalStateClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, finalStateClauses));
+ }
+ }
+ else
+ {
+ List changedClauses = PolicyJobSqlBuilder.BuildChangedJobClauses(
+ operation.ScheduleInterval, operation.OldScheduleInterval,
+ operation.MaxRuntime, operation.OldMaxRuntime,
+ operation.MaxRetries, operation.OldMaxRetries,
+ operation.RetryPeriod, operation.OldRetryPeriod);
+ if (changedClauses.Count != 0)
+ {
+ statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, changedClauses));
+ }
+ }
+
+ return statements;
+ }
+
+ public static List Generate(DropRetentionPolicyOperation operation)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema);
+
+ List statements =
+ [
+ $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);"
+ ];
+ return statements;
+ }
+
+ private static string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart)
+ {
+ string qualifiedTableName = SqlBuilderHelper.Regclass(tableName, schema);
+
+ List args = [];
+
+ if (!string.IsNullOrWhiteSpace(dropAfter))
+ args.Add($"drop_after => INTERVAL '{dropAfter}'");
+ else if (!string.IsNullOrWhiteSpace(dropCreatedBefore))
+ args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'");
+
+ if (initialStart.HasValue)
+ {
+ string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
+ args.Add($"initial_start => '{timestamp}'");
+ }
+
+ return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});";
+ }
+ }
+}
diff --git a/src/Eftdb/Generators/SqlBuilderHelper.cs b/src/Eftdb/Generators/SqlBuilderHelper.cs
index 84aa402..8f82aed 100644
--- a/src/Eftdb/Generators/SqlBuilderHelper.cs
+++ b/src/Eftdb/Generators/SqlBuilderHelper.cs
@@ -3,9 +3,9 @@
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators
{
- public class SqlBuilderHelper(string quoteString)
+ public static class SqlBuilderHelper
{
- private readonly string quoteString = quoteString;
+ private static readonly string quoteString = "\"";
public static void BuildQueryString(List statements, MigrationCommandListBuilder builder, bool suppressTransaction = false, bool usePerform = false)
{
@@ -17,13 +17,16 @@ public static void BuildQueryString(List statements, MigrationCommandLis
// Group consecutive statements that don't end with semicolon into single commands
List> commandGroups = [];
List currentGroup = [];
+ int dollarQuoteCount = 0;
foreach (string statement in statements)
{
currentGroup.Add(statement);
- // If statement ends with semicolon, it's a complete command
- if (statement.TrimEnd().EndsWith(';'))
+ dollarQuoteCount += statement.AsSpan().Count("$$");
+ bool insideDollarQuote = dollarQuoteCount % 2 != 0;
+
+ if (!insideDollarQuote && statement.TrimEnd().EndsWith(';'))
{
commandGroups.Add([.. currentGroup]);
currentGroup.Clear();
@@ -107,14 +110,20 @@ public static void BuildQueryString(List statements, IndentedStringBuild
}
}
- public string Regclass(string tableName, string schema = DefaultValues.DefaultSchema)
+ public static string Regclass(string tableName, string schema = DefaultValues.DefaultSchema)
{
return $"'{schema}.{quoteString}{tableName}{quoteString}'";
}
- public string QualifiedIdentifier(string tableName, string schema = DefaultValues.DefaultSchema)
+ public static string QualifiedIdentifier(string tableName, string schema = DefaultValues.DefaultSchema)
{
return $"{quoteString}{schema}{quoteString}.{quoteString}{tableName}{quoteString}";
}
+
+ ///
+ /// Wraps a single identifier in PostgreSQL double quotes. Used by SQL generators
+ /// to quote column references in compression segment/order-by lists, group-by clauses, etc.
+ ///
+ public static string QuoteIdentifier(string identifier) => $"\"{identifier}\"";
}
}
diff --git a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs
index 4ad5468..97fdc3c 100644
--- a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs
+++ b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs
@@ -15,12 +15,23 @@ public class ContinuousAggregatePolicyDiffer : IFeatureDiffer
/// The source model (from the last migration).
/// The target model (the current state).
/// A collection of migration operations.
- public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
+
List operations = [];
- List sourcePolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(source)];
- List targetPolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(target)];
+ List allSourcePolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(source)];
+ List allTargetPolicies = [.. ContinuousAggregatePolicyModelExtractor.GetContinuousAggregatePolicies(target)];
+
+ // Recreating an aggregate drops its refresh policy, so re-add it and skip the normal diff.
+ foreach (AddContinuousAggregatePolicyOperation policy in allTargetPolicies.Where(t => context.RecreatedAggregates.Contains((t.Schema, t.MaterializedViewName))))
+ {
+ operations.Add(policy);
+ }
+
+ List sourcePolicies = [.. allSourcePolicies.Where(s => !context.RecreatedAggregates.Contains((s.Schema, s.MaterializedViewName)))];
+ List targetPolicies = [.. allTargetPolicies.Where(t => !context.RecreatedAggregates.Contains((t.Schema, t.MaterializedViewName)))];
// Find new policies - continuous aggregates that now have a policy but didn't before
IEnumerable newPolicies = targetPolicies
diff --git a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
index aa4657d..9e73c3c 100644
--- a/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
+++ b/src/Eftdb/Internals/Features/ContinuousAggregates/ContinuousAggregateDiffer.cs
@@ -6,13 +6,21 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Continuous
{
public class ContinuousAggregateDiffer : IFeatureDiffer
{
- public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
+
List operations = [];
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);
+ }
+
// Find new continuous aggregates - only compare by MaterializedViewName, not Schema
IEnumerable newAggregates = targetAggregates
.Where(t => !sourceAggregates.Any(s => s.MaterializedViewName == t.MaterializedViewName));
@@ -93,7 +101,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source
return operations;
}
- private static bool AreAggregateFunctionsEqual(List? list1, List? list2)
+ private static bool AreAggregateFunctionsEqual(IReadOnlyList? list1, IReadOnlyList? list2)
{
if (list1 == null && list2 == null) return true;
if (list1 == null || list2 == null) return false;
@@ -102,7 +110,7 @@ private static bool AreAggregateFunctionsEqual(List? list1, List
return list1.SequenceEqual(list2);
}
- private static bool AreGroupByColumnsEqual(List? list1, List? list2)
+ private static bool AreGroupByColumnsEqual(IReadOnlyList? list1, IReadOnlyList? list2)
{
if (list1 == null && list2 == null) return true;
if (list1 == null || list2 == null) return false;
diff --git a/src/Eftdb/Internals/Features/FeatureDiffContext.cs b/src/Eftdb/Internals/Features/FeatureDiffContext.cs
new file mode 100644
index 0000000..75489f6
--- /dev/null
+++ b/src/Eftdb/Internals/Features/FeatureDiffContext.cs
@@ -0,0 +1,51 @@
+namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features
+{
+ ///
+ /// Carries cross-cutting information that individual implementations
+ /// need but cannot derive on their own: renames detected by EF Core's base differ, and parent
+ /// objects that another feature differ has decided to drop and recreate.
+ ///
+ ///
+ /// Schemas are always stored normalized to a concrete value (never null); callers must normalize
+ /// missing schemas to before building or querying the maps,
+ /// matching how the model extractors normalize GetSchema().
+ ///
+ public sealed class FeatureDiffContext
+ {
+ /// Maps a source object's (schema, oldTableName) to its (schema, newTableName).
+ public IReadOnlyDictionary<(string Schema, string Name), (string Schema, string Name)> TableRenames { get; init; }
+ = new Dictionary<(string, string), (string, string)>();
+
+ /// Maps an index's (schema, oldIndexName) to its (schema, newIndexName).
+ public IReadOnlyDictionary<(string Schema, string Name), (string Schema, string Name)> IndexRenames { get; init; }
+ = new Dictionary<(string, string), (string, string)>();
+
+ ///
+ /// Maps a column's (schema, newTableName, oldColumnName) to its new column name. The table key uses
+ /// the post-rename table name because EF Core emits RenameColumnOperation against the renamed table.
+ ///
+ public IReadOnlyDictionary<(string Schema, string Table, string Column), string> ColumnRenames { get; init; }
+ = new Dictionary<(string, string, string), string>();
+
+ ///
+ /// Continuous aggregates (by (schema, viewName)) that are being dropped and recreated in this diff.
+ /// Recreating a continuous aggregate cascades to drop its refresh and retention policies, so dependent
+ /// policy differs must re-add those policies even when their configuration is unchanged. Populated by the
+ /// orchestrator after the continuous aggregate differ runs.
+ ///
+ public ISet<(string Schema, string ViewName)> RecreatedAggregates { get; init; }
+ = new HashSet<(string, string)>();
+
+ /// An empty context with identity rename maps; used when a differ is invoked without orchestration.
+ public static FeatureDiffContext Empty { get; } = new();
+
+ public (string Schema, string Name) ResolveTable(string schema, string name)
+ => TableRenames.TryGetValue((schema, name), out (string Schema, string Name) mapped) ? mapped : (schema, name);
+
+ public (string Schema, string Name) ResolveIndex(string schema, string name)
+ => IndexRenames.TryGetValue((schema, name), out (string Schema, string Name) mapped) ? mapped : (schema, name);
+
+ public string ResolveColumn(string schema, string table, string column)
+ => ColumnRenames.TryGetValue((schema, table, column), out string? mapped) ? mapped : column;
+ }
+}
diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs
index f657c7d..9840273 100644
--- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs
+++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs
@@ -7,15 +7,17 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertable
{
public class HypertableDiffer : IFeatureDiffer
{
- public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
+
List operations = [];
- List sourceHypertables = [.. HypertableModelExtractor.GetHypertables(source)];
+ List sourceHypertables = [.. HypertableModelExtractor.GetHypertables(source).Select(s => RewriteSource(s, context))];
List targetHypertables = [.. HypertableModelExtractor.GetHypertables(target)];
// Find new hypertables
- IEnumerable newHypertables = targetHypertables.Where(t => !sourceHypertables.Any(s => s.TableName == t.TableName));
+ IEnumerable newHypertables = targetHypertables.Where(t => !sourceHypertables.Any(s => s.Schema == t.Schema && s.TableName == t.TableName));
operations.AddRange(newHypertables);
// Find updated hypertables
@@ -60,11 +62,62 @@ public IReadOnlyList GetDifferences(IRelationalModel? source
});
}
- // TODO: Detect dropped hypertables if TimescaleDB supports a "de-hyper" operation.
-
return operations;
}
+ ///
+ /// Produces a copy of a source hypertable with its table, schema, and all column-bearing fields rewritten
+ /// through the rename maps, so that a pure rename compares equal to its target and produces no operation.
+ ///
+ private static CreateHypertableOperation RewriteSource(CreateHypertableOperation source, FeatureDiffContext context)
+ {
+ (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName);
+
+ return new CreateHypertableOperation
+ {
+ TableName = tableName,
+ Schema = schema,
+ TimeColumnName = context.ResolveColumn(schema, tableName, source.TimeColumnName),
+ 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),
+ AdditionalDimensions = RewriteDimensions(source.AdditionalDimensions, schema, tableName, context),
+ };
+ }
+
+ 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
+ {
+ ColumnName = context.ResolveColumn(schema, table, d.ColumnName),
+ Type = d.Type,
+ Interval = d.Interval,
+ NumberOfPartitions = d.NumberOfPartitions,
+ }).ToList();
+ }
+
private static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyList? list2)
{
return (list1 ?? []).SequenceEqual(list2 ?? []);
diff --git a/src/Eftdb/Internals/Features/IFeatureDiffer.cs b/src/Eftdb/Internals/Features/IFeatureDiffer.cs
index e977a4a..e12b041 100644
--- a/src/Eftdb/Internals/Features/IFeatureDiffer.cs
+++ b/src/Eftdb/Internals/Features/IFeatureDiffer.cs
@@ -14,7 +14,11 @@ public interface IFeatureDiffer
///
/// The source model (from the last migration).
/// The target model (the current state).
+ ///
+ /// Cross-cutting diff information. When omitted, the differ behaves as if
+ /// nothing was renamed or recreated ().
+ ///
/// A collection of migration operations.
- IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target);
+ IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null);
}
}
diff --git a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs
index dd8729a..741e40f 100644
--- a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs
+++ b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs
@@ -6,17 +6,19 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPol
{
public class ReorderPolicyDiffer : IFeatureDiffer
{
- public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
+
// Get the standard migration operations (CreateTable, AddColumn, etc.) from the base MigrationsModelDiffer.
List operations = [];
- // Reorder diffs
- List sourcePolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(source)];
+ // Apply table/index renames to the source so a rename isn't seen as a new policy.
+ List sourcePolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(source).Select(s => RewriteSource(s, context))];
List targetPolicies = [.. ReorderPolicyModelExtractor.GetReorderPolicies(target)];
- // Identiy new reorder policies
- IEnumerable newReorderPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.TableName == t.TableName));
+ // Identiy new reorder policies (keyed on schema and table name, consistent with the update join below)
+ IEnumerable newReorderPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.Schema == t.Schema && s.TableName == t.TableName));
operations.AddRange(newReorderPolicies);
// Identify updated reorder policies
@@ -65,5 +67,27 @@ public IReadOnlyList GetDifferences(IRelationalModel? source
return operations;
}
+
+ ///
+ /// Produces a copy of a source reorder policy with its table, schema, and referenced index rewritten through
+ /// the rename maps, so that a pure rename compares equal to its target and produces no operation.
+ ///
+ private static AddReorderPolicyOperation RewriteSource(AddReorderPolicyOperation source, FeatureDiffContext context)
+ {
+ (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName);
+ (_, string indexName) = context.ResolveIndex(source.Schema, source.IndexName);
+
+ return new AddReorderPolicyOperation
+ {
+ TableName = tableName,
+ Schema = schema,
+ IndexName = indexName,
+ InitialStart = source.InitialStart,
+ ScheduleInterval = source.ScheduleInterval,
+ MaxRuntime = source.MaxRuntime,
+ MaxRetries = source.MaxRetries,
+ RetryPeriod = source.RetryPeriod,
+ };
+ }
}
}
diff --git a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs
index 2da4b02..73c2906 100644
--- a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs
+++ b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs
@@ -6,12 +6,24 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionP
{
public class RetentionPolicyDiffer : IFeatureDiffer
{
- public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
+ public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target, FeatureDiffContext? context = null)
{
+ context ??= FeatureDiffContext.Empty;
+
List operations = [];
- List sourcePolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(source)];
- List targetPolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(target)];
+ // Apply table renames to the source so a rename isn't seen as a drop-and-add.
+ List allSourcePolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(source).Select(s => RewriteSource(s, context))];
+ List allTargetPolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(target)];
+
+ // Recreating an aggregate drops its retention policy, so re-add it and skip the normal diff.
+ foreach (AddRetentionPolicyOperation policy in allTargetPolicies.Where(t => context.RecreatedAggregates.Contains((t.Schema, t.TableName))))
+ {
+ operations.Add(policy);
+ }
+
+ List sourcePolicies = [.. allSourcePolicies.Where(s => !context.RecreatedAggregates.Contains((s.Schema, s.TableName)))];
+ List targetPolicies = [.. allTargetPolicies.Where(t => !context.RecreatedAggregates.Contains((t.Schema, t.TableName)))];
// Identify new retention policies
IEnumerable newRetentionPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.TableName == t.TableName && s.Schema == t.Schema));
@@ -67,5 +79,27 @@ public IReadOnlyList GetDifferences(IRelationalModel? source
return operations;
}
+
+ ///
+ /// Produces a copy of a source retention policy 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 AddRetentionPolicyOperation RewriteSource(AddRetentionPolicyOperation source, FeatureDiffContext context)
+ {
+ (string schema, string tableName) = context.ResolveTable(source.Schema, source.TableName);
+
+ return new AddRetentionPolicyOperation
+ {
+ TableName = tableName,
+ Schema = schema,
+ DropAfter = source.DropAfter,
+ DropCreatedBefore = source.DropCreatedBefore,
+ InitialStart = source.InitialStart,
+ ScheduleInterval = source.ScheduleInterval,
+ MaxRuntime = source.MaxRuntime,
+ MaxRetries = source.MaxRetries,
+ RetryPeriod = source.RetryPeriod,
+ };
+ }
}
}
diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs
index fd1e553..5a728f8 100644
--- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs
+++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs
@@ -22,29 +22,97 @@ public class TimescaleMigrationsModelDiffer(
IRowIdentityMapFactory rowIdentityMapFactory,
CommandBatchPreparerDependencies commandBatchPreparerDependencies) : MigrationsModelDiffer(typeMappingSource, migrationsAnnotationProvider, relationalAnnotationProvider, rowIdentityMapFactory, commandBatchPreparerDependencies)
{
- private readonly IReadOnlyList _featureDiffers = [
- new HypertableDiffer(),
- new ReorderPolicyDiffer(),
- new ContinuousAggregateDiffer(),
- new ContinuousAggregatePolicyDiffer(),
- new RetentionPolicyDiffer(),
- ];
-
public override IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target)
{
- // Get all operations
+ // Standard EF Core operations, which include the rename operations the feature differs need to
+ // distinguish a rename from a drop-and-create.
List allOperations = [.. base.GetDifferences(source, target)];
- foreach (IFeatureDiffer differ in _featureDiffers)
- {
- allOperations.AddRange(differ.GetDifferences(source, target));
- }
+ FeatureDiffContext context = BuildContext(allOperations);
+
+ allOperations.AddRange(new HypertableDiffer().GetDifferences(source, target, context));
+ allOperations.AddRange(new ReorderPolicyDiffer().GetDifferences(source, target, context));
+
+ IReadOnlyList aggregateOperations = new ContinuousAggregateDiffer().GetDifferences(source, target, context);
+ allOperations.AddRange(aggregateOperations);
+
+ PopulateRecreatedAggregates(aggregateOperations, context.RecreatedAggregates);
+
+ allOperations.AddRange(new ContinuousAggregatePolicyDiffer().GetDifferences(source, target, context));
+ allOperations.AddRange(new RetentionPolicyDiffer().GetDifferences(source, target, context));
// Sort the entire list based on the priority defined in the helper method
List sortedOperations = [.. allOperations.OrderBy(GetOperationPriority)];
return sortedOperations;
}
+ ///
+ /// Builds rename maps from the standard EF Core operations so feature differs can recognize renamed
+ /// tables, indexes, and columns instead of treating them as drop-and-create.
+ ///
+ private static FeatureDiffContext BuildContext(IEnumerable baseOperations)
+ {
+ Dictionary<(string, string), (string, string)> tableRenames = [];
+ Dictionary<(string, string), (string, string)> indexRenames = [];
+ Dictionary<(string, string, string), string> columnRenames = [];
+
+ foreach (MigrationOperation operation in baseOperations)
+ {
+ switch (operation)
+ {
+ case RenameTableOperation rename:
+ {
+ string oldSchema = rename.Schema ?? DefaultValues.DefaultSchema;
+ string newSchema = rename.NewSchema ?? rename.Schema ?? DefaultValues.DefaultSchema;
+ string newName = rename.NewName ?? rename.Name;
+ tableRenames[(oldSchema, rename.Name)] = (newSchema, newName);
+ break;
+ }
+ case RenameIndexOperation rename when rename.NewName != null:
+ {
+ string schema = rename.Schema ?? DefaultValues.DefaultSchema;
+ indexRenames[(schema, rename.Name)] = (schema, rename.NewName);
+ break;
+ }
+ case RenameColumnOperation rename:
+ {
+ // RenameColumnOperation targets the table by its post-rename name, so the column key
+ // uses the new table name to line up with rename-rewritten source operations.
+ string schema = rename.Schema ?? DefaultValues.DefaultSchema;
+ columnRenames[(schema, rename.Table, rename.Name)] = rename.NewName;
+ break;
+ }
+ }
+ }
+
+ return new FeatureDiffContext
+ {
+ TableRenames = tableRenames,
+ IndexRenames = indexRenames,
+ ColumnRenames = columnRenames,
+ RecreatedAggregates = new HashSet<(string, string)>(),
+ };
+ }
+
+ ///
+ /// Records continuous aggregates that appear in both a drop and a create operation, signalling that their
+ /// refresh and retention policies must be re-added after the recreate.
+ ///
+ private static void PopulateRecreatedAggregates(IReadOnlyList aggregateOperations, ISet<(string Schema, string ViewName)> recreated)
+ {
+ HashSet<(string, string)> dropped = [.. aggregateOperations
+ .OfType()
+ .Select(o => (o.Schema, o.MaterializedViewName))];
+
+ foreach (CreateContinuousAggregateOperation create in aggregateOperations.OfType())
+ {
+ if (dropped.Contains((create.Schema, create.MaterializedViewName)))
+ {
+ recreated.Add((create.Schema, create.MaterializedViewName));
+ }
+ }
+ }
+
///
/// Assigns a priority to operations to ensure correct execution order.
/// Lower numbers execute first.
@@ -75,6 +143,8 @@ private static int GetOperationPriority(MigrationOperation operation)
// --- Add/Alter operations: positive priorities, dependency order ---
case CreateHypertableOperation:
return 10;
+ case AlterHypertableOperation:
+ return 15;
case AddReorderPolicyOperation:
case AlterReorderPolicyOperation:
diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs
new file mode 100644
index 0000000..837d464
--- /dev/null
+++ b/src/Eftdb/MigrationExtensions/ContinuousAggregateMigrationExtensions.cs
@@ -0,0 +1,90 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
+
+namespace Microsoft.EntityFrameworkCore.Migrations
+{
+ public static class ContinuousAggregateMigrationExtensions
+ {
+ public static OperationBuilder CreateContinuousAggregate(
+ this MigrationBuilder migrationBuilder,
+ string materializedViewName,
+ string parentName,
+ string? schema = null,
+ string? chunkInterval = null,
+ bool withNoData = false,
+ bool createGroupIndexes = false,
+ bool materializedOnly = false,
+ string? timeBucketWidth = null,
+ string? timeBucketSourceColumn = null,
+ bool timeBucketGroupBy = true,
+ IReadOnlyList? aggregateFunctions = null,
+ IReadOnlyList? groupByColumns = null,
+ string? whereClause = null,
+ string? viewDefinition = null)
+ {
+ CreateContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = materializedViewName,
+ ParentName = parentName,
+ Schema = schema ?? string.Empty,
+ ChunkInterval = chunkInterval,
+ WithNoData = withNoData,
+ CreateGroupIndexes = createGroupIndexes,
+ MaterializedOnly = materializedOnly,
+ TimeBucketWidth = timeBucketWidth ?? string.Empty,
+ TimeBucketSourceColumn = timeBucketSourceColumn ?? string.Empty,
+ TimeBucketGroupBy = timeBucketGroupBy,
+ AggregateFunctions = aggregateFunctions is null ? [] : [.. aggregateFunctions.Select(f => f.ToAnnotationValue())],
+ GroupByColumns = groupByColumns ?? [],
+ WhereClause = whereClause,
+ ViewDefinition = viewDefinition,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder AlterContinuousAggregate(
+ this MigrationBuilder migrationBuilder,
+ string materializedViewName,
+ string? schema = null,
+ string? chunkInterval = null,
+ bool createGroupIndexes = false,
+ bool materializedOnly = false,
+ string? oldChunkInterval = null,
+ bool oldCreateGroupIndexes = false,
+ bool oldMaterializedOnly = false)
+ {
+ AlterContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = materializedViewName,
+ Schema = schema ?? string.Empty,
+ ChunkInterval = chunkInterval,
+ CreateGroupIndexes = createGroupIndexes,
+ MaterializedOnly = materializedOnly,
+ OldChunkInterval = oldChunkInterval,
+ OldCreateGroupIndexes = oldCreateGroupIndexes,
+ OldMaterializedOnly = oldMaterializedOnly,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder DropContinuousAggregate(
+ this MigrationBuilder migrationBuilder,
+ string materializedViewName,
+ string? schema = null)
+ {
+ DropContinuousAggregateOperation operation = new()
+ {
+ MaterializedViewName = materializedViewName,
+ Schema = schema ?? string.Empty,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+ }
+}
diff --git a/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs
new file mode 100644
index 0000000..46f123f
--- /dev/null
+++ b/src/Eftdb/MigrationExtensions/ContinuousAggregatePolicyMigrationExtensions.cs
@@ -0,0 +1,58 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
+
+namespace Microsoft.EntityFrameworkCore.Migrations
+{
+ public static class ContinuousAggregatePolicyMigrationExtensions
+ {
+ public static OperationBuilder AddContinuousAggregatePolicy(
+ this MigrationBuilder migrationBuilder,
+ string materializedViewName,
+ string? schema = null,
+ string? startOffset = null,
+ string? endOffset = null,
+ string? scheduleInterval = null,
+ DateTime? initialStart = null,
+ bool ifNotExists = false,
+ bool? includeTieredData = null,
+ int bucketsPerBatch = 1,
+ int maxBatchesPerExecution = 0,
+ bool refreshNewestFirst = true)
+ {
+ AddContinuousAggregatePolicyOperation operation = new()
+ {
+ MaterializedViewName = materializedViewName,
+ Schema = schema ?? string.Empty,
+ StartOffset = startOffset,
+ EndOffset = endOffset,
+ ScheduleInterval = scheduleInterval,
+ InitialStart = initialStart,
+ IfNotExists = ifNotExists,
+ IncludeTieredData = includeTieredData,
+ BucketsPerBatch = bucketsPerBatch,
+ MaxBatchesPerExecution = maxBatchesPerExecution,
+ RefreshNewestFirst = refreshNewestFirst,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder RemoveContinuousAggregatePolicy(
+ this MigrationBuilder migrationBuilder,
+ string materializedViewName,
+ string? schema = null,
+ bool ifExists = false)
+ {
+ RemoveContinuousAggregatePolicyOperation operation = new()
+ {
+ MaterializedViewName = materializedViewName,
+ Schema = schema ?? string.Empty,
+ IfExists = ifExists,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+ }
+}
diff --git a/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs
new file mode 100644
index 0000000..a5cf9a2
--- /dev/null
+++ b/src/Eftdb/MigrationExtensions/HypertableMigrationExtensions.cs
@@ -0,0 +1,79 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
+
+namespace Microsoft.EntityFrameworkCore.Migrations
+{
+ public static class HypertableMigrationExtensions
+ {
+ public static OperationBuilder CreateHypertable(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string timeColumnName,
+ string? schema = null,
+ string? chunkTimeInterval = null,
+ bool enableCompression = false,
+ bool migrateData = false,
+ IReadOnlyList? chunkSkipColumns = null,
+ IReadOnlyList? additionalDimensions = null,
+ IReadOnlyList? compressionSegmentBy = null,
+ IReadOnlyList? compressionOrderBy = null)
+ {
+ CreateHypertableOperation operation = new()
+ {
+ TableName = tableName,
+ TimeColumnName = timeColumnName,
+ Schema = schema ?? string.Empty,
+ ChunkTimeInterval = chunkTimeInterval ?? string.Empty,
+ EnableCompression = enableCompression,
+ MigrateData = migrateData,
+ ChunkSkipColumns = chunkSkipColumns,
+ AdditionalDimensions = additionalDimensions,
+ CompressionSegmentBy = compressionSegmentBy,
+ CompressionOrderBy = compressionOrderBy,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder AlterHypertable(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string? schema = null,
+ string? chunkTimeInterval = null,
+ bool enableCompression = false,
+ IReadOnlyList? chunkSkipColumns = null,
+ IReadOnlyList? additionalDimensions = null,
+ IReadOnlyList? compressionSegmentBy = null,
+ IReadOnlyList? compressionOrderBy = null,
+ string? oldChunkTimeInterval = null,
+ bool oldEnableCompression = false,
+ IReadOnlyList? oldChunkSkipColumns = null,
+ IReadOnlyList? oldAdditionalDimensions = null,
+ IReadOnlyList? oldCompressionSegmentBy = null,
+ IReadOnlyList? oldCompressionOrderBy = null)
+ {
+ AlterHypertableOperation operation = new()
+ {
+ TableName = tableName,
+ Schema = schema ?? string.Empty,
+ ChunkTimeInterval = chunkTimeInterval ?? string.Empty,
+ EnableCompression = enableCompression,
+ ChunkSkipColumns = chunkSkipColumns,
+ AdditionalDimensions = additionalDimensions,
+ CompressionSegmentBy = compressionSegmentBy,
+ CompressionOrderBy = compressionOrderBy,
+ OldChunkTimeInterval = oldChunkTimeInterval ?? string.Empty,
+ OldEnableCompression = oldEnableCompression,
+ OldChunkSkipColumns = oldChunkSkipColumns,
+ OldAdditionalDimensions = oldAdditionalDimensions,
+ OldCompressionSegmentBy = oldCompressionSegmentBy,
+ OldCompressionOrderBy = oldCompressionOrderBy,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+ }
+}
diff --git a/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs
new file mode 100644
index 0000000..f8ee758
--- /dev/null
+++ b/src/Eftdb/MigrationExtensions/ReorderPolicyMigrationExtensions.cs
@@ -0,0 +1,89 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
+
+namespace Microsoft.EntityFrameworkCore.Migrations
+{
+ public static class ReorderPolicyMigrationExtensions
+ {
+ public static OperationBuilder AddReorderPolicy(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string indexName,
+ string? schema = null,
+ DateTime? initialStart = null,
+ string? scheduleInterval = null,
+ string? maxRuntime = null,
+ int? maxRetries = null,
+ string? retryPeriod = null)
+ {
+ AddReorderPolicyOperation operation = new()
+ {
+ TableName = tableName,
+ IndexName = indexName,
+ Schema = schema ?? string.Empty,
+ InitialStart = initialStart,
+ ScheduleInterval = scheduleInterval,
+ MaxRuntime = maxRuntime,
+ MaxRetries = maxRetries,
+ RetryPeriod = retryPeriod,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder AlterReorderPolicy(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string indexName,
+ string? schema = null,
+ DateTime? initialStart = null,
+ string? scheduleInterval = null,
+ string? maxRuntime = null,
+ int? maxRetries = null,
+ string? retryPeriod = null,
+ string? oldIndexName = null,
+ DateTime? oldInitialStart = null,
+ string? oldScheduleInterval = null,
+ string? oldMaxRuntime = null,
+ int? oldMaxRetries = null,
+ string? oldRetryPeriod = null)
+ {
+ AlterReorderPolicyOperation operation = new()
+ {
+ TableName = tableName,
+ IndexName = indexName,
+ Schema = schema ?? string.Empty,
+ InitialStart = initialStart,
+ ScheduleInterval = scheduleInterval,
+ MaxRuntime = maxRuntime,
+ MaxRetries = maxRetries,
+ RetryPeriod = retryPeriod,
+ OldIndexName = oldIndexName ?? string.Empty,
+ OldInitialStart = oldInitialStart,
+ OldScheduleInterval = oldScheduleInterval,
+ OldMaxRuntime = oldMaxRuntime,
+ OldMaxRetries = oldMaxRetries,
+ OldRetryPeriod = oldRetryPeriod,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder DropReorderPolicy(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string? schema = null)
+ {
+ DropReorderPolicyOperation operation = new()
+ {
+ TableName = tableName,
+ Schema = schema ?? string.Empty,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+ }
+}
diff --git a/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs b/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs
new file mode 100644
index 0000000..f829882
--- /dev/null
+++ b/src/Eftdb/MigrationExtensions/RetentionPolicyMigrationExtensions.cs
@@ -0,0 +1,95 @@
+using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
+
+namespace Microsoft.EntityFrameworkCore.Migrations
+{
+ public static class RetentionPolicyMigrationExtensions
+ {
+ public static OperationBuilder AddRetentionPolicy(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string? schema = null,
+ string? dropAfter = null,
+ string? dropCreatedBefore = null,
+ DateTime? initialStart = null,
+ string? scheduleInterval = null,
+ string? maxRuntime = null,
+ int? maxRetries = null,
+ string? retryPeriod = null)
+ {
+ AddRetentionPolicyOperation operation = new()
+ {
+ TableName = tableName,
+ Schema = schema ?? string.Empty,
+ DropAfter = dropAfter,
+ DropCreatedBefore = dropCreatedBefore,
+ InitialStart = initialStart,
+ ScheduleInterval = scheduleInterval,
+ MaxRuntime = maxRuntime,
+ MaxRetries = maxRetries,
+ RetryPeriod = retryPeriod,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder AlterRetentionPolicy(
+ this MigrationBuilder migrationBuilder,
+ string tableName,
+ string? schema = null,
+ string? dropAfter = null,
+ string? dropCreatedBefore = null,
+ DateTime? initialStart = null,
+ string? scheduleInterval = null,
+ string? maxRuntime = null,
+ int? maxRetries = null,
+ string? retryPeriod = null,
+ string? oldDropAfter = null,
+ string? oldDropCreatedBefore = null,
+ DateTime? oldInitialStart = null,
+ string? oldScheduleInterval = null,
+ string? oldMaxRuntime = null,
+ int? oldMaxRetries = null,
+ string? oldRetryPeriod = null)
+ {
+ AlterRetentionPolicyOperation operation = new()
+ {
+ TableName = tableName,
+ Schema = schema ?? string.Empty,
+ DropAfter = dropAfter,
+ DropCreatedBefore = dropCreatedBefore,
+ InitialStart = initialStart,
+ ScheduleInterval = scheduleInterval,
+ MaxRuntime = maxRuntime,
+ MaxRetries = maxRetries,
+ RetryPeriod = retryPeriod,
+ OldDropAfter = oldDropAfter,
+ OldDropCreatedBefore = oldDropCreatedBefore,
+ OldInitialStart = oldInitialStart,
+ OldScheduleInterval = oldScheduleInterval,
+ OldMaxRuntime = oldMaxRuntime,
+ OldMaxRetries = oldMaxRetries,
+ OldRetryPeriod = oldRetryPeriod,
+ };
+
+ migrationBuilder.Operations.Add(operation);
+ return new OperationBuilder(operation);
+ }
+
+ public static OperationBuilder