Skip to content

Commit b9a9658

Browse files
feat: add migrate_data parameter support for hypertables (fixes #16)
Adds the ability to specify whether existing data should be migrated when converting a regular PostgreSQL table to a TimescaleDB hypertable. Implementation: - Added MigrateData property to CreateHypertableOperation - Added MigrateData annotation constant to HypertableAnnotations - Added MigrateData property to HypertableAttribute - Added WithMigrateData() fluent API method to HypertableTypeBuilder - Updated HypertableConvention to process MigrateData from attribute - Updated HypertableModelExtractor to extract MigrateData annotation - Updated HypertableOperationGenerator to generate migrate_data => true SQL Testing: - Added 19 unit tests across 5 test files - Added 5 integration tests verifying end-to-end behavior
1 parent f72b4fc commit b9a9658

File tree

14 files changed

+1140
-4
lines changed

14 files changed

+1140
-4
lines changed

README.md

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

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

110113
```csharp
111-
[Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "86400000")]
114+
[Hypertable(nameof(Time),
115+
ChunkSkipColumns = new[] { "Time" },
116+
ChunkTimeInterval = "86400000",
117+
MigrateData = true)]
112118
[PrimaryKey(nameof(Id), nameof(Time))]
113119
public class DeviceReading
114120
{

src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public static class HypertableAnnotations
88
public const string IsHypertable = "TimescaleDB:IsHypertable";
99
public const string HypertableTimeColumn = "TimescaleDB:TimeColumnName";
1010
public const string EnableCompression = "TimescaleDB:EnableCompression";
11+
public const string MigrateData = "TimescaleDB:MigrateData";
1112
public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval";
1213
public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns";
1314
public const string AdditionalDimensions = "TimescaleDB:AdditionalDimensions";

src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ public sealed class HypertableAttribute : Attribute
1010
public string TimeColumnName { get; } = string.Empty;
1111

1212
/// <summary>
13-
///
13+
/// Specifies whether compression is enabled on the hypertable.
1414
/// </summary>
1515
public bool EnableCompression { get; set; } = false;
1616

17+
/// <summary>
18+
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
19+
/// </summary>
20+
public bool MigrateData { get; set; } = false;
21+
1722
/// <summary>
1823
/// Defines the duration of time covered by each chunk in a hypertable.
1924
/// </summary>

src/Eftdb/Configuration/Hypertable/HypertableConvention.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde
3737
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);
3838
}
3939

40+
if (attribute.MigrateData == true)
41+
{
42+
entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, true);
43+
}
44+
4045
if (attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0)
4146
{
4247
/// Chunk skipping requires compression to be enabled

src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,25 @@ public static EntityTypeBuilder<TEntity> EnableCompression<TEntity>(
128128
return entityTypeBuilder;
129129
}
130130

131+
/// <summary>
132+
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
133+
/// </summary>
134+
/// <remarks>
135+
/// When converting an existing table to a hypertable, this parameter controls whether existing data
136+
/// is migrated into chunks. If set to false, only new data will be stored in chunks.
137+
/// Defaults to <c>false</c> to match TimescaleDB's default behavior.
138+
/// </remarks>
139+
/// <typeparam name="TEntity">The entity type being configured.</typeparam>
140+
/// <param name="entityTypeBuilder">The builder for the entity type.</param>
141+
/// <param name="migrateData">A boolean indicating whether to migrate existing data. Defaults to <c>true</c>.</param>
142+
public static EntityTypeBuilder<TEntity> WithMigrateData<TEntity>(
143+
this EntityTypeBuilder<TEntity> entityTypeBuilder,
144+
bool migrateData = true) where TEntity : class
145+
{
146+
entityTypeBuilder.HasAnnotation(HypertableAnnotations.MigrateData, migrateData);
147+
return entityTypeBuilder;
148+
}
149+
131150
/// <summary>
132151
/// Extracts the property name from a member access lambda expression.
133152
/// </summary>

src/Eftdb/Generators/HypertableOperationGenerator.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ public List<string> Generate(CreateHypertableOperation operation)
2424
string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema);
2525
string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema);
2626

27+
string migrateDataParam = operation.MigrateData ? ", migrate_data => true" : "";
28+
2729
List<string> statements =
2830
[
29-
$"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}');"
31+
$"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'{migrateDataParam});"
3032
];
3133

3234
// ChunkTimeInterval

src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public static IEnumerable<CreateHypertableOperation> GetHypertables(IRelationalM
7676

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

8081
yield return new CreateHypertableOperation
8182
{
@@ -84,6 +85,7 @@ public static IEnumerable<CreateHypertableOperation> GetHypertables(IRelationalM
8485
TimeColumnName = timeColumnName,
8586
ChunkTimeInterval = chunkTimeInterval ?? DefaultValues.ChunkTimeInterval,
8687
EnableCompression = enableCompression,
88+
MigrateData = migrateData,
8789
ChunkSkipColumns = chunkSkipColumns,
8890
AdditionalDimensions = additionalDimensions
8991
};

src/Eftdb/Operations/CreateHypertableOperation.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class CreateHypertableOperation : MigrationOperation
1010
public string TimeColumnName { get; set; } = string.Empty;
1111
public string ChunkTimeInterval { get; set; } = string.Empty;
1212
public bool EnableCompression { get; set; }
13+
public bool MigrateData { get; set; } = false;
1314
public IReadOnlyList<string>? ChunkSkipColumns { get; set; }
1415
public IReadOnlyList<Dimension>? AdditionalDimensions { get; set; }
1516
}

tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,43 @@ public void ChunkSkipColumns_CanBeSetToEmptyArray()
169169
Assert.Empty(attr.ChunkSkipColumns);
170170
}
171171

172+
[Fact]
173+
public void MigrateData_DefaultsToFalse()
174+
{
175+
// Arrange & Act
176+
HypertableAttribute attr = new("Timestamp");
177+
178+
// Assert
179+
Assert.False(attr.MigrateData);
180+
}
181+
182+
[Fact]
183+
public void MigrateData_CanBeSetToTrue()
184+
{
185+
// Arrange
186+
HypertableAttribute attr = new("Timestamp")
187+
{
188+
// Act
189+
MigrateData = true
190+
};
191+
192+
// Assert
193+
Assert.True(attr.MigrateData);
194+
}
195+
196+
[Fact]
197+
public void MigrateData_CanBeSetToFalse()
198+
{
199+
// Arrange
200+
HypertableAttribute attr = new("Timestamp")
201+
{
202+
// Act
203+
MigrateData = false
204+
};
205+
206+
// Assert
207+
Assert.False(attr.MigrateData);
208+
}
209+
172210
#endregion
173211
}

tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,4 +595,202 @@ public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI()
595595
}
596596

597597
#endregion
598+
599+
#region Should_Process_Hypertable_With_MigrateData_True
600+
601+
[Hypertable("Timestamp", MigrateData = true)]
602+
private class MigrateDataTrueEntity
603+
{
604+
public DateTime Timestamp { get; set; }
605+
public double Value { get; set; }
606+
}
607+
608+
private class MigrateDataTrueContext : DbContext
609+
{
610+
public DbSet<MigrateDataTrueEntity> Entities => Set<MigrateDataTrueEntity>();
611+
612+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
613+
=> optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
614+
.UseTimescaleDb();
615+
616+
protected override void OnModelCreating(ModelBuilder modelBuilder)
617+
{
618+
modelBuilder.Entity<MigrateDataTrueEntity>(entity =>
619+
{
620+
entity.HasNoKey();
621+
entity.ToTable("MigrateDataTrue");
622+
});
623+
}
624+
}
625+
626+
[Fact]
627+
public void Should_Process_Hypertable_With_MigrateData_True()
628+
{
629+
using MigrateDataTrueContext context = new();
630+
IModel model = GetModel(context);
631+
IEntityType entityType = model.FindEntityType(typeof(MigrateDataTrueEntity))!;
632+
633+
Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value);
634+
Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.MigrateData)?.Value);
635+
}
636+
637+
#endregion
638+
639+
#region Should_Not_Apply_MigrateData_When_False
640+
641+
[Hypertable("Timestamp", MigrateData = false)]
642+
private class MigrateDataFalseEntity
643+
{
644+
public DateTime Timestamp { get; set; }
645+
public double Value { get; set; }
646+
}
647+
648+
private class MigrateDataFalseContext : DbContext
649+
{
650+
public DbSet<MigrateDataFalseEntity> Entities => Set<MigrateDataFalseEntity>();
651+
652+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
653+
=> optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
654+
.UseTimescaleDb();
655+
656+
protected override void OnModelCreating(ModelBuilder modelBuilder)
657+
{
658+
modelBuilder.Entity<MigrateDataFalseEntity>(entity =>
659+
{
660+
entity.HasNoKey();
661+
entity.ToTable("MigrateDataFalse");
662+
});
663+
}
664+
}
665+
666+
[Fact]
667+
public void Should_Not_Apply_MigrateData_When_False()
668+
{
669+
using MigrateDataFalseContext context = new();
670+
IModel model = GetModel(context);
671+
IEntityType entityType = model.FindEntityType(typeof(MigrateDataFalseEntity))!;
672+
673+
// MigrateData annotation should be null when the attribute property is false
674+
Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData));
675+
// But IsHypertable should still be set
676+
Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value);
677+
}
678+
679+
#endregion
680+
681+
#region Should_Not_Apply_MigrateData_By_Default
682+
683+
[Hypertable("Timestamp")]
684+
private class MigrateDataDefaultEntity
685+
{
686+
public DateTime Timestamp { get; set; }
687+
public double Value { get; set; }
688+
}
689+
690+
private class MigrateDataDefaultContext : DbContext
691+
{
692+
public DbSet<MigrateDataDefaultEntity> Entities => Set<MigrateDataDefaultEntity>();
693+
694+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
695+
=> optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
696+
.UseTimescaleDb();
697+
698+
protected override void OnModelCreating(ModelBuilder modelBuilder)
699+
{
700+
modelBuilder.Entity<MigrateDataDefaultEntity>(entity =>
701+
{
702+
entity.HasNoKey();
703+
entity.ToTable("MigrateDataDefault");
704+
});
705+
}
706+
}
707+
708+
[Fact]
709+
public void Should_Not_Apply_MigrateData_By_Default()
710+
{
711+
using MigrateDataDefaultContext context = new();
712+
IModel model = GetModel(context);
713+
IEntityType entityType = model.FindEntityType(typeof(MigrateDataDefaultEntity))!;
714+
715+
// When MigrateData is not explicitly set in attribute, annotation should be null
716+
Assert.Null(entityType.FindAnnotation(HypertableAnnotations.MigrateData));
717+
// But IsHypertable should still be set
718+
Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value);
719+
}
720+
721+
#endregion
722+
723+
#region MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI
724+
725+
[Hypertable("Timestamp", MigrateData = true)]
726+
private class MigrateDataAttributeEntity
727+
{
728+
public DateTime Timestamp { get; set; }
729+
public double Value { get; set; }
730+
}
731+
732+
private class MigrateDataFluentEntity
733+
{
734+
public DateTime Timestamp { get; set; }
735+
public double Value { get; set; }
736+
}
737+
738+
private class MigrateDataAttributeContext : DbContext
739+
{
740+
public DbSet<MigrateDataAttributeEntity> Entities => Set<MigrateDataAttributeEntity>();
741+
742+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
743+
=> optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
744+
.UseTimescaleDb();
745+
746+
protected override void OnModelCreating(ModelBuilder modelBuilder)
747+
{
748+
modelBuilder.Entity<MigrateDataAttributeEntity>(entity =>
749+
{
750+
entity.HasNoKey();
751+
entity.ToTable("MigrateDataEquivalence");
752+
});
753+
}
754+
}
755+
756+
private class MigrateDataFluentContext : DbContext
757+
{
758+
public DbSet<MigrateDataFluentEntity> Entities => Set<MigrateDataFluentEntity>();
759+
760+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
761+
=> optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test")
762+
.UseTimescaleDb();
763+
764+
protected override void OnModelCreating(ModelBuilder modelBuilder)
765+
{
766+
modelBuilder.Entity<MigrateDataFluentEntity>(entity =>
767+
{
768+
entity.HasNoKey();
769+
entity.ToTable("MigrateDataEquivalence");
770+
entity.IsHypertable(x => x.Timestamp)
771+
.WithMigrateData(true);
772+
});
773+
}
774+
}
775+
776+
[Fact]
777+
public void MigrateData_Attribute_Should_Produce_Same_Annotation_As_FluentAPI()
778+
{
779+
using MigrateDataAttributeContext attributeContext = new();
780+
using MigrateDataFluentContext fluentContext = new();
781+
782+
IModel attributeModel = GetModel(attributeContext);
783+
IModel fluentModel = GetModel(fluentContext);
784+
785+
IEntityType attributeEntity = attributeModel.FindEntityType(typeof(MigrateDataAttributeEntity))!;
786+
IEntityType fluentEntity = fluentModel.FindEntityType(typeof(MigrateDataFluentEntity))!;
787+
788+
Assert.Equal(
789+
attributeEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value,
790+
fluentEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value
791+
);
792+
Assert.Equal(true, attributeEntity.FindAnnotation(HypertableAnnotations.MigrateData)?.Value);
793+
}
794+
795+
#endregion
598796
}

0 commit comments

Comments
 (0)