Skip to content

Commit f6ab044

Browse files
test: Add comprehensive test coverage for continuous aggregate policies
Add unit tests for previously untested components: builder validation, model extractor, annotation applier, and convention error handling. These tests cover input validation, default value handling, schema resolution, annotation application, and fluent API method chaining.
1 parent ffb5c65 commit f6ab044

24 files changed

Lines changed: 6607 additions & 144 deletions

samples/Eftdb.Samples.Shared/Configurations/TradeAggregateConfiguration.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ public void Configure(EntityTypeBuilder<TradeAggregate> builder)
2121
.Where("\"ticker\" = 'MCRS'")
2222
.MaterializedOnly()
2323
.WithRefreshPolicy(startOffset: "7 days", endOffset: "1 hour", scheduleInterval: "1 hour")
24-
.WithTimezone("UTC")
2524
.WithRefreshNewestFirst(true);
2625
}
2726
}

samples/Eftdb.Samples.Shared/Models/WeatherAggregate.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.Models
2323
StartOffset = "30 days",
2424
EndOffset = "1 day",
2525
ScheduleInterval = "1 hour",
26-
Timezone = "UTC",
2726
RefreshNewestFirst = true)]
2827
public class WeatherAggregate
2928
{

src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyAnnotationApplier.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,6 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo)
3636
table[ContinuousAggregatePolicyAnnotations.ScheduleInterval] = info.ScheduleInterval;
3737
}
3838

39-
// Apply timezone
40-
if (!string.IsNullOrWhiteSpace(info.Timezone))
41-
{
42-
table[ContinuousAggregatePolicyAnnotations.Timezone] = info.Timezone;
43-
}
44-
4539
// Apply initial_start
4640
if (info.InitialStart.HasValue)
4741
{

src/Eftdb.Design/Scaffolding/ContinuousAggregatePolicyScaffoldingExtractor.cs

Lines changed: 2 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ public sealed record ContinuousAggregatePolicyInfo(
1313
string? StartOffset,
1414
string? EndOffset,
1515
string? ScheduleInterval,
16-
string? Timezone,
1716
DateTime? InitialStart,
1817
bool? IncludeTieredData,
1918
int? BucketsPerBatch,
@@ -36,7 +35,6 @@ public sealed record ContinuousAggregatePolicyInfo(
3635
using (DbCommand command = connection.CreateCommand())
3736
{
3837
// Query continuous aggregate policies from TimescaleDB jobs table
39-
// The config column contains JSONB with start_offset, end_offset, timezone, mat_hypertable_id and other policy parameters
4038
command.CommandText = @"
4139
SELECT
4240
ca.user_view_schema,
@@ -61,7 +59,6 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca
6159
// Parse the JSONB config to extract policy parameters
6260
string? startOffset = null;
6361
string? endOffset = null;
64-
string? timezone = null;
6562
bool? includeTieredData = null;
6663
int? bucketsPerBatch = null;
6764
int? maxBatchesPerExecution = null;
@@ -75,20 +72,13 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca
7572
// Extract start_offset
7673
if (root.TryGetProperty("start_offset", out JsonElement startOffsetElement))
7774
{
78-
startOffset = ParseIntervalOrInteger(startOffsetElement);
75+
startOffset = IntervalParsingHelper.ParseIntervalOrInteger(startOffsetElement);
7976
}
8077

8178
// Extract end_offset
8279
if (root.TryGetProperty("end_offset", out JsonElement endOffsetElement))
8380
{
84-
endOffset = ParseIntervalOrInteger(endOffsetElement);
85-
}
86-
87-
// Extract timezone
88-
if (root.TryGetProperty("timezone", out JsonElement timezoneElement)
89-
&& timezoneElement.ValueKind == JsonValueKind.String)
90-
{
91-
timezone = timezoneElement.GetString();
81+
endOffset = IntervalParsingHelper.ParseIntervalOrInteger(endOffsetElement);
9282
}
9383

9484
// Extract include_tiered_data (optional)
@@ -124,7 +114,6 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca
124114
StartOffset: startOffset,
125115
EndOffset: endOffset,
126116
ScheduleInterval: scheduleInterval,
127-
Timezone: timezone,
128117
InitialStart: initialStart,
129118
IncludeTieredData: includeTieredData,
130119
BucketsPerBatch: bucketsPerBatch,
@@ -148,80 +137,5 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca
148137
}
149138
}
150139
}
151-
152-
/// <summary>
153-
/// Parses an interval or integer value from JSONB.
154-
/// TimescaleDB stores intervals as strings (e.g., "1 mon", "7 days")
155-
/// or integers for integer-based time columns.
156-
/// </summary>
157-
private static string? ParseIntervalOrInteger(JsonElement element)
158-
{
159-
if (element.ValueKind == JsonValueKind.Null)
160-
{
161-
return null;
162-
}
163-
164-
if (element.ValueKind == JsonValueKind.String)
165-
{
166-
string value = element.GetString() ?? string.Empty;
167-
// TimescaleDB stores intervals in PostgreSQL format (e.g., "1 mon", "7 days", "01:00:00")
168-
// We need to normalize these to a format that matches what users would write
169-
return NormalizeInterval(value);
170-
}
171-
172-
if (element.ValueKind == JsonValueKind.Number)
173-
{
174-
// Integer-based time column
175-
return element.GetInt64().ToString();
176-
}
177-
178-
return null;
179-
}
180-
181-
/// <summary>
182-
/// Normalizes PostgreSQL interval format to user-friendly format.
183-
/// </summary>
184-
/// <remarks>
185-
/// PostgreSQL stores intervals in formats like:
186-
/// - "1 mon" for 1 month
187-
/// - "7 days" for 7 days
188-
/// - "01:00:00" for 1 hour
189-
/// We normalize these to match the format users would use in Fluent API:
190-
/// - "1 month"
191-
/// - "7 days"
192-
/// - "1 hour"
193-
/// </remarks>
194-
private static string NormalizeInterval(string pgInterval)
195-
{
196-
if (string.IsNullOrWhiteSpace(pgInterval))
197-
{
198-
return pgInterval;
199-
}
200-
201-
string normalized = pgInterval.Trim();
202-
203-
// Replace "mon" with "month"
204-
normalized = normalized.Replace(" mon", " month");
205-
206-
// Convert time-only intervals (HH:MM:SS) to hour/minute format
207-
if (TimeSpan.TryParse(normalized, out TimeSpan timeSpan))
208-
{
209-
if (timeSpan.TotalMinutes < 60 && timeSpan.Minutes > 0 && timeSpan.Hours == 0)
210-
{
211-
return $"{timeSpan.Minutes} minute{(timeSpan.Minutes > 1 ? "s" : "")}";
212-
}
213-
if (timeSpan.TotalHours < 24 && timeSpan.Hours > 0)
214-
{
215-
return $"{timeSpan.Hours} hour{(timeSpan.Hours > 1 ? "s" : "")}";
216-
}
217-
// For days, use the total days
218-
if (timeSpan.Days > 0)
219-
{
220-
return $"{timeSpan.Days} day{(timeSpan.Days > 1 ? "s" : "")}";
221-
}
222-
}
223-
224-
return normalized;
225-
}
226140
}
227141
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using System.Text.Json;
2+
3+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
4+
{
5+
/// <summary>
6+
/// Provides helper methods for parsing and normalizing TimescaleDB interval values.
7+
/// </summary>
8+
public static class IntervalParsingHelper
9+
{
10+
/// <summary>
11+
/// Parses an interval or integer value from a JSON element.
12+
/// </summary>
13+
/// <param name="element">The JSON element to parse.</param>
14+
/// <returns>
15+
/// A normalized interval string for string-based intervals,
16+
/// or a string representation of the integer for integer-based time columns,
17+
/// or null if the element is null or cannot be parsed.
18+
/// </returns>
19+
/// <remarks>
20+
/// TimescaleDB stores intervals as strings (e.g., "1 mon", "7 days")
21+
/// or integers for integer-based time columns.
22+
/// </remarks>
23+
public static string? ParseIntervalOrInteger(JsonElement element)
24+
{
25+
if (element.ValueKind == JsonValueKind.Null)
26+
{
27+
return null;
28+
}
29+
30+
if (element.ValueKind == JsonValueKind.String)
31+
{
32+
string value = element.GetString() ?? string.Empty;
33+
return NormalizeInterval(value);
34+
}
35+
36+
if (element.ValueKind == JsonValueKind.Number)
37+
{
38+
return element.GetInt64().ToString();
39+
}
40+
41+
return null;
42+
}
43+
44+
/// <summary>
45+
/// Normalizes PostgreSQL interval format to a user-friendly format.
46+
/// </summary>
47+
/// <param name="pgInterval">The PostgreSQL interval string to normalize.</param>
48+
/// <returns>A normalized interval string.</returns>
49+
/// <remarks>
50+
/// PostgreSQL stores intervals in formats like:
51+
/// - "1 mon" for 1 month
52+
/// - "7 days" for 7 days
53+
/// - "01:00:00" for 1 hour
54+
/// This method normalizes these to match the format users would use in Fluent API:
55+
/// - "1 month"
56+
/// - "7 days"
57+
/// - "1 hour"
58+
/// </remarks>
59+
public static string NormalizeInterval(string pgInterval)
60+
{
61+
if (string.IsNullOrWhiteSpace(pgInterval))
62+
{
63+
return pgInterval;
64+
}
65+
66+
string normalized = pgInterval.Trim();
67+
68+
normalized = normalized.Replace(" mon", " month");
69+
70+
if (TimeSpan.TryParse(normalized, out TimeSpan timeSpan))
71+
{
72+
if (timeSpan.TotalMinutes < 60 && timeSpan.Minutes > 0 && timeSpan.Hours == 0)
73+
{
74+
return $"{timeSpan.Minutes} minute{(timeSpan.Minutes > 1 ? "s" : "")}";
75+
}
76+
if (timeSpan.TotalHours < 24 && timeSpan.Hours > 0)
77+
{
78+
return $"{timeSpan.Hours} hour{(timeSpan.Hours > 1 ? "s" : "")}";
79+
}
80+
if (timeSpan.Days > 0)
81+
{
82+
return $"{timeSpan.Days} day{(timeSpan.Days > 1 ? "s" : "")}";
83+
}
84+
}
85+
86+
return normalized;
87+
}
88+
}
89+
}

src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregateBuilderPolicyExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ public static class ContinuousAggregateBuilderPolicyExtensions
2121
/// <code>
2222
/// builder.IsContinuousAggregate&lt;HourlyMetric, Metric&gt;("hourly_metrics", "1 hour", x => x.Timestamp)
2323
/// .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour")
24-
/// .WithTimezone("UTC")
2524
/// .WithRefreshNewestFirst(true);
2625
/// </code>
2726
/// </example>

src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyAnnotations.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,6 @@ public static class ContinuousAggregatePolicyAnnotations
3838
/// </summary>
3939
public const string IfNotExists = "TimescaleDB:ContinuousAggregatePolicy:IfNotExists";
4040

41-
/// <summary>
42-
/// Timezone to mitigate daylight savings alignment shifts. Stored as string (e.g., "UTC", "America/New_York").
43-
/// </summary>
44-
public const string Timezone = "TimescaleDB:ContinuousAggregatePolicy:Timezone";
45-
4641
/// <summary>
4742
/// Override tiered read settings. Stored as nullable bool.
4843
/// </summary>

src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyAttribute.cs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,6 @@ public sealed class ContinuousAggregatePolicyAttribute : Attribute
7070
/// </summary>
7171
public bool IfNotExists { get; set; } = false;
7272

73-
/// <summary>
74-
/// Gets or sets the timezone to mitigate daylight savings alignment shifts.
75-
/// </summary>
76-
/// <example>
77-
/// "UTC", "America/New_York", "Europe/London"
78-
/// </example>
79-
public string? Timezone { get; set; }
80-
8173
/// <summary>
8274
/// Gets or sets a value indicating whether to override tiered read settings.
8375
/// NULL means use default behavior.

src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyBuilder.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,6 @@ public ContinuousAggregatePolicyBuilder<TEntity, TSourceEntity> WithIfNotExists(
5151
return this;
5252
}
5353

54-
/// <summary>
55-
/// Sets the timezone for the continuous aggregate refresh policy to mitigate daylight savings alignment shifts.
56-
/// </summary>
57-
/// <param name="timezone">The timezone (e.g., "UTC", "America/New_York", "Europe/London").</param>
58-
/// <returns>The builder for method chaining.</returns>
59-
public ContinuousAggregatePolicyBuilder<TEntity, TSourceEntity> WithTimezone(string timezone)
60-
{
61-
if (string.IsNullOrWhiteSpace(timezone))
62-
throw new ArgumentException("Timezone must be provided.", nameof(timezone));
63-
64-
EntityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.Timezone, timezone);
65-
return this;
66-
}
67-
6854
/// <summary>
6955
/// Configures whether to override tiered read settings for the continuous aggregate refresh policy.
7056
/// </summary>

src/Eftdb/Configuration/ContinuousAggregatePolicy/ContinuousAggregatePolicyConvention.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde
6060
if (attribute.IfNotExists)
6161
entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.IfNotExists, attribute.IfNotExists);
6262

63-
// Apply timezone
64-
if (!string.IsNullOrWhiteSpace(attribute.Timezone))
65-
entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.Timezone, attribute.Timezone);
66-
6763
// Apply include_tiered_data if explicitly set
6864
if (attribute.IncludeTieredData.HasValue)
6965
entityTypeBuilder.HasAnnotation(ContinuousAggregatePolicyAnnotations.IncludeTieredData, attribute.IncludeTieredData.Value);

0 commit comments

Comments
 (0)