Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,10 @@ FodyWeavers.xsd
*.msp

# Ignore all generated migration files
CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess/Migrations/
samples/Eftdb.Samples.Shared/Migrations/

# Ignore all scaffolded models and the DbContext from the DbFirst project
CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.DbFirst/
samples/Eftdb.Samples.DatabaseFirst/**/*.cs

# AI
CLAUDE.md
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Seamlessly define and manage **TimescaleDB hypertables** using standard EF Core
- **Time Partitioning**: Easily specify the primary time column and set the `chunk_time_interval`.
- **Space Partitioning**: Add additional dimensions for hash or range partitioning to further optimize queries.
- **Chunk Time Interval**: Configure chunk intervals to balance performance and storage efficiency.
- **Data Migration**: Control whether existing data should be migrated when converting a regular table to a hypertable using `migrate_data`.
- **Compression & Chunk Skipping**: Enable TimescaleDB's native compression and configure chunk skipping to improve query performance.

### Reorder Policies
Expand Down Expand Up @@ -97,7 +98,9 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration<WeatherData>
// Optional: Enable chunk skipping for faster queries on this column.
.WithChunkSkipping(x => x.Time)
// Optional: Set the chunk interval. Can be a string ("7 days") or long (microseconds).
.WithChunkTimeInterval("86400000");
.WithChunkTimeInterval("86400000")
// Optional: Migrate existing data when converting to hypertable (defaults to false).
.WithMigrateData(true);
}
}
```
Expand All @@ -108,7 +111,10 @@ public class WeatherDataConfiguration : IEntityTypeConfiguration<WeatherData>
For simpler configurations, you can use the [Hypertable] attribute directly on your model class.

```csharp
[Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "86400000")]
[Hypertable(nameof(Time),
ChunkSkipColumns = new[] { "Time" },
ChunkTimeInterval = "86400000",
MigrateData = true)]
[PrimaryKey(nameof(Id), nameof(Time))]
public class DeviceReading
{
Expand Down
65 changes: 37 additions & 28 deletions samples/Eftdb.Samples.CodeFirst/README.md
Original file line number Diff line number Diff line change
@@ -1,69 +1,78 @@
# EF Core Code-First Example with TimescaleDB
# EF Core Code-First Example with TimescaleDB

This project demonstrates how to use the **Code-First** approach with [TimescaleDB](https://www.timescale.com/) using the `CmdScale.EntityFrameworkCore.TimescaleDB` package.

---

## 🚀 Migrations and Database Management
## Migrations and Database Management

Use the following commands to manage your EF Core migrations and database updates.

### 📌 Add a new migration
> **Note:** Run all commands from the repository root directory.

### Add a new migration

```bash
dotnet ef migrations add --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example <MigrationName>
dotnet ef migrations add <MigrationName> --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst
```

### Apply migrations to the database
### Apply migrations to the database

```bash
dotnet ef database update --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example
dotnet ef database update --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst
```

### ❌ Remove last migration (if not applied to the database, yet)
```
dotnet ef migrations remove --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example
### Remove last migration (if not applied to the database yet)

```bash
dotnet ef migrations remove --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst
```

### 🧹 Reset all migrations (rollback to initial state)
### Reset all migrations (rollback to initial state)

```bash
dotnet ef database update 0 --project CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess --startup-project CmdScale.EntityFrameworkCore.TimescaleDB.Example
dotnet ef database update 0 --project samples/Eftdb.Samples.Shared --startup-project samples/Eftdb.Samples.CodeFirst
```

---

## 📁 Project Structure
## Project Structure

```text
CmdScale.EntityFrameworkCore.TimescaleDB.Example/
├── CmdScale.EntityFrameworkCore.TimescaleDB.Example/ # Startup project
├── CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess/ # Contains DbContext and migrations
└── docker-compose.yml # Sets up TimescaleDB container (in Solution Items)
samples/
├── Eftdb.Samples.CodeFirst/ # Startup project (contains Program.cs and design-time services)
├── Eftdb.Samples.Shared/ # Contains DbContext, entities, and migrations
└── Eftdb.Samples.DatabaseFirst/ # Database-first scaffolding example
```

---

## 🐳 Docker
- This project assumes you have an existing TimescaleDB-compatible PostgreSQL database.
- A `docker-compose.yml` file is included in the **Solution Items** folder to spin up a TimescaleDB container for local development and testing.
## Docker

This project assumes you have an existing TimescaleDB-compatible PostgreSQL database. A `docker-compose.yml` file is included in the repository root to spin up a TimescaleDB container for local development and testing.

To start the container, run:
To start the container, run from the repository root:

```bash
docker-compose up -d
```
```bash
docker-compose up -d
```

To stop and reset the database:

```bash
docker-compose down -v
```

- Connection string settings should match the configuration in your `docker-compose.yml`.
Connection string settings should match the configuration in your `docker-compose.yml`.

---

## 🧠 Notes
- Depending on if you're using project- or package-references you might to (un)comment adding the services in `TimescaleDBDesignTimeService.cs`
## Notes

- Depending on if you're using project- or package-references you might need to (un)comment adding the services in `TimescaleDBDesignTimeService.cs`
- The `Eftdb.Samples.Shared` project contains the `TimescaleContext` and entity models shared between samples

## 📚 Resources
## Resources

- [Entity Framework Core Documentation](https://learn.microsoft.com/en-us/ef/core/)
- [TimescaleDB Documentation](https://docs.timescale.com/)
Expand Down
11 changes: 9 additions & 2 deletions src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui
ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null;
ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null;

List<string> statements = [];
List<string> statements;
bool suppressTransaction = false;

switch (operation)
Expand Down Expand Up @@ -60,7 +60,14 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui

default:
base.Generate(operation, builder);
break;
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static class HypertableAnnotations
public const string IsHypertable = "TimescaleDB:IsHypertable";
public const string HypertableTimeColumn = "TimescaleDB:TimeColumnName";
public const string EnableCompression = "TimescaleDB:EnableCompression";
public const string MigrateData = "TimescaleDB:MigrateData";
public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval";
public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns";
public const string AdditionalDimensions = "TimescaleDB:AdditionalDimensions";
Expand Down
7 changes: 6 additions & 1 deletion src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ public sealed class HypertableAttribute : Attribute
public string TimeColumnName { get; } = string.Empty;

/// <summary>
///
/// Specifies whether compression is enabled on the hypertable.
/// </summary>
public bool EnableCompression { get; set; } = false;

/// <summary>
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
/// </summary>
public bool MigrateData { get; set; } = false;

/// <summary>
/// Defines the duration of time covered by each chunk in a hypertable.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);
}

if (attribute.MigrateData == true)
{
entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, true);
}

if (attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0)
{
/// Chunk skipping requires compression to be enabled
Expand Down
19 changes: 19 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ public static EntityTypeBuilder<TEntity> EnableCompression<TEntity>(
return entityTypeBuilder;
}

/// <summary>
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
/// </summary>
/// <remarks>
/// When converting an existing table to a hypertable, this parameter controls whether existing data
/// is migrated into chunks. If set to false, only new data will be stored in chunks.
/// Defaults to <c>false</c> to match TimescaleDB's default behavior.
/// </remarks>
/// <typeparam name="TEntity">The entity type being configured.</typeparam>
/// <param name="entityTypeBuilder">The builder for the entity type.</param>
/// <param name="migrateData">A boolean indicating whether to migrate existing data. Defaults to <c>true</c>.</param>
public static EntityTypeBuilder<TEntity> WithMigrateData<TEntity>(
this EntityTypeBuilder<TEntity> entityTypeBuilder,
bool migrateData = true) where TEntity : class
{
entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, migrateData);
return entityTypeBuilder;
}

/// <summary>
/// Extracts the property name from a member access lambda expression.
/// </summary>
Expand Down
4 changes: 3 additions & 1 deletion src/Eftdb/Generators/HypertableOperationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ public List<string> Generate(CreateHypertableOperation operation)
string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema);

string migrateDataParam = operation.MigrateData ? ", migrate_data => true" : "";

List<string> statements =
[
$"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}');"
$"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'{migrateDataParam});"
];

// ChunkTimeInterval
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public static IEnumerable<CreateHypertableOperation> GetHypertables(IRelationalM

string chunkTimeInterval = entityType.FindAnnotation(HypertableAnnotations.ChunkTimeInterval)?.Value as string ?? DefaultValues.ChunkTimeInterval;
bool enableCompression = entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value as bool? ?? false;
bool migrateData = entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value as bool? ?? false;

yield return new CreateHypertableOperation
{
Expand All @@ -84,6 +85,7 @@ public static IEnumerable<CreateHypertableOperation> GetHypertables(IRelationalM
TimeColumnName = timeColumnName,
ChunkTimeInterval = chunkTimeInterval ?? DefaultValues.ChunkTimeInterval,
EnableCompression = enableCompression,
MigrateData = migrateData,
ChunkSkipColumns = chunkSkipColumns,
AdditionalDimensions = additionalDimensions
};
Expand Down
1 change: 1 addition & 0 deletions src/Eftdb/Operations/CreateHypertableOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class CreateHypertableOperation : MigrationOperation
public string TimeColumnName { get; set; } = string.Empty;
public string ChunkTimeInterval { get; set; } = string.Empty;
public bool EnableCompression { get; set; }
public bool MigrateData { get; set; } = false;
public IReadOnlyList<string>? ChunkSkipColumns { get; set; }
public IReadOnlyList<Dimension>? AdditionalDimensions { get; set; }
}
Expand Down
38 changes: 38 additions & 0 deletions tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,43 @@ public void ChunkSkipColumns_CanBeSetToEmptyArray()
Assert.Empty(attr.ChunkSkipColumns);
}

[Fact]
public void MigrateData_DefaultsToFalse()
{
// Arrange & Act
HypertableAttribute attr = new("Timestamp");

// Assert
Assert.False(attr.MigrateData);
}

[Fact]
public void MigrateData_CanBeSetToTrue()
{
// Arrange
HypertableAttribute attr = new("Timestamp")
{
// Act
MigrateData = true
};

// Assert
Assert.True(attr.MigrateData);
}

[Fact]
public void MigrateData_CanBeSetToFalse()
{
// Arrange
HypertableAttribute attr = new("Timestamp")
{
// Act
MigrateData = false
};

// Assert
Assert.False(attr.MigrateData);
}

#endregion
}
Loading
Loading