Skip to content

Commit 139a91d

Browse files
Merge pull request #13 from cmdscale/feature/continuous_aggregates
feature/continuous aggregates
2 parents 2bc9b8a + 3785be1 commit 139a91d

File tree

49 files changed

+3375
-593
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+3375
-593
lines changed

.editorconfig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[*.cs]
2+
3+
# IDE0066: Convert switch statement to expression
4+
csharp_style_prefer_switch_expression = false
5+
6+
# IDE0079: Remove unnecessary suppression
7+
dotnet_diagnostic.IDE0079.severity = none

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,4 +431,8 @@ FodyWeavers.xsd
431431
CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess/Migrations/
432432

433433
# Ignore all scaffolded models and the DbContext from the DbFirst project
434-
CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.DbFirst/
434+
CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.DbFirst/
435+
436+
# AI
437+
CLAUDE.md
438+
.claude
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate;
2+
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;
3+
using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.ContinuousAggregateScaffoldingExtractor;
4+
5+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
6+
{
7+
/// <summary>
8+
/// Applies continuous aggregate annotations to scaffolded database views.
9+
/// Note: Continuous aggregates in TimescaleDB are materialized views, so they appear as tables/views in scaffolding.
10+
/// </summary>
11+
internal sealed class ContinuousAggregateAnnotationApplier : IAnnotationApplier
12+
{
13+
public void ApplyAnnotations(DatabaseTable table, object featureInfo)
14+
{
15+
if (featureInfo is not ContinuousAggregateInfo info)
16+
{
17+
throw new ArgumentException($"Expected {nameof(ContinuousAggregateInfo)}, got {featureInfo.GetType().Name}", nameof(featureInfo));
18+
}
19+
20+
// Mark as a continuous aggregate view
21+
table[ContinuousAggregateAnnotations.MaterializedViewName] = info.MaterializedViewName;
22+
table[ContinuousAggregateAnnotations.ParentName] = info.SourceHypertableName;
23+
table[ContinuousAggregateAnnotations.MaterializedOnly] = info.MaterializedOnly;
24+
25+
if (!string.IsNullOrEmpty(info.ChunkInterval))
26+
{
27+
table[ContinuousAggregateAnnotations.ChunkInterval] = info.ChunkInterval;
28+
}
29+
30+
// Store the view definition for reference (custom annotation)
31+
// This will help users understand the structure when scaffolding
32+
table["TimescaleDB:ViewDefinition"] = info.ViewDefinition;
33+
}
34+
}
35+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Data;
2+
using System.Data.Common;
3+
4+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
5+
{
6+
/// <summary>
7+
/// Extracts continuous aggregate metadata from a TimescaleDB database for scaffolding.
8+
/// </summary>
9+
internal sealed class ContinuousAggregateScaffoldingExtractor : ITimescaleFeatureExtractor
10+
{
11+
internal sealed record ContinuousAggregateInfo(
12+
string MaterializedViewName,
13+
string Schema,
14+
string ViewDefinition,
15+
string SourceHypertableName,
16+
string SourceSchema,
17+
bool MaterializedOnly,
18+
string? ChunkInterval
19+
);
20+
21+
public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection)
22+
{
23+
bool wasOpen = connection.State == ConnectionState.Open;
24+
if (!wasOpen)
25+
{
26+
connection.Open();
27+
}
28+
29+
try
30+
{
31+
Dictionary<(string, string), ContinuousAggregateInfo> continuousAggregates = [];
32+
33+
using (DbCommand command = connection.CreateCommand())
34+
{
35+
// Query continuous aggregates from TimescaleDB information schema
36+
// This query supports TimescaleDB v2.16 and higher
37+
command.CommandText = @"
38+
SELECT
39+
ca.view_schema,
40+
ca.view_name,
41+
ca.view_definition,
42+
ca.hypertable_schema,
43+
ca.hypertable_name,
44+
ca.materialized_only,
45+
CASE
46+
WHEN d.interval_length IS NOT NULL THEN
47+
(INTERVAL '1 microsecond' * d.interval_length)::text
48+
ELSE NULL
49+
END AS chunk_interval
50+
FROM timescaledb_information.continuous_aggregates ca
51+
LEFT JOIN _timescaledb_catalog.continuous_agg cagg
52+
ON ca.view_schema = cagg.user_view_schema
53+
AND ca.view_name = cagg.user_view_name
54+
LEFT JOIN _timescaledb_catalog.dimension d
55+
ON cagg.mat_hypertable_id = d.hypertable_id
56+
AND d.id = (
57+
SELECT MIN(d2.id)
58+
FROM _timescaledb_catalog.dimension d2
59+
WHERE d2.hypertable_id = cagg.mat_hypertable_id
60+
);";
61+
62+
using DbDataReader reader = command.ExecuteReader();
63+
while (reader.Read())
64+
{
65+
string viewSchema = reader.GetString(0);
66+
string viewName = reader.GetString(1);
67+
string viewDefinition = reader.GetString(2);
68+
string hypertableSchema = reader.GetString(3);
69+
string hypertableName = reader.GetString(4);
70+
bool materializedOnly = reader.GetBoolean(5);
71+
string? chunkInterval = reader.IsDBNull(6) ? null : reader.GetString(6);
72+
73+
continuousAggregates[(viewSchema, viewName)] = new ContinuousAggregateInfo(
74+
MaterializedViewName: viewName,
75+
Schema: viewSchema,
76+
ViewDefinition: viewDefinition,
77+
SourceHypertableName: hypertableName,
78+
SourceSchema: hypertableSchema,
79+
MaterializedOnly: materializedOnly,
80+
ChunkInterval: chunkInterval
81+
);
82+
}
83+
}
84+
85+
// Convert to object dictionary to match interface
86+
return continuousAggregates.ToDictionary(
87+
kvp => kvp.Key,
88+
kvp => (object)kvp.Value
89+
);
90+
}
91+
finally
92+
{
93+
if (!wasOpen)
94+
{
95+
connection.Close();
96+
}
97+
}
98+
}
99+
}
100+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable;
2+
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;
3+
using System.Text.Json;
4+
using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.HypertableScaffoldingExtractor;
5+
6+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
7+
{
8+
/// <summary>
9+
/// Applies hypertable annotations to scaffolded database tables.
10+
/// </summary>
11+
internal sealed class HypertableAnnotationApplier : IAnnotationApplier
12+
{
13+
public void ApplyAnnotations(DatabaseTable table, object featureInfo)
14+
{
15+
if (featureInfo is not HypertableInfo info)
16+
{
17+
throw new ArgumentException($"Expected {nameof(HypertableInfo)}, got {featureInfo.GetType().Name}", nameof(featureInfo));
18+
}
19+
20+
table[HypertableAnnotations.IsHypertable] = true;
21+
table[HypertableAnnotations.HypertableTimeColumn] = info.TimeColumnName;
22+
table[HypertableAnnotations.ChunkTimeInterval] = info.ChunkTimeInterval;
23+
table[HypertableAnnotations.EnableCompression] = info.CompressionEnabled;
24+
25+
if (info.ChunkSkipColumns.Count > 0)
26+
{
27+
table[HypertableAnnotations.ChunkSkipColumns] = string.Join(",", info.ChunkSkipColumns);
28+
}
29+
30+
if (info.AdditionalDimensions.Count > 0)
31+
{
32+
table[HypertableAnnotations.AdditionalDimensions] = JsonSerializer.Serialize(info.AdditionalDimensions);
33+
}
34+
}
35+
}
36+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions;
2+
using System.Data;
3+
using System.Data.Common;
4+
5+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
6+
{
7+
/// <summary>
8+
/// Extracts hypertable metadata from a TimescaleDB database for scaffolding.
9+
/// </summary>
10+
internal sealed class HypertableScaffoldingExtractor : ITimescaleFeatureExtractor
11+
{
12+
internal sealed record HypertableInfo(
13+
string TimeColumnName,
14+
string ChunkTimeInterval,
15+
bool CompressionEnabled,
16+
List<string> ChunkSkipColumns,
17+
List<Dimension> AdditionalDimensions
18+
);
19+
20+
public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection)
21+
{
22+
bool wasOpen = connection.State == ConnectionState.Open;
23+
if (!wasOpen)
24+
{
25+
connection.Open();
26+
}
27+
28+
try
29+
{
30+
Dictionary<(string, string), HypertableInfo> hypertables = [];
31+
Dictionary<(string, string), bool> compressionSettings = GetCompressionSettings(connection);
32+
33+
GetHypertableSettings(connection, hypertables, compressionSettings);
34+
GetChunkSkipColumns(connection, hypertables);
35+
36+
// Convert to object dictionary to match interface
37+
return hypertables.ToDictionary(
38+
kvp => kvp.Key,
39+
kvp => (object)kvp.Value
40+
);
41+
}
42+
finally
43+
{
44+
if (!wasOpen)
45+
{
46+
connection.Close();
47+
}
48+
}
49+
}
50+
51+
private static Dictionary<(string, string), bool> GetCompressionSettings(DbConnection connection)
52+
{
53+
Dictionary<(string, string), bool> compressionSettings = [];
54+
using DbCommand command = connection.CreateCommand();
55+
command.CommandText = "SELECT hypertable_schema, hypertable_name, compression_enabled FROM timescaledb_information.hypertables;";
56+
using DbDataReader reader = command.ExecuteReader();
57+
while (reader.Read())
58+
{
59+
compressionSettings[(reader.GetString(0), reader.GetString(1))] = reader.GetBoolean(2);
60+
}
61+
return compressionSettings;
62+
}
63+
64+
private static void GetHypertableSettings(
65+
DbConnection connection,
66+
Dictionary<(string, string), HypertableInfo> hypertables,
67+
Dictionary<(string, string), bool> compressionSettings)
68+
{
69+
using DbCommand command = connection.CreateCommand();
70+
command.CommandText = @"
71+
SELECT
72+
hypertable_schema,
73+
hypertable_name,
74+
column_name,
75+
dimension_number,
76+
num_partitions,
77+
EXTRACT(EPOCH FROM time_interval) * 1000 AS time_interval_microseconds
78+
FROM timescaledb_information.dimensions
79+
ORDER BY hypertable_schema, hypertable_name, dimension_number;";
80+
81+
using DbDataReader reader = command.ExecuteReader();
82+
while (reader.Read())
83+
{
84+
string schema = reader.GetString(0);
85+
string name = reader.GetString(1);
86+
string columnName = reader.GetString(2);
87+
int dimensionNumber = reader.GetInt32(3);
88+
89+
(string schema, string name) key = (schema, name);
90+
91+
// If it's the first dimension, it defines the primary hypertable settings
92+
if (dimensionNumber == 1)
93+
{
94+
long chunkInterval = reader.IsDBNull(5) ? DefaultValues.ChunkTimeIntervalLong : (long)reader.GetDouble(5);
95+
bool compressionEnabled = compressionSettings.TryGetValue(key, out bool enabled) && enabled;
96+
97+
hypertables[key] = new HypertableInfo(
98+
TimeColumnName: columnName,
99+
ChunkTimeInterval: chunkInterval.ToString(),
100+
CompressionEnabled: compressionEnabled,
101+
ChunkSkipColumns: [],
102+
AdditionalDimensions: []
103+
);
104+
}
105+
// For all other dimensions, add them to the AdditionalDimensions list
106+
else
107+
{
108+
if (hypertables.TryGetValue(key, out HypertableInfo? info))
109+
{
110+
Dimension dimension;
111+
112+
if (!reader.IsDBNull(4) && reader.GetInt32(4) > 0)
113+
{
114+
// Space dimension
115+
dimension = Dimension.CreateHash(columnName, reader.GetInt32(4));
116+
}
117+
else if (!reader.IsDBNull(5))
118+
{
119+
// Time dimension
120+
long interval = (long)reader.GetDouble(5);
121+
dimension = Dimension.CreateRange(columnName, interval.ToString());
122+
}
123+
else continue;
124+
125+
info.AdditionalDimensions.Add(dimension);
126+
}
127+
}
128+
}
129+
}
130+
131+
private static void GetChunkSkipColumns(DbConnection connection, Dictionary<(string, string), HypertableInfo> hypertables)
132+
{
133+
using DbCommand command = connection.CreateCommand();
134+
command.CommandText = @"
135+
SELECT
136+
h.schema_name,
137+
h.table_name,
138+
ccs.column_name
139+
FROM _timescaledb_catalog.chunk_column_stats AS ccs
140+
JOIN _timescaledb_catalog.hypertable AS h ON ccs.hypertable_id = h.id;";
141+
142+
using DbDataReader reader = command.ExecuteReader();
143+
while (reader.Read())
144+
{
145+
string schema = reader.GetString(0);
146+
string name = reader.GetString(1);
147+
string columnName = reader.GetString(2);
148+
149+
if (hypertables.TryGetValue((schema, name), out HypertableInfo? info))
150+
{
151+
info.ChunkSkipColumns.Add(columnName);
152+
}
153+
}
154+
}
155+
}
156+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;
2+
3+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
4+
{
5+
/// <summary>
6+
/// Interface for applying TimescaleDB feature annotations to scaffolded database tables.
7+
/// </summary>
8+
internal interface IAnnotationApplier
9+
{
10+
/// <summary>
11+
/// Applies annotations to the database table based on the feature metadata.
12+
/// </summary>
13+
void ApplyAnnotations(DatabaseTable table, object featureInfo);
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Data.Common;
2+
3+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding
4+
{
5+
/// <summary>
6+
/// Interface for extracting TimescaleDB feature metadata from a database connection.
7+
/// </summary>
8+
internal interface ITimescaleFeatureExtractor
9+
{
10+
/// <summary>
11+
/// Extracts feature metadata from the database and returns a dictionary keyed by (schema, tableName).
12+
/// </summary>
13+
Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection);
14+
}
15+
}

0 commit comments

Comments
 (0)