diff --git a/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs b/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs index 5b6affb..c42059b 100644 --- a/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs +++ b/samples/Eftdb.Samples.Shared/Models/DeviceReading.cs @@ -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)] diff --git a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs index 29c5029..7e24550 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableAnnotationApplier.cs @@ -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); diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index adba391..9bba416 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -13,6 +13,8 @@ public sealed record HypertableInfo( string TimeColumnName, string ChunkTimeInterval, bool CompressionEnabled, + List CompressionSegmentBy, + List CompressionOrderBy, List ChunkSkipColumns, List AdditionalDimensions ); @@ -32,6 +34,7 @@ List AdditionalDimensions GetHypertableSettings(connection, hypertables, compressionSettings); GetChunkSkipColumns(connection, hypertables); + GetCompressionConfiguration(connection, hypertables); // Convert to object dictionary to match interface return hypertables.ToDictionary( @@ -99,6 +102,8 @@ FROM timescaledb_information.dimensions TimeColumnName: columnName, ChunkTimeInterval: chunkInterval.ToString(), CompressionEnabled: compressionEnabled, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -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}"); + } + } + } } } diff --git a/src/Eftdb/Abstractions/OrderBy.cs b/src/Eftdb/Abstractions/OrderBy.cs new file mode 100644 index 0000000..079c2d6 --- /dev/null +++ b/src/Eftdb/Abstractions/OrderBy.cs @@ -0,0 +1,122 @@ +using System.Linq.Expressions; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions +{ + /// + /// Represents an ordering specification for a column. + /// + /// The name of the column to order by. + /// + /// If true, orders Ascending (ASC). + /// If false, orders Descending (DESC). + /// If null, uses database default (ASC). + /// + /// + /// If true, forces NULLS FIRST. + /// If false, forces NULLS LAST. + /// If null, uses database default (NULLS LAST for ASC, NULLS FIRST for DESC). + /// + 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(); + } + } + + /// + /// Fluent builder for creating OrderBy instances. + /// + public static class OrderByBuilder + { + public static OrderByConfiguration For(Expression> expression) => new(expression); + } + + /// + /// Fluent configuration for creating OrderBy instances. + /// + public class OrderByConfiguration(Expression> 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> 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."); + } + } + + /// + /// Fluent builder for creating OrderBy instances using lambda expressions. + /// + /// + public class OrderBySelector + { + public OrderBy By(Expression> expression, bool? nullsFirst = null) + => new(GetPropertyName(expression), null, nullsFirst); + + public OrderBy ByAscending(Expression> expression, bool? nullsFirst = null) + => new(GetPropertyName(expression), true, nullsFirst); + + public OrderBy ByDescending(Expression> expression, bool? nullsFirst = null) + => new(GetPropertyName(expression), false, nullsFirst); + + // Internal helper to get property names from expressions + private static string GetPropertyName(Expression> 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."); + } + } + + /// + /// Extension methods for creating OrderBy instances. + /// + public static class OrderByExtensions + { + /// + /// Creates an ascending OrderBy instance. + /// + /// The name of the column to order by. + /// Whether nulls should appear first. + public static OrderBy Ascending(this string columnName, bool nullsFirst = false) + { + return new OrderBy(columnName, true, nullsFirst); + } + + /// + /// Creates a descending OrderBy instance. + /// + /// The name of the column to order by. + /// Whether nulls should appear first. + public static OrderBy Descending(this string columnName, bool nullsFirst = false) + { + return new OrderBy(columnName, false, nullsFirst); + } + } +} \ No newline at end of file diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs index de65d3e..8d36503 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAnnotations.cs @@ -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"; diff --git a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs index 1d49e3e..bcbc1f4 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableAttribute.cs @@ -14,6 +14,25 @@ public sealed class HypertableAttribute : Attribute /// public bool EnableCompression { get; set; } = false; + /// + /// Specifies the columns to group by when compressing the hypertable. + /// Maps to timescaledb.compress_segmentby. + /// + /// + /// [Hypertable("time", CompressionSegmentBy = ["device_id", "tenant_id"])] + /// + public string[]? CompressionSegmentBy { get; set; } = null; + + /// + /// Specifies the columns to order by within each compressed segment. + /// Maps to timescaledb.compress_orderby. + /// Since attributes cannot use Expressions, you must specify the full SQL syntax if direction is needed. + /// + /// + /// [Hypertable("time", CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"])] + /// + public string[]? CompressionOrderBy { get; set; } = null; + /// /// Specifies whether existing data should be migrated when converting a table to a hypertable. /// diff --git a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs index aa670c5..437185e 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs @@ -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)); + } } } } diff --git a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs index 9f6d781..2fe918f 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs @@ -128,6 +128,62 @@ public static EntityTypeBuilder EnableCompression( return entityTypeBuilder; } + /// + /// Specifies the columns to group by when compressing the hypertable (SegmentBy). + /// + /// + /// Valid settings for timescaledb.compress_segmentby. + /// 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"). + /// + public static EntityTypeBuilder WithCompressionSegmentBy( + this EntityTypeBuilder entityTypeBuilder, + params Expression>[] segmentByColumns) where TEntity : class + { + string[] columnNames = [.. segmentByColumns.Select(GetPropertyName)]; + + entityTypeBuilder.HasAnnotation(HypertableAnnotations.CompressionSegmentBy, string.Join(", ", columnNames)); + entityTypeBuilder.HasAnnotation(HypertableAnnotations.EnableCompression, true); + + return entityTypeBuilder; + } + + /// + /// Specifies the columns to order by within each compressed segment using explicit OrderBy definitions. + /// + /// + /// Uses the to define direction and null handling. + /// Example: .WithCompressionOrderBy(OrderByBuilder.For<T>(x => x.Time).Descending()) + /// + public static EntityTypeBuilder WithCompressionOrderBy( + this EntityTypeBuilder 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; + } + + /// + /// Specifies the columns to order by within each compressed segment using the OrderBySelector. + /// + /// + /// Provides a simplified syntax for defining order. + /// Example: .WithCompressionOrderBy(s => [s.ByDescending(x => x.Time), s.By(x => x.Value)]) + /// + public static EntityTypeBuilder WithCompressionOrderBy( + this EntityTypeBuilder entityTypeBuilder, + Func, IEnumerable> orderSelector) where TEntity : class + { + var selector = new OrderBySelector(); + var rules = orderSelector(selector); + + return entityTypeBuilder.WithCompressionOrderBy(rules.ToArray()); + } + /// /// Specifies whether existing data should be migrated when converting a table to a hypertable. /// diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index 4a39817..2fe4d61 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -50,11 +50,35 @@ public List Generate(CreateHypertableOperation operation) createHypertableCall.Append(");"); statements.Add(createHypertableCall.ToString()); - // EnableCompression (Community Edition only) - if (operation.EnableCompression || operation.ChunkSkipColumns?.Count > 0) + List compressionSettings = []; + + bool hasSegmentBy = operation.CompressionSegmentBy != null && operation.CompressionSegmentBy.Count > 0; + bool hasOrderBy = operation.CompressionOrderBy != null && operation.CompressionOrderBy.Count > 0; + bool hasChunkSkipping = operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0; + + bool shouldEnableCompression = operation.EnableCompression || hasChunkSkipping || hasSegmentBy || hasOrderBy; + + if (shouldEnableCompression) + { + compressionSettings.Add("timescaledb.compress = true"); + } + + if (hasSegmentBy) { - bool enableCompression = operation.EnableCompression || operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0; - communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {enableCompression.ToString().ToLower()});"); + string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(QuoteIdentifier)); + compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'"); + } + + if (hasOrderBy) + { + string orderList = QuoteOrderByList(operation.CompressionOrderBy!); + compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'"); + } + + // If there are compression settings, add the ALTER TABLE SET (...) statement + if (compressionSettings.Count > 0) + { + communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"); } // ChunkSkipColumns (Community Edition only) @@ -128,14 +152,48 @@ public List Generate(AlterHypertableOperation operation) statements.Add(setChunkTimeInterval.ToString()); } - // Check for EnableCompression change (Community Edition only) - bool newCompressionState = operation.EnableCompression || operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Any(); - bool oldCompressionState = operation.OldEnableCompression || operation.OldChunkSkipColumns != null && operation.OldChunkSkipColumns.Any(); + List compressionSettings = []; + + static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? newList) + { + return !(oldList ?? []).SequenceEqual(newList ?? []); + } + + bool newCompressionState = operation.EnableCompression + || (operation.ChunkSkipColumns?.Count > 0) + || (operation.CompressionSegmentBy?.Count > 0) + || (operation.CompressionOrderBy?.Count > 0); + + bool oldCompressionState = operation.OldEnableCompression + || (operation.OldChunkSkipColumns?.Count > 0) + || (operation.OldCompressionSegmentBy?.Count > 0) + || (operation.OldCompressionOrderBy?.Count > 0); if (newCompressionState != oldCompressionState) { - string compressionValue = newCompressionState.ToString().ToLower(); - communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {compressionValue});"); + compressionSettings.Add($"timescaledb.compress = {newCompressionState.ToString().ToLower()}"); + } + + if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy)) + { + string val = (operation.CompressionSegmentBy?.Count > 0) + ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(QuoteIdentifier))}'" + : "''"; + compressionSettings.Add($"timescaledb.compress_segmentby = {val}"); + } + + if (ListsChanged(operation.OldCompressionOrderBy, operation.CompressionOrderBy)) + { + string val = (operation.CompressionOrderBy?.Count > 0) + ? $"'{QuoteOrderByList(operation.CompressionOrderBy)}'" + : "''"; + compressionSettings.Add($"timescaledb.compress_orderby = {val}"); + } + + // If there are compression settings, add the ALTER TABLE SET (...) statement + if (compressionSettings.Count > 0) + { + communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"); } // Handle ChunkSkipColumns (Community Edition only) @@ -245,5 +303,31 @@ private static string WrapCommunityFeatures(List sqlStatements) return sb.ToString(); } + + /// + /// Wraps an identifier in double quotes to preserve case-sensitivity in Postgres. + /// Escapes existing double quotes. + /// Example: TenantId -> "TenantId" + /// + private static string QuoteIdentifier(string identifier) + { + return $"\"{identifier.Replace("\"", "\"\"")}\""; + } + + /// + /// Quotes the column name within an ORDER BY clause while preserving direction/nulls. + /// Example: Timestamp DESC -> "Timestamp" DESC + /// + private static string QuoteOrderByList(IEnumerable orderByClauses) + { + return string.Join(", ", orderByClauses.Select(clause => + { + var parts = clause.Split(' ', 2); + string col = parts[0]; + string suffix = parts.Length > 1 ? " " + parts[1] : ""; + + return QuoteIdentifier(col) + suffix; + })); + } } } \ No newline at end of file diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs index e95c073..f657c7d 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableDiffer.cs @@ -30,7 +30,9 @@ public IReadOnlyList GetDifferences(IRelationalModel? source x.Target.ChunkTimeInterval != x.Source.ChunkTimeInterval || x.Target.EnableCompression != x.Source.EnableCompression || !AreChunkSkipColumnsEqual(x.Target.ChunkSkipColumns, x.Source.ChunkSkipColumns) || - !AreDimensionsEqual(x.Target.AdditionalDimensions, x.Source.AdditionalDimensions) + !AreDimensionsEqual(x.Target.AdditionalDimensions, x.Source.AdditionalDimensions) || + !AreStringListsEqual(x.Target.CompressionSegmentBy, x.Source.CompressionSegmentBy) || + !AreStringListsEqual(x.Target.CompressionOrderBy, x.Source.CompressionOrderBy) ); foreach (var hypertable in updatedHypertables) @@ -39,14 +41,22 @@ public IReadOnlyList GetDifferences(IRelationalModel? source { TableName = hypertable.Target.TableName, Schema = hypertable.Target.Schema, + + // Current values ChunkTimeInterval = hypertable.Target.ChunkTimeInterval, EnableCompression = hypertable.Target.EnableCompression, ChunkSkipColumns = hypertable.Target.ChunkSkipColumns, AdditionalDimensions = hypertable.Target.AdditionalDimensions, + CompressionSegmentBy = hypertable.Target.CompressionSegmentBy, + CompressionOrderBy = hypertable.Target.CompressionOrderBy, + + // Old values OldChunkTimeInterval = hypertable.Source.ChunkTimeInterval, OldEnableCompression = hypertable.Source.EnableCompression, OldChunkSkipColumns = hypertable.Source.ChunkSkipColumns, - OldAdditionalDimensions = hypertable.Source.AdditionalDimensions + OldAdditionalDimensions = hypertable.Source.AdditionalDimensions, + OldCompressionSegmentBy = hypertable.Source.CompressionSegmentBy, + OldCompressionOrderBy = hypertable.Source.CompressionOrderBy }); } @@ -55,6 +65,11 @@ public IReadOnlyList GetDifferences(IRelationalModel? source return operations; } + private static bool AreStringListsEqual(IReadOnlyList? list1, IReadOnlyList? list2) + { + return (list1 ?? []).SequenceEqual(list2 ?? []); + } + private static bool AreChunkSkipColumnsEqual(IReadOnlyList? list1, IReadOnlyList? list2) { if (list1 == null && list2 == null) return true; diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs index a1eaa15..08518db 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs @@ -52,6 +52,40 @@ public static IEnumerable GetHypertables(IRelationalM .ToList()!; } + string? segmentByString = entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value as string; + List? compressionSegmentBy = null; + if (!string.IsNullOrWhiteSpace(segmentByString)) + { + compressionSegmentBy = segmentByString.Split(',', StringSplitOptions.TrimEntries) + .Select(propName => ResolveColumnName(entityType, storeIdentifier, propName)) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList()!; + } + + string? orderByString = entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value as string; + List? compressionOrderBy = null; + if (!string.IsNullOrWhiteSpace(orderByString)) + { + compressionOrderBy = []; + var clauses = orderByString.Split(',', StringSplitOptions.TrimEntries); + + foreach (var clause in clauses) + { + // Split by the first space to separate PropertyName from Directions (ASC/DESC/NULLS) + var parts = clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) + { + string propName = parts[0]; + string suffix = parts.Length > 1 ? " " + parts[1] : ""; + + string columnName = ResolveColumnName(entityType, storeIdentifier, propName); + if (!string.IsNullOrEmpty(columnName)) + { + compressionOrderBy.Add(columnName + suffix); + } + } + } + } List? additionalDimensions = null; IAnnotation? additionalDimensionsAnnotations = entityType.FindAnnotation(HypertableAnnotations.AdditionalDimensions); @@ -87,9 +121,20 @@ public static IEnumerable GetHypertables(IRelationalM EnableCompression = enableCompression, MigrateData = migrateData, ChunkSkipColumns = chunkSkipColumns, - AdditionalDimensions = additionalDimensions + AdditionalDimensions = additionalDimensions, + CompressionSegmentBy = compressionSegmentBy, + CompressionOrderBy = compressionOrderBy }; } } + + /// + /// Resolves a C# property name to a Database column name. + /// If the property is not found (e.g., user provided a raw column name via Attribute), returns the input string. + /// + private static string ResolveColumnName(IEntityType entityType, StoreObjectIdentifier storeIdentifier, string propertyName) + { + return entityType.FindProperty(propertyName)?.GetColumnName(storeIdentifier) ?? propertyName; + } } } \ No newline at end of file diff --git a/src/Eftdb/Operations/AlterHypertableOperation.cs b/src/Eftdb/Operations/AlterHypertableOperation.cs index 57fecb5..98296ef 100644 --- a/src/Eftdb/Operations/AlterHypertableOperation.cs +++ b/src/Eftdb/Operations/AlterHypertableOperation.cs @@ -18,7 +18,14 @@ public class AlterHypertableOperation : MigrationOperation public string OldChunkTimeInterval { get; set; } = string.Empty; public bool OldEnableCompression { get; set; } + public IReadOnlyList? OldChunkSkipColumns { get; set; } public IReadOnlyList? OldAdditionalDimensions { get; set; } + + public IReadOnlyList? CompressionSegmentBy { get; set; } + public IReadOnlyList? OldCompressionSegmentBy { get; set; } + + public IReadOnlyList? CompressionOrderBy { get; set; } + public IReadOnlyList? OldCompressionOrderBy { get; set; } } } diff --git a/src/Eftdb/Operations/CreateHypertableOperation.cs b/src/Eftdb/Operations/CreateHypertableOperation.cs index 95fb84d..b8ac733 100644 --- a/src/Eftdb/Operations/CreateHypertableOperation.cs +++ b/src/Eftdb/Operations/CreateHypertableOperation.cs @@ -11,7 +11,11 @@ public class CreateHypertableOperation : MigrationOperation public string ChunkTimeInterval { get; set; } = string.Empty; public bool EnableCompression { get; set; } public bool MigrateData { get; set; } = false; + public IReadOnlyList? ChunkSkipColumns { get; set; } public IReadOnlyList? AdditionalDimensions { get; set; } + + public IReadOnlyList? CompressionSegmentBy { get; set; } + public IReadOnlyList? CompressionOrderBy { get; set; } } } diff --git a/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs b/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs index f079b23..379078c 100644 --- a/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs +++ b/tests/Eftdb.Tests/Configuration/HypertableAttributeTests.cs @@ -84,6 +84,8 @@ public void Constructor_With_Valid_TimeColumnName_SetsDefaultValues() Assert.False(attr.EnableCompression); Assert.Equal(DefaultValues.ChunkTimeInterval, attr.ChunkTimeInterval); Assert.Null(attr.ChunkSkipColumns); + Assert.Null(attr.CompressionSegmentBy); + Assert.Null(attr.CompressionOrderBy); } [Fact] @@ -149,6 +151,7 @@ public void ChunkSkipColumns_CanBeSetToArray() }; // Assert + Assert.NotNull(attr.ChunkSkipColumns); Assert.Equal(2, attr.ChunkSkipColumns.Length); Assert.Contains("Value", attr.ChunkSkipColumns); Assert.Contains("DeviceId", attr.ChunkSkipColumns); @@ -169,6 +172,40 @@ public void ChunkSkipColumns_CanBeSetToEmptyArray() Assert.Empty(attr.ChunkSkipColumns); } + [Fact] + public void CompressionSegmentBy_CanBeSetToArray() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + CompressionSegmentBy = ["tenant_id", "device_id"] + }; + + // Assert + Assert.NotNull(attr.CompressionSegmentBy); + Assert.Equal(2, attr.CompressionSegmentBy.Length); + Assert.Contains("tenant_id", attr.CompressionSegmentBy); + Assert.Contains("device_id", attr.CompressionSegmentBy); + } + + [Fact] + public void CompressionOrderBy_CanBeSetToArray() + { + // Arrange + HypertableAttribute attr = new("Timestamp") + { + // Act + CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"] + }; + + // Assert + Assert.NotNull(attr.CompressionOrderBy); + Assert.Equal(2, attr.CompressionOrderBy.Length); + Assert.Equal("time DESC", attr.CompressionOrderBy[0]); + Assert.Equal("value ASC NULLS LAST", attr.CompressionOrderBy[1]); + } + [Fact] public void MigrateData_DefaultsToFalse() { diff --git a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs index 85010c3..e10025c 100644 --- a/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs +++ b/tests/Eftdb.Tests/Conventions/HypertableConventionTests.cs @@ -137,6 +137,140 @@ public void Should_Process_Hypertable_With_Compression_Enabled() #endregion + #region Should_Process_Hypertable_With_CompressionSegmentBy + + [Hypertable("Timestamp", CompressionSegmentBy = ["TenantId", "DeviceId"])] + private class SegmentByEntity + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class SegmentByContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("SegmentBy"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_CompressionSegmentBy() + { + using SegmentByContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SegmentByEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + // Should implicitly enable compression + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + // Should join array with comma space + Assert.Equal("TenantId, DeviceId", entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value); + } + + #endregion + + #region Should_Process_Hypertable_With_CompressionOrderBy + + [Hypertable("Timestamp", CompressionOrderBy = ["Timestamp DESC", "Value ASC NULLS LAST"])] + private class OrderByEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderByContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("OrderBy"); + }); + } + } + + [Fact] + public void Should_Process_Hypertable_With_CompressionOrderBy() + { + using OrderByContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(OrderByEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + // Should implicitly enable compression + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + // Should preserve raw SQL strings joined by comma space + Assert.Equal("Timestamp DESC, Value ASC NULLS LAST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); + } + + #endregion + + #region Should_Not_Apply_Compression_Settings_When_Arrays_Empty + + [Hypertable("Timestamp", CompressionSegmentBy = [], CompressionOrderBy = [])] + private class EmptyCompressionSettingsEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class EmptyCompressionSettingsContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("EmptyCompressionSettings"); + }); + } + } + + [Fact] + public void Should_Not_Apply_Compression_Settings_When_Arrays_Empty() + { + using EmptyCompressionSettingsContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(EmptyCompressionSettingsEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + + // Should NOT enable compression because arrays are empty + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.EnableCompression)); + + // Should NOT set the segment/order annotations + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)); + Assert.Null(entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)); + } + + #endregion + #region Should_Process_Hypertable_With_ChunkSkipColumns [Hypertable("Timestamp", ChunkSkipColumns = ["Value", "DeviceId"])] diff --git a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs index c2a3f88..e58c041 100644 --- a/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs +++ b/tests/Eftdb.Tests/Differs/HypertableDifferTests.cs @@ -1267,6 +1267,323 @@ public void Should_Detect_Compression_Disabled() #endregion + #region Should_Detect_CompressionSegmentBy_Added + + private class MetricEntity19 + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext19 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class SegmentByContext19 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId); + }); + } + } + + [Fact] + public void Should_Detect_CompressionSegmentBy_Added() + { + using BasicHypertableContext19 sourceContext = new(); + using SegmentByContext19 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + + // Ensure SegmentBy was detected + Assert.Null(alterOp.OldCompressionSegmentBy); + Assert.NotNull(alterOp.CompressionSegmentBy); + Assert.Single(alterOp.CompressionSegmentBy); + Assert.Equal("TenantId", alterOp.CompressionSegmentBy[0]); + + // Ensure Compression was implicitly enabled + Assert.True(alterOp.EnableCompression); + } + + #endregion + + #region Should_Detect_Change_When_SegmentBy_Order_Different + + // Unlike ChunkSkipColumns, SegmentBy order matters for physical storage layout. + // This test ensures the differ detects a change even if the set of columns is the same. + + private class MetricEntity20 + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class SegmentByOrderAContext20 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId, x => x.DeviceId); + }); + } + } + + private class SegmentByOrderBContext20 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId, x => x.TenantId); + }); + } + } + + [Fact] + public void Should_Detect_Change_When_SegmentBy_Order_Different() + { + using SegmentByOrderAContext20 sourceContext = new(); + using SegmentByOrderBContext20 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + + // Assert: A diff SHOULD be generated because SequenceEqual checks order + Assert.NotNull(alterOp); + Assert.Equal("TenantId", alterOp.OldCompressionSegmentBy![0]); + Assert.Equal("DeviceId", alterOp.OldCompressionSegmentBy![1]); + + Assert.Equal("DeviceId", alterOp.CompressionSegmentBy![0]); + Assert.Equal("TenantId", alterOp.CompressionSegmentBy![1]); + } + + #endregion + + #region Should_Detect_CompressionOrderBy_Added + + private class MetricEntity21 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BasicHypertableContext21 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class OrderByContext21 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value) + ]); + }); + } + } + + [Fact] + public void Should_Detect_CompressionOrderBy_Added() + { + using BasicHypertableContext21 sourceContext = new(); + using OrderByContext21 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + + Assert.Null(alterOp.OldCompressionOrderBy); + Assert.NotNull(alterOp.CompressionOrderBy); + Assert.Equal(2, alterOp.CompressionOrderBy.Count); + + Assert.Equal("Timestamp DESC", alterOp.CompressionOrderBy[0]); + Assert.Equal("Value", alterOp.CompressionOrderBy[1]); + + Assert.True(alterOp.EnableCompression); + } + + #endregion + + #region Should_Detect_CompressionSettings_Removed + + private class MetricEntity22 + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class FullCompressionContext22 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(b => [ + b.ByDescending(x => x.Timestamp) + ]); + }); + } + } + + private class BasicHypertableContext22 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_CompressionSettings_Removed() + { + using FullCompressionContext22 sourceContext = new(); + using BasicHypertableContext22 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + HypertableDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterHypertableOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + + // Verify Old Values are present + Assert.NotNull(alterOp.OldCompressionSegmentBy); + Assert.NotNull(alterOp.OldCompressionOrderBy); + Assert.NotEmpty(alterOp.OldCompressionSegmentBy); + Assert.NotEmpty(alterOp.OldCompressionOrderBy); + + Assert.Equal("TenantId", alterOp.OldCompressionSegmentBy[0]); + Assert.Equal("Timestamp DESC", alterOp.OldCompressionOrderBy[0]); + + Assert.Null(alterOp.CompressionSegmentBy); + Assert.Null(alterOp.CompressionOrderBy); + + Assert.False(alterOp.EnableCompression); + } + + #endregion + #region Should_Detect_ChunkSkipColumns_Removed private class MetricEntity18 diff --git a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs index 3c7e5ea..e2503fe 100644 --- a/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs +++ b/tests/Eftdb.Tests/Extractors/HypertableModelExtractorTests.cs @@ -324,6 +324,224 @@ public void Should_Extract_EnableCompression_False_By_Default() #endregion + #region Should_Extract_CompressionSegmentBy + + private class SegmentByMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class SegmentByContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId); + }); + } + } + + [Fact] + public void Should_Extract_CompressionSegmentBy() + { + using SegmentByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + + Assert.NotNull(operation.CompressionSegmentBy); + Assert.Single(operation.CompressionSegmentBy); + Assert.Equal("TenantId", operation.CompressionSegmentBy[0]); + // Compression should be enabled implicitly + Assert.True(operation.EnableCompression); + } + + #endregion + + #region Should_Extract_CompressionOrderBy + + private class OrderByMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderByContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value, nullsFirst: true) + ]); + }); + } + } + + [Fact] + public void Should_Extract_CompressionOrderBy() + { + using OrderByContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + + Assert.NotNull(operation.CompressionOrderBy); + Assert.Equal(2, operation.CompressionOrderBy.Count); + + // Verify formatted strings + Assert.Equal("Timestamp DESC", operation.CompressionOrderBy[0]); + Assert.Equal("Value NULLS FIRST", operation.CompressionOrderBy[1]); + + Assert.True(operation.EnableCompression); + } + + #endregion + + #region Should_Resolve_Compression_Columns_With_Naming_Convention + + private class SnakeCaseCompressionMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double SensorValue { get; set; } + } + + private class SnakeCaseCompressionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseSnakeCaseNamingConvention() + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.SensorValue) + ]); + }); + } + } + + [Fact] + public void Should_Resolve_Compression_Columns_With_Naming_Convention() + { + using SnakeCaseCompressionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + + // Verify SegmentBy (TenantId -> tenant_id) + Assert.NotNull(operation.CompressionSegmentBy); + Assert.Equal("tenant_id", operation.CompressionSegmentBy[0]); + + // Verify OrderBy (SensorValue -> sensor_value) + // This confirms the complex parsing logic in Extractor works (Split -> Resolve -> Rebuild) + Assert.NotNull(operation.CompressionOrderBy); + Assert.Equal("sensor_value DESC", operation.CompressionOrderBy[0]); + } + + #endregion + + #region Should_Resolve_Compression_Columns_With_Explicit_Names + + private class ExplicitCompressionMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double SensorValue { get; set; } + } + + private class ExplicitCompressionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + + // Explicitly map properties to different column names + entity.Property(x => x.TenantId).HasColumnName("tid"); + entity.Property(x => x.SensorValue).HasColumnName("val"); + + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.SensorValue) + ]); + }); + } + } + + [Fact] + public void Should_Resolve_Compression_Columns_With_Explicit_Names() + { + using ExplicitCompressionContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. HypertableModelExtractor.GetHypertables(relationalModel)]; + + Assert.Single(operations); + CreateHypertableOperation operation = operations[0]; + + // Verify SegmentBy used explicit name "tid" + Assert.NotNull(operation.CompressionSegmentBy); + Assert.Equal("tid", operation.CompressionSegmentBy[0]); + + // Verify OrderBy used explicit name "val" + Assert.NotNull(operation.CompressionOrderBy); + Assert.Equal("val DESC", operation.CompressionOrderBy[0]); + } + + #endregion + #region Should_Extract_Single_ChunkSkipColumn private class SingleChunkSkipMetric diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs index b1d4040..a98e0d9 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -360,6 +360,105 @@ public void DesignTime_Create_WithRangeDimension_IntegerInterval_GeneratesCorrec #endregion + #region CreateHypertableOperation - Compression Settings Tests + + [Fact] + public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "segmented_data", + Schema = "public", + TimeColumnName = "time", + CompressionSegmentBy = ["tenant_id", "device_id"] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""segmented_data""""', 'time'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""segmented_data"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""tenant_id"", ""device_id""'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "ordered_data", + Schema = "public", + TimeColumnName = "time", + CompressionOrderBy = ["time DESC", "value ASC NULLS LAST"] + }; + + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""ordered_data""""', 'time'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""ordered_data"""" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "full_compression", + Schema = "public", + TimeColumnName = "time", + // Explicit enable + segment + order + EnableCompression = true, + CompressionSegmentBy = ["tenant_id"], + CompressionOrderBy = ["time DESC"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("ALTER TABLE \"public\".\"full_compression\" SET", result); + Assert.Contains("timescaledb.compress = true", result); + Assert.Contains("timescaledb.compress_segmentby = ''\"tenant_id\"''", result); + Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); + } + + #endregion + #region AlterHypertableOperation - Design Time Tests [Fact] @@ -741,6 +840,140 @@ public void DesignTime_Alter_AddingRangeDimension_WithIntegerInterval_GeneratesC #endregion + #region AlterHypertableOperation - Compression Settings Tests + + [Fact] + public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + CompressionSegmentBy = ["device_id"], + OldCompressionSegmentBy = [] + }; + + string expected = @".Sql(@"" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""device_id""'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetDesignTimeCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + // Changing from ASC to DESC + CompressionOrderBy = ["time DESC"], + OldCompressionOrderBy = ["time ASC"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + // Note: EnableCompression=true is NOT generated if it hasn't changed state (implicit false->false or true->true) + // But we do expect the update to the specific setting. + Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); + } + + [Fact] + public void Runtime_Alter_RemovingCompressionSegmentBy_GeneratesEmptyStringSetting() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + // Removing the setting + CompressionSegmentBy = [], + OldCompressionSegmentBy = ["device_id"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + // TimescaleDB requires setting the value to an empty string '' to unset it + Assert.Contains("timescaledb.compress_segmentby = ''", result); + } + + [Fact] + public void Runtime_Alter_RemovingCompressionOrderBy_GeneratesEmptyStringSetting() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + // Removing the setting + CompressionOrderBy = null, + OldCompressionOrderBy = ["time DESC"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + Assert.Contains("timescaledb.compress_orderby = ''", result); + } + + [Fact] + public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + + // Enable compression (was off) + // Since SegmentBy was set, compression was turned on implicitly before; now explicitly enabling it + EnableCompression = true, + OldEnableCompression = false, + + // Change SegmentBy + CompressionSegmentBy = ["new_col"], + OldCompressionSegmentBy = ["old_col"], + + // Remove OrderBy + CompressionOrderBy = [], + OldCompressionOrderBy = ["time DESC"] + }; + + // Act + string result = GetRuntimeSql(operation); + + // Assert + // Should be a single ALTER TABLE statement with 3 settings + Assert.Contains("ALTER TABLE \"public\".\"metrics\" SET", result); + Assert.Contains("timescaledb.compress_segmentby = ''\"new_col\"''", result); + Assert.Contains("timescaledb.compress_orderby = ''''", result); + } + + #endregion + #region TimescaleDB Constraint Validation Tests [Fact] diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs index 8c98604..cf7ce89 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -163,6 +163,155 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); } + [Fact] + public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Correct_Sql() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "CompressedTable", + Schema = "public", + TimeColumnName = "Timestamp", + EnableCompression = true, + CompressionSegmentBy = ["TenantId", "DeviceId"], + CompressionOrderBy = ["Timestamp DESC", "Value ASC NULLS LAST"] + }; + + // Expected: implicit compress=true, plus segmentby/orderby strings + string expected = @".Sql(@"" + SELECT create_hypertable('public.""""CompressedTable""""', 'Timestamp'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""CompressedTable"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.compress_orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "Metrics", + Schema = "public", + // Adding segment by configuration + CompressionSegmentBy = ["DeviceId"], + OldCompressionSegmentBy = [] + }; + + string expected = @".Sql(@"" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""DeviceId""'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "Metrics", + Schema = "public", + // Changing from ASC to DESC + CompressionOrderBy = ["Timestamp DESC"], + OldCompressionOrderBy = ["Timestamp ASC"] + }; + + string expected = @".Sql(@"" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_orderby = ''""Timestamp"" DESC'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + [Fact] + public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_Strings() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "Metrics", + Schema = "public", + EnableCompression = true, + OldEnableCompression = true, + + // Removing both settings + CompressionSegmentBy = [], + OldCompressionSegmentBy = ["DeviceId"], + CompressionOrderBy = null, + OldCompressionOrderBy = ["Timestamp DESC"] + }; + + // TimescaleDB requires setting the value to '' (empty string) to clear it + string expected = @".Sql(@"" + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_segmentby = '''', timescaledb.compress_orderby = '''')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + [Fact] public void Generate_Alter_when_adding_and_removing_skip_columns_generates_correct_sql() { diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index 43bf7fe..29f5ab6 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -11,6 +11,15 @@ public class HypertableIntegrationTests : MigrationTestBase, IAsyncLifetime private PostgreSqlContainer? _container; private string? _connectionString; + private class CompressionSettingInfo + { + public string ColumnName { get; set; } = string.Empty; + public int? SegmentByIndex { get; set; } + public int? OrderByIndex { get; set; } + public bool IsAscending { get; set; } + public bool IsNullsFirst { get; set; } + } + public async Task InitializeAsync() { _container = new PostgreSqlBuilder() @@ -120,6 +129,53 @@ FROM timescaledb_information.hypertables return result is bool boolResult && boolResult; } + private static async Task> GetCompressionSettingsAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + // This view contains the exact details of how compression is configured per column + command.CommandText = @" + SELECT + attname, + segmentby_column_index, + orderby_column_index, + orderby_asc, + orderby_nullsfirst + FROM timescaledb_information.compression_settings + WHERE hypertable_name = @tableName + ORDER BY segmentby_column_index, orderby_column_index; + "; + command.Parameters.AddWithValue("tableName", tableName); + + List settings = []; + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + settings.Add(new CompressionSettingInfo + { + ColumnName = reader.GetString(0), + SegmentByIndex = reader.IsDBNull(1) ? null : reader.GetInt32(1), + OrderByIndex = reader.IsDBNull(2) ? null : reader.GetInt32(2), + IsAscending = !reader.IsDBNull(3) && reader.GetBoolean(3), + IsNullsFirst = !reader.IsDBNull(4) && reader.GetBoolean(4) + }); + } + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return settings; + } + private static async Task> GetChunkSkipColumnsAsync(DbContext context, string tableName) { NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); @@ -357,6 +413,158 @@ public async Task Should_Create_Hypertable_With_Compression_Enabled() #endregion + #region Should_Create_Hypertable_With_CompressionSegmentBy + + private class SegmentByMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class SegmentByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("segment_by_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_CompressionSegmentBy() + { + await using SegmentByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool isCompressed = await IsCompressionEnabledAsync(context, "segment_by_metrics"); + Assert.True(isCompressed, "Compression should be implicitly enabled by SegmentBy"); + + List settings = await GetCompressionSettingsAsync(context, "segment_by_metrics"); + + var tenantSetting = settings.FirstOrDefault(s => s.ColumnName == "TenantId"); + Assert.NotNull(tenantSetting); + + Assert.Equal(1, tenantSetting.SegmentByIndex); + Assert.Null(tenantSetting.OrderByIndex); + } + + #endregion + + #region Should_Create_Hypertable_With_CompressionOrderBy + + private class OrderByMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("order_by_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value, nullsFirst: true) + ]); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_CompressionOrderBy() + { + await using OrderByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool isCompressed = await IsCompressionEnabledAsync(context, "order_by_metrics"); + Assert.True(isCompressed); + + List settings = await GetCompressionSettingsAsync(context, "order_by_metrics"); + + // Verify Timestamp (DESC) + var tsSetting = settings.First(s => s.ColumnName == "Timestamp"); + Assert.NotNull(tsSetting.OrderByIndex); // Should be ordered + Assert.False(tsSetting.IsAscending); // DESC + + // Verify Value (ASC, NULLS FIRST) + var valSetting = settings.First(s => s.ColumnName == "Value"); + Assert.NotNull(valSetting.OrderByIndex); + Assert.True(valSetting.IsAscending); // ASC (Default) + Assert.True(valSetting.IsNullsFirst); // NULLS FIRST + } + + #endregion + + #region Should_Create_Hypertable_With_FullCompressionSettings + + private class FullCompressionMetric + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class FullCompressionContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("full_comp_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_FullCompressionSettings() + { + await using FullCompressionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List settings = await GetCompressionSettingsAsync(context, "full_comp_metrics"); + + // DeviceId should be Segment #1 + var deviceSetting = settings.First(s => s.ColumnName == "DeviceId"); + Assert.Equal(1, deviceSetting.SegmentByIndex); + + // Timestamp should be Order #1 (DESC) + var tsSetting = settings.First(s => s.ColumnName == "Timestamp"); + Assert.Equal(1, tsSetting.OrderByIndex); + Assert.False(tsSetting.IsAscending); + } + + #endregion + #region Should_Create_Hypertable_With_ChunkSkipping private class ChunkSkippingData diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index a3d2180..07d8381 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -175,6 +175,167 @@ public async Task Should_Extract_Hypertable_With_Compression_Enabled() #endregion + #region Should_Extract_CompressionSegmentBy + + private class ScaffoldingSegmentByMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class ScaffoldingSegmentByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionSegmentBy() + { + await using ScaffoldingSegmentByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + + // Compression should be enabled + Assert.True(info.CompressionEnabled); + + // SegmentBy list should contain "TenantId" + Assert.Single(info.CompressionSegmentBy); + Assert.Equal("TenantId", info.CompressionSegmentBy[0]); + } + + #endregion + + #region Should_Extract_CompressionOrderBy + + private class ScaffoldingOrderByMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldingOrderByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + // "Timestamp DESC", "Value ASC NULLS FIRST" + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value, nullsFirst: true) + ]); + }); + } + } + + [Fact] + public async Task Should_Extract_CompressionOrderBy() + { + await using ScaffoldingOrderByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + + Assert.True(info.CompressionEnabled); + + Assert.Equal(2, info.CompressionOrderBy.Count); + + // Extractor reconstructs the string: "ColumnName [ASC|DESC] [NULLS FIRST|LAST]" + Assert.Equal("Timestamp DESC NULLS FIRST", info.CompressionOrderBy[0]); + // Note: Default for ASC is usually NULLS LAST in Postgres, but if we set NULLS FIRST explicitly: + Assert.Equal("Value ASC NULLS FIRST", info.CompressionOrderBy[1]); + } + + #endregion + + #region Should_Extract_Full_Compression_Configuration + + private class ScaffoldingFullCompressionMetric + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Temperature { get; set; } + } + + private class ScaffoldingFullCompressionContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]); + }); + } + } + + [Fact] + public async Task Should_Extract_Full_Compression_Configuration() + { + await using ScaffoldingFullCompressionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + + // SegmentBy + Assert.Single(info.CompressionSegmentBy); + Assert.Equal("DeviceId", info.CompressionSegmentBy[0]); + + // OrderBy + Assert.Single(info.CompressionOrderBy); + // Postgres default for DESC is NULLS FIRST, extractor logic appends what it reads + Assert.Contains("Timestamp DESC", info.CompressionOrderBy[0]); + } + + #endregion + #region Should_Extract_ChunkSkipColumns private class ChunkSkippingMetric diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index 8b337db..f2fec7d 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -189,6 +189,182 @@ public async Task Should_Scaffold_Hypertable_With_Compression() #endregion + #region Should_Scaffold_Hypertable_With_Compression_SegmentBy + + private class ScaffoldSegmentByMetric + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public double Value { get; set; } + } + + private class ScaffoldSegmentByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Compression_SegmentBy() + { + await using ScaffoldSegmentByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + // Verify Hypertable + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + + // Compression should be implicitly enabled + Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); + + // Verify SegmentBy Annotation + // The annotation value should be the column name "TenantId" + string? segmentBy = metricsTable[HypertableAnnotations.CompressionSegmentBy] as string; + Assert.NotNull(segmentBy); + Assert.Equal("TenantId", segmentBy); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Compression_OrderBy + + private class ScaffoldOrderByMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScaffoldOrderByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.By(x => x.Value, nullsFirst: true) + ]); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Compression_OrderBy() + { + await using ScaffoldOrderByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); + + // Verify OrderBy Annotation + // Expect comma-separated string of clauses + string? orderBy = metricsTable[HypertableAnnotations.CompressionOrderBy] as string; + Assert.NotNull(orderBy); + + // The extractor reconstructs strings like "Column [ASC|DESC] [NULLS FIRST|LAST]" + Assert.Contains("Timestamp DESC", orderBy); + Assert.Contains("Value ASC", orderBy); + Assert.Contains("NULLS FIRST", orderBy); + } + + #endregion + + #region Should_Scaffold_Hypertable_With_Full_Compression_Settings + + private class ScaffoldFullCompMetric + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class ScaffoldFullCompContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Hypertable_With_Full_Compression_Settings() + { + await using ScaffoldFullCompContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + TimescaleDatabaseModelFactory factory = CreateFactory(); + await using NpgsqlConnection connection = new(_connectionString); + + DatabaseModelFactoryOptions options = new(tables: ["Metrics"], schemas: []); + DatabaseModel model = factory.Create(connection, options); + + DatabaseTable? metricsTable = model.Tables.FirstOrDefault(t => t.Name == "Metrics"); + Assert.NotNull(metricsTable); + + // Verify all compression settings are present + Assert.Equal(true, metricsTable[HypertableAnnotations.IsHypertable]); + Assert.Equal(true, metricsTable[HypertableAnnotations.EnableCompression]); + + Assert.Equal("DeviceId", metricsTable[HypertableAnnotations.CompressionSegmentBy]); + + string? orderBy = metricsTable[HypertableAnnotations.CompressionOrderBy] as string; + Assert.NotNull(orderBy); + Assert.Contains("Timestamp DESC", orderBy); + } + + #endregion + #region Should_Scaffold_Hypertable_With_Hash_Dimension private class HashDimensionMetric diff --git a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs index 54d734a..51cd50b 100644 --- a/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs +++ b/tests/Eftdb.Tests/Scaffolding/HypertableAnnotationApplierTests.cs @@ -27,6 +27,8 @@ public void Should_Apply_Minimal_Hypertable_Annotations() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -58,6 +60,8 @@ public void Should_Apply_TimeColumn_Annotation() TimeColumnName: "created_at", ChunkTimeInterval: "86400000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -82,6 +86,8 @@ public void Should_Apply_ChunkTimeInterval_Annotation() TimeColumnName: "Timestamp", ChunkTimeInterval: "3600000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -106,6 +112,8 @@ public void Should_Apply_Compression_Enabled_True() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -130,6 +138,8 @@ public void Should_Apply_Compression_Enabled_False() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -143,6 +153,125 @@ public void Should_Apply_Compression_Enabled_False() #endregion + #region Should_Apply_CompressionSegmentBy + + [Fact] + public void Should_Apply_CompressionSegmentBy() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + CompressionSegmentBy: ["TenantId", "DeviceId"], // Set segment columns + CompressionOrderBy: [], + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.CompressionSegmentBy]); + + // Expect comma+space separated string + Assert.Equal("TenantId, DeviceId", table[HypertableAnnotations.CompressionSegmentBy]); + + // Ensure compression enabled is passed through + Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); + } + + #endregion + + #region Should_Apply_CompressionOrderBy + + [Fact] + public void Should_Apply_CompressionOrderBy() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: ["Timestamp DESC", "Value ASC NULLS LAST"], // Set order rules + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.NotNull(table[HypertableAnnotations.CompressionOrderBy]); + + // Expect comma+space separated string + Assert.Equal("Timestamp DESC, Value ASC NULLS LAST", table[HypertableAnnotations.CompressionOrderBy]); + } + + #endregion + + #region Should_Apply_Full_Compression_Configuration + + [Fact] + public void Should_Apply_Full_Compression_Configuration() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + CompressionSegmentBy: ["DeviceId"], + CompressionOrderBy: ["Timestamp DESC"], + ChunkSkipColumns: ["DeviceId"], // Chunk skipping often overlaps with segment by + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); + Assert.Equal("DeviceId", table[HypertableAnnotations.CompressionSegmentBy]); + Assert.Equal("Timestamp DESC", table[HypertableAnnotations.CompressionOrderBy]); + Assert.Equal("DeviceId", table[HypertableAnnotations.ChunkSkipColumns]); + } + + #endregion + + #region Should_Not_Apply_Compression_Annotations_When_Lists_Empty + + [Fact] + public void Should_Not_Apply_Compression_Annotations_When_Lists_Empty() + { + // Arrange + DatabaseTable table = CreateTable(); + HypertableInfo info = new( + TimeColumnName: "Timestamp", + ChunkTimeInterval: "604800000000", + CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: [], + ChunkSkipColumns: [], + AdditionalDimensions: [] + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + // Compression is enabled, but specific segment/order annotations should be null + Assert.Equal(true, table[HypertableAnnotations.EnableCompression]); + Assert.Null(table[HypertableAnnotations.CompressionSegmentBy]); + Assert.Null(table[HypertableAnnotations.CompressionOrderBy]); + } + + #endregion + #region Should_Apply_Single_ChunkSkipColumn [Fact] @@ -154,6 +283,8 @@ public void Should_Apply_Single_ChunkSkipColumn() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: ["DeviceId"], AdditionalDimensions: [] ); @@ -179,6 +310,8 @@ public void Should_Apply_Multiple_ChunkSkipColumns() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: ["DeviceId", "Location", "SensorType"], AdditionalDimensions: [] ); @@ -204,6 +337,8 @@ public void Should_Not_Apply_ChunkSkipColumns_When_Empty() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -229,6 +364,8 @@ public void Should_Apply_Single_Hash_Dimension() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [hashDimension] ); @@ -266,6 +403,8 @@ public void Should_Apply_Single_Range_Dimension() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [rangeDimension] ); @@ -304,6 +443,8 @@ public void Should_Apply_Multiple_Dimensions() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [hashDimension, rangeDimension] ); @@ -344,6 +485,8 @@ public void Should_Not_Apply_AdditionalDimensions_When_Empty() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -370,6 +513,8 @@ public void Should_Apply_All_Annotations_For_Fully_Configured_Hypertable() TimeColumnName: "recorded_at", ChunkTimeInterval: "86400000000", CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: ["device_id", "sensor_type", "region_code"], AdditionalDimensions: [hashDimension, rangeDimension] ); @@ -466,6 +611,8 @@ public void Should_Apply_IsHypertable_Always_True() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -494,6 +641,8 @@ public void Should_Preserve_Existing_Table_Properties() TimeColumnName: "Timestamp", ChunkTimeInterval: "604800000000", CompressionEnabled: true, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: [], AdditionalDimensions: [] ); @@ -524,6 +673,8 @@ public void Should_Handle_Special_Characters_In_Column_Names() TimeColumnName: "time_stamp_utc", ChunkTimeInterval: "604800000000", CompressionEnabled: false, + CompressionSegmentBy: [], + CompressionOrderBy: [], ChunkSkipColumns: ["device_id", "sensor_type_v2"], AdditionalDimensions: [] ); diff --git a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs index 6a54a85..1ce8224 100644 --- a/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs +++ b/tests/Eftdb.Tests/TypeBuilders/HypertableTypeBuilderTests.cs @@ -330,6 +330,193 @@ public void EnableCompression_Should_Support_Explicit_False() #endregion + #region WithCompressionSegmentBy_Should_Set_Annotation_And_Enable_Compression + + private class SegmentByEntity + { + public DateTime Timestamp { get; set; } + public int TenantId { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class SegmentByContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionSegmentBy(x => x.TenantId, x => x.DeviceId); + }); + } + } + + [Fact] + public void WithCompressionSegmentBy_Should_Set_Annotation_And_Enable_Compression() + { + using SegmentByContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(SegmentByEntity))!; + + // Verify Annotation Value (comma separated) + Assert.Equal("TenantId, DeviceId", entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value); + + // Verify Implicit Compression Enablement + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region WithCompressionOrderBy_Builder_Syntax_Should_Set_Annotation + + private class OrderByBuilderEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderByBuilderContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy( + OrderByBuilder.For(x => x.Timestamp).Descending(), + OrderByBuilder.For(x => x.Value).Ascending(nullsFirst: true) + ); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_Builder_Syntax_Should_Set_Annotation() + { + using OrderByBuilderContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(OrderByBuilderEntity))!; + + // Verify Annotation Value + Assert.Equal("Timestamp DESC, Value ASC NULLS FIRST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); + + // Verify Implicit Compression Enablement + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region WithCompressionOrderBy_Selector_Syntax_Should_Set_Annotation + + private class OrderBySelectorEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderBySelectorContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithCompressionOrderBy(s => [ + s.ByDescending(x => x.Timestamp), + s.ByAscending(x => x.Value, nullsFirst: true) + ]); + }); + } + } + + [Fact] + public void WithCompressionOrderBy_Selector_Syntax_Should_Set_Annotation() + { + using OrderBySelectorContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(OrderBySelectorEntity))!; + + // Verify Annotation Value matches the builder syntax result + Assert.Equal("Timestamp DESC, Value ASC NULLS FIRST", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + } + + #endregion + + #region Should_Support_Chaining_All_Compression_Methods + + private class FullCompressionEntity + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class FullCompressionContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("7 days") + .WithCompressionSegmentBy(x => x.DeviceId) + .WithCompressionOrderBy(s => [s.ByDescending(x => x.Timestamp)]) + .WithChunkSkipping(x => x.DeviceId); // Often same as segment by + }); + } + } + + [Fact] + public void Should_Support_Chaining_All_Compression_Methods() + { + using FullCompressionContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullCompressionEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.EnableCompression)?.Value); + + Assert.Equal("DeviceId", entityType.FindAnnotation(HypertableAnnotations.CompressionSegmentBy)?.Value); + Assert.Equal("Timestamp DESC", entityType.FindAnnotation(HypertableAnnotations.CompressionOrderBy)?.Value); + Assert.Equal("DeviceId", entityType.FindAnnotation(HypertableAnnotations.ChunkSkipColumns)?.Value); + } + + #endregion + #region WithChunkSkipping_Should_Set_ChunkSkipColumns_Annotation private class ChunkSkippingEntity