Skip to content
Open
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
2 changes: 1 addition & 1 deletion samples/Eftdb.Samples.Shared/Models/DeviceReading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Example.DataAccess.Models
{
[Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "1 day", EnableCompression = true)]
[Hypertable(nameof(Time), ChunkSkipColumns = new[] { "Time" }, ChunkTimeInterval = "1 day", EnableCompression = true, CompressionSegmentBy = new[] { "DeviceId" }, CompressionOrderBy = new[] { "Time DESC" })]
[Index(nameof(Time), Name = "ix_device_readings_time")]
[PrimaryKey(nameof(Id), nameof(Time))]
[ReorderPolicy("ix_device_readings_time", InitialStart = "2025-09-23T09:15:19.3905112Z", ScheduleInterval = "1 day", MaxRuntime = "00:00:00", RetryPeriod = "00:05:00", MaxRetries = 3)]
Expand Down
12 changes: 12 additions & 0 deletions src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ public void ApplyAnnotations(DatabaseTable table, object featureInfo)
table[HypertableAnnotations.ChunkSkipColumns] = string.Join(",", info.ChunkSkipColumns);
}

// Apply SegmentBy annotation if present
if (info.CompressionSegmentBy.Count > 0)
{
table[HypertableAnnotations.CompressionSegmentBy] = string.Join(", ", info.CompressionSegmentBy);
}

// Apply OrderBy annotation if present
if (info.CompressionOrderBy.Count > 0)
{
table[HypertableAnnotations.CompressionOrderBy] = string.Join(", ", info.CompressionOrderBy);
}

if (info.AdditionalDimensions.Count > 0)
{
table[HypertableAnnotations.AdditionalDimensions] = JsonSerializer.Serialize(info.AdditionalDimensions);
Expand Down
58 changes: 58 additions & 0 deletions src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public sealed record HypertableInfo(
string TimeColumnName,
string ChunkTimeInterval,
bool CompressionEnabled,
List<string> CompressionSegmentBy,
List<string> CompressionOrderBy,
List<string> ChunkSkipColumns,
List<Dimension> AdditionalDimensions
);
Expand All @@ -32,6 +34,7 @@ List<Dimension> AdditionalDimensions

GetHypertableSettings(connection, hypertables, compressionSettings);
GetChunkSkipColumns(connection, hypertables);
GetCompressionConfiguration(connection, hypertables);

// Convert to object dictionary to match interface
return hypertables.ToDictionary(
Expand Down Expand Up @@ -99,6 +102,8 @@ FROM timescaledb_information.dimensions
TimeColumnName: columnName,
ChunkTimeInterval: chunkInterval.ToString(),
CompressionEnabled: compressionEnabled,
CompressionSegmentBy: [],
CompressionOrderBy: [],
ChunkSkipColumns: [],
AdditionalDimensions: []
);
Expand Down Expand Up @@ -159,5 +164,58 @@ FROM _timescaledb_catalog.chunk_column_stats AS ccs
}
}
}

private static void GetCompressionConfiguration(DbConnection connection, Dictionary<(string, string), HypertableInfo> hypertables)
{
using DbCommand command = connection.CreateCommand();

// This view provides the column-level details for compression.
// segmentby_column_index is not null for segment columns.
// orderby_column_index is not null for order columns.
command.CommandText = @"
SELECT
hypertable_schema,
hypertable_name,
attname,
segmentby_column_index,
orderby_column_index,
orderby_asc,
orderby_nullsfirst
FROM timescaledb_information.compression_settings
ORDER BY hypertable_schema, hypertable_name, segmentby_column_index, orderby_column_index;";

using DbDataReader reader = command.ExecuteReader();
while (reader.Read())
{
string schema = reader.GetString(0);
string name = reader.GetString(1);
string columnName = reader.GetString(2);

// Find the corresponding hypertable info
if (!hypertables.TryGetValue((schema, name), out HypertableInfo? info))
{
continue;
}

// Handle SegmentBy
if (!reader.IsDBNull(3)) // segmentby_column_index
{
info.CompressionSegmentBy.Add(columnName);
}

// Handle OrderBy
if (!reader.IsDBNull(4)) // orderby_column_index
{
bool isAscending = reader.GetBoolean(5);
bool isNullsFirst = reader.GetBoolean(6);

string direction = isAscending ? "ASC" : "DESC";
string nulls = isNullsFirst ? "NULLS FIRST" : "NULLS LAST";

// Reconstruct the full string format: "colName DESC NULLS LAST"
info.CompressionOrderBy.Add($"{columnName} {direction} {nulls}");
}
}
}
}
}
122 changes: 122 additions & 0 deletions src/Eftdb/Abstractions/OrderBy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.Linq.Expressions;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions
{
/// <summary>
/// Represents an ordering specification for a column.
/// </summary>
/// <param name="columnName">The name of the column to order by.</param>
/// <param name="isAscending">
/// If true, orders Ascending (ASC).
/// If false, orders Descending (DESC).
/// If null, uses database default (ASC).
/// </param>
/// <param name="nullsFirst">
/// If true, forces NULLS FIRST.
/// If false, forces NULLS LAST.
/// If null, uses database default (NULLS LAST for ASC, NULLS FIRST for DESC).
/// </param>
public class OrderBy(string columnName, bool? isAscending = null, bool? nullsFirst = null)
{
public string ColumnName { get; } = columnName;
public bool? IsAscending { get; } = isAscending;
public bool? NullsFirst { get; } = nullsFirst;

public string ToSql()
{
var sb = new System.Text.StringBuilder(ColumnName);

// Only append direction if explicitly set
if (IsAscending.HasValue)
{
sb.Append(IsAscending.Value ? " ASC" : " DESC");
}

// Only append NULLS clause if explicitly set
if (NullsFirst.HasValue)
{
sb.Append(NullsFirst.Value ? " NULLS FIRST" : " NULLS LAST");
}

return sb.ToString();
}
}

/// <summary>
/// Fluent builder for creating OrderBy instances.
/// </summary>
public static class OrderByBuilder
{
public static OrderByConfiguration<TEntity> For<TEntity>(Expression<Func<TEntity, object>> expression) => new(expression);
}

/// <summary>
/// Fluent configuration for creating OrderBy instances.
/// </summary>
public class OrderByConfiguration<TEntity>(Expression<Func<TEntity, object>> expression)
{
private readonly string _propertyName = GetPropertyName(expression);

public OrderBy Default(bool? nullsFirst = null) => new(_propertyName, null, nullsFirst);
public OrderBy Ascending(bool? nullsFirst = null) => new(_propertyName, true, nullsFirst);
public OrderBy Descending(bool? nullsFirst = null) => new(_propertyName, false, nullsFirst);

// Helper to extract the string name from the expression
private static string GetPropertyName(Expression<Func<TEntity, object>> expression)
{
if (expression.Body is MemberExpression member) return member.Member.Name;
if (expression.Body is UnaryExpression unary && unary.Operand is MemberExpression m) return m.Member.Name;
throw new ArgumentException("Invalid expression. Please use a simple property access expression.");
}
}

/// <summary>
/// Fluent builder for creating OrderBy instances using lambda expressions.
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class OrderBySelector<TEntity>
{
public OrderBy By(Expression<Func<TEntity, object>> expression, bool? nullsFirst = null)
=> new(GetPropertyName(expression), null, nullsFirst);

public OrderBy ByAscending(Expression<Func<TEntity, object>> expression, bool? nullsFirst = null)
=> new(GetPropertyName(expression), true, nullsFirst);

public OrderBy ByDescending(Expression<Func<TEntity, object>> expression, bool? nullsFirst = null)
=> new(GetPropertyName(expression), false, nullsFirst);

// Internal helper to get property names from expressions
private static string GetPropertyName(Expression<Func<TEntity, object>> expression)
{
if (expression.Body is MemberExpression m) return m.Member.Name;
if (expression.Body is UnaryExpression u && u.Operand is MemberExpression m2) return m2.Member.Name;
throw new ArgumentException("Expression must be a property access.");
}
}

/// <summary>
/// Extension methods for creating OrderBy instances.
/// </summary>
public static class OrderByExtensions
{
/// <summary>
/// Creates an ascending OrderBy instance.
/// </summary>
/// <param name="columnName">The name of the column to order by.</param>
/// <param name="nullsFirst">Whether nulls should appear first.</param>
public static OrderBy Ascending(this string columnName, bool nullsFirst = false)
{
return new OrderBy(columnName, true, nullsFirst);
}

/// <summary>
/// Creates a descending OrderBy instance.
/// </summary>
/// <param name="columnName">The name of the column to order by.</param>
/// <param name="nullsFirst">Whether nulls should appear first.</param>
public static OrderBy Descending(this string columnName, bool nullsFirst = false)
{
return new OrderBy(columnName, false, nullsFirst);
}
}
}
2 changes: 2 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 CompressionSegmentBy = "TimescaleDB:CompressionSegmentBy";
public const string CompressionOrderBy = "TimescaleDB:CompressionOrderBy";
public const string MigrateData = "TimescaleDB:MigrateData";
public const string ChunkTimeInterval = "TimescaleDB:ChunkTimeInterval";
public const string ChunkSkipColumns = "TimescaleDB:ChunkSkipColumns";
Expand Down
19 changes: 19 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ public sealed class HypertableAttribute : Attribute
/// </summary>
public bool EnableCompression { get; set; } = false;

/// <summary>
/// Specifies the columns to group by when compressing the hypertable.
/// Maps to <c>timescaledb.compress_segmentby</c>.
/// </summary>
/// <example>
/// <code>[Hypertable("time", CompressionSegmentBy = ["device_id", "tenant_id"])]</code>
/// </example>
public string[]? CompressionSegmentBy { get; set; } = null;

/// <summary>
/// Specifies the columns to order by within each compressed segment.
/// Maps to <c>timescaledb.compress_orderby</c>.
/// Since attributes cannot use Expressions, you must specify the full SQL syntax if direction is needed.
/// </summary>
/// <example>
/// <code>[Hypertable("time", CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"])]</code>
/// </example>
public string[]? CompressionOrderBy { get; set; } = null;

/// <summary>
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
/// </summary>
Expand Down
14 changes: 14 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);
entityTypeBuilder.HasAnnotation(HypertableAnnotations.ChunkSkipColumns, string.Join(",", attribute.ChunkSkipColumns));
}

if (attribute.CompressionSegmentBy != null && attribute.CompressionSegmentBy.Length > 0)
{
/// SegmentBy requires compression to be enabled
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);
entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, string.Join(", ", attribute.CompressionSegmentBy));
}

if (attribute.CompressionOrderBy != null && attribute.CompressionOrderBy.Length > 0)
{
/// OrderBy requires compression to be enabled
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);
entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, string.Join(", ", attribute.CompressionOrderBy));
}
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,62 @@ public static EntityTypeBuilder<TEntity> EnableCompression<TEntity>(
return entityTypeBuilder;
}

/// <summary>
/// Specifies the columns to group by when compressing the hypertable (SegmentBy).
/// </summary>
/// <remarks>
/// Valid settings for <c>timescaledb.compress_segmentby</c>.
/// Columns used for segmenting are not compressed themselves but are used as keys to group rows.
/// Good candidates are columns with low cardinality (e.g., "device_id", "tenant_id").
/// </remarks>
public static EntityTypeBuilder<TEntity> WithCompressionSegmentBy<TEntity>(
this EntityTypeBuilder<TEntity> entityTypeBuilder,
params Expression<Func<TEntity, object>>[] segmentByColumns) where TEntity : class
{
string[] columnNames = [.. segmentByColumns.Select(GetPropertyName)];

entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, string.Join(", ", columnNames));
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);

return entityTypeBuilder;
}

/// <summary>
/// Specifies the columns to order by within each compressed segment using explicit OrderBy definitions.
/// </summary>
/// <remarks>
/// Uses the <see cref="OrderByBuilder"/> to define direction and null handling.
/// Example: <c>.WithCompressionOrderBy(OrderByBuilder.For&lt;T&gt;(x => x.Time).Descending())</c>
/// </remarks>
public static EntityTypeBuilder<TEntity> WithCompressionOrderBy<TEntity>(
this EntityTypeBuilder<TEntity> entityTypeBuilder,
params OrderBy[] orderByRules) where TEntity : class
{
string annotationValue = string.Join(", ", orderByRules.Select(r => r.ToSql()));

entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionOrderBy, annotationValue);
entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true);

return entityTypeBuilder;
}

/// <summary>
/// Specifies the columns to order by within each compressed segment using the OrderBySelector.
/// </summary>
/// <remarks>
/// Provides a simplified syntax for defining order.
/// Example: <c>.WithCompressionOrderBy(s => [s.ByDescending(x => x.Time), s.By(x => x.Value)])</c>
/// </remarks>
public static EntityTypeBuilder<TEntity> WithCompressionOrderBy<TEntity>(
this EntityTypeBuilder<TEntity> entityTypeBuilder,
Func<OrderBySelector<TEntity>, IEnumerable<OrderBy>> orderSelector) where TEntity : class
{
var selector = new OrderBySelector<TEntity>();
var rules = orderSelector(selector);

return entityTypeBuilder.WithCompressionOrderBy(rules.ToArray());
}

/// <summary>
/// Specifies whether existing data should be migrated when converting a table to a hypertable.
/// </summary>
Expand Down
Loading