From f538ace462f8e79911ab9af0676324b4e78e100e Mon Sep 17 00:00:00 2001 From: Collin Town Date: Wed, 28 Jan 2026 22:19:52 -0500 Subject: [PATCH 1/5] wip: compression with orderby and segmentby attributes --- .../HypertableAnnotationApplier.cs | 12 + .../HypertableScaffoldingExtractor.cs | 58 ++++ src/Eftdb/Abstractions/OrderBy.cs | 122 +++++++ .../Hypertable/HypertableAnnotations.cs | 2 + .../Hypertable/HypertableAttribute.cs | 19 ++ .../Hypertable/HypertableConvention.cs | 14 + .../Hypertable/HypertableTypeBuilder.cs | 56 ++++ .../HypertableOperationGenerator.cs | 77 ++++- .../Features/Hypertables/HypertableDiffer.cs | 19 +- .../Hypertables/HypertableModelExtractor.cs | 47 ++- .../Operations/AlterHypertableOperation.cs | 7 + .../Operations/CreateHypertableOperation.cs | 4 + .../Configuration/HypertableAttributeTests.cs | 37 ++ .../Conventions/HypertableConventionTests.cs | 134 ++++++++ .../Differs/HypertableDifferTests.cs | 317 ++++++++++++++++++ .../HypertableModelExtractorTests.cs | 218 ++++++++++++ ...bleOperationGeneratorComprehensiveTests.cs | 235 +++++++++++++ .../HypertableOperationGeneratorTests.cs | 149 ++++++++ .../Integration/HypertableIntegrationTests.cs | 208 ++++++++++++ .../HypertableScaffoldingExtractorTests.cs | 161 +++++++++ .../TimescaleDatabaseModelFactoryTests.cs | 176 ++++++++++ .../HypertableAnnotationApplierTests.cs | 151 +++++++++ .../HypertableTypeBuilderTests.cs | 187 +++++++++++ 23 files changed, 2398 insertions(+), 12 deletions(-) create mode 100644 src/Eftdb/Abstractions/OrderBy.cs 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..35112f8 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) { - bool enableCompression = operation.EnableCompression || operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0; - communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {enableCompression.ToString().ToLower()});"); + compressionSettings.Add("timescaledb.compress = true"); + } + + if (hasSegmentBy) + { + string segmentList = string.Join(", ", operation.CompressionSegmentBy!); + compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'"); + } + + if (hasOrderBy) + { + string orderList = string.Join(", ", 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,49 @@ 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)) + { + // If list is null/empty, set to '' to clear setting in DB + string val = (operation.CompressionSegmentBy?.Count > 0) + ? $"'{string.Join(", ", operation.CompressionSegmentBy)}'" + : "''"; + compressionSettings.Add($"timescaledb.compress_segmentby = {val}"); + } + + if (ListsChanged(operation.OldCompressionOrderBy, operation.CompressionOrderBy)) + { + string val = (operation.CompressionOrderBy?.Count > 0) + ? $"'{string.Join(", ", 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) 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..b0cfdd9 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -360,6 +360,107 @@ 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"] + }; + + // Expected: compress=true AND compress_segmentby='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); + // Must contain all settings in one statement + 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 +842,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..df50a8b 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 From 01ef041377cb51aebba735794e0a447c141d4d0b Mon Sep 17 00:00:00 2001 From: Collin Town Date: Thu, 29 Jan 2026 09:51:41 -0500 Subject: [PATCH 2/5] fix: properly quote compression queries --- .../HypertableOperationGenerator.cs | 35 ++++++++++++++++--- ...bleOperationGeneratorComprehensiveTests.cs | 16 ++++----- .../HypertableOperationGeneratorTests.cs | 6 ++-- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index 35112f8..2fe4d61 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -65,13 +65,13 @@ public List Generate(CreateHypertableOperation operation) if (hasSegmentBy) { - string segmentList = string.Join(", ", operation.CompressionSegmentBy!); + string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(QuoteIdentifier)); compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'"); } if (hasOrderBy) { - string orderList = string.Join(", ", operation.CompressionOrderBy!); + string orderList = QuoteOrderByList(operation.CompressionOrderBy!); compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'"); } @@ -176,9 +176,8 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy)) { - // If list is null/empty, set to '' to clear setting in DB string val = (operation.CompressionSegmentBy?.Count > 0) - ? $"'{string.Join(", ", operation.CompressionSegmentBy)}'" + ? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(QuoteIdentifier))}'" : "''"; compressionSettings.Add($"timescaledb.compress_segmentby = {val}"); } @@ -186,7 +185,7 @@ static bool ListsChanged(IReadOnlyList? oldList, IReadOnlyList? if (ListsChanged(operation.OldCompressionOrderBy, operation.CompressionOrderBy)) { string val = (operation.CompressionOrderBy?.Count > 0) - ? $"'{string.Join(", ", operation.CompressionOrderBy)}'" + ? $"'{QuoteOrderByList(operation.CompressionOrderBy)}'" : "''"; compressionSettings.Add($"timescaledb.compress_orderby = {val}"); } @@ -304,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/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs index b0cfdd9..a98e0d9 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -374,7 +374,6 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() CompressionSegmentBy = ["tenant_id", "device_id"] }; - // Expected: compress=true AND compress_segmentby='tenant_id, device_id' string expected = @".Sql(@"" SELECT create_hypertable('public.""""segmented_data""""', 'time'); DO $$ @@ -384,7 +383,7 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() 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'')'; + 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; @@ -419,7 +418,7 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() 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'')'; + 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; @@ -453,10 +452,9 @@ public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter() // Assert Assert.Contains("ALTER TABLE \"public\".\"full_compression\" SET", result); - // Must contain all settings in one statement Assert.Contains("timescaledb.compress = true", result); - Assert.Contains("timescaledb.compress_segmentby = ''tenant_id''", result); - Assert.Contains("timescaledb.compress_orderby = ''time DESC''", result); + Assert.Contains("timescaledb.compress_segmentby = ''\"tenant_id\"''", result); + Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); } #endregion @@ -864,7 +862,7 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() 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'')'; + 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; @@ -897,7 +895,7 @@ public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL() // 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); + Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result); } [Fact] @@ -970,7 +968,7 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() // 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_segmentby = ''\"new_col\"''", result); Assert.Contains("timescaledb.compress_orderby = ''''", result); } diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs index df50a8b..cf7ce89 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -187,7 +187,7 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre 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'')'; + 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; @@ -222,7 +222,7 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() 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'')'; + 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; @@ -257,7 +257,7 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() 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'')'; + 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; From 2e4d5aef75d9f6c1af34cf81c0c97625b1ae5d29 Mon Sep 17 00:00:00 2001 From: Collin Town Date: Thu, 29 Jan 2026 09:58:04 -0500 Subject: [PATCH 3/5] feat: use new compression settings in sample --- samples/Eftdb.Samples.Shared/Models/DeviceReading.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)] From 8e32b8d2c1e4e8c188c0a55f837c19d6f041fdeb Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Wed, 4 Feb 2026 16:47:56 +0100 Subject: [PATCH 4/5] fix: improve OrderBy consistency and add XML documentation Fix scaffolding round-trip mismatch by omitting default NULLS clauses to NULLS FIRST). Change OrderByExtensions default from bool to bool? with null default to prevent phantom migrations between API styles. Replace var with explicit types and triple-slash inline comments with double-slash per coding standards. --- .../HypertableScaffoldingExtractor.cs | 7 +-- src/Eftdb/Abstractions/OrderBy.cs | 43 ++++++++++++++++--- .../Hypertable/HypertableConvention.cs | 6 +-- .../Hypertable/HypertableTypeBuilder.cs | 6 +-- .../HypertableOperationGenerator.cs | 2 +- .../Hypertables/HypertableModelExtractor.cs | 6 +-- .../Integration/HypertableIntegrationTests.cs | 10 ++--- .../HypertableScaffoldingExtractorTests.cs | 8 ++-- 8 files changed, 60 insertions(+), 28 deletions(-) diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index 9bba416..2bde759 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -29,6 +29,7 @@ List AdditionalDimensions try { + // TODO: Evtl. CompessionSettings und CompressionConfiguration combinen? Dictionary<(string, string), HypertableInfo> hypertables = []; Dictionary<(string, string), bool> compressionSettings = GetCompressionSettings(connection); @@ -210,10 +211,10 @@ FROM timescaledb_information.compression_settings bool isNullsFirst = reader.GetBoolean(6); string direction = isAscending ? "ASC" : "DESC"; - string nulls = isNullsFirst ? "NULLS FIRST" : "NULLS LAST"; + bool isDefaultNulls = (isAscending && !isNullsFirst) || (!isAscending && isNullsFirst); + string nulls = isDefaultNulls ? "" : (isNullsFirst ? " NULLS FIRST" : " NULLS LAST"); - // Reconstruct the full string format: "colName DESC NULLS LAST" - info.CompressionOrderBy.Add($"{columnName} {direction} {nulls}"); + info.CompressionOrderBy.Add($"{columnName} {direction}{nulls}"); } } } diff --git a/src/Eftdb/Abstractions/OrderBy.cs b/src/Eftdb/Abstractions/OrderBy.cs index 079c2d6..5dd5932 100644 --- a/src/Eftdb/Abstractions/OrderBy.cs +++ b/src/Eftdb/Abstractions/OrderBy.cs @@ -1,5 +1,7 @@ using System.Linq.Expressions; +using System.Text; +// TODO: Evtl. in .Configuration.Hypertable statt .Abstractions verschieben? namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions { /// @@ -18,13 +20,21 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions /// public class OrderBy(string columnName, bool? isAscending = null, bool? nullsFirst = null) { + /// The name of the column to order by. public string ColumnName { get; } = columnName; + + /// Ordering direction. True for ASC, false for DESC, null for database default. public bool? IsAscending { get; } = isAscending; + + /// Null sorting behavior. True for NULLS FIRST, false for NULLS LAST, null for database default. public bool? NullsFirst { get; } = nullsFirst; + /// + /// Converts this ordering specification to a SQL clause fragment. + /// public string ToSql() { - var sb = new System.Text.StringBuilder(ColumnName); + StringBuilder sb = new(ColumnName); // Only append direction if explicitly set if (IsAscending.HasValue) @@ -47,6 +57,11 @@ public string ToSql() /// public static class OrderByBuilder { + /// + /// Starts building an OrderBy specification for the specified property. + /// + /// The entity type containing the property. + /// A lambda expression selecting the property to order by. public static OrderByConfiguration For(Expression> expression) => new(expression); } @@ -57,8 +72,16 @@ public class OrderByConfiguration(Expression> exp { private readonly string _propertyName = GetPropertyName(expression); + /// Creates an OrderBy using the database default direction. + /// Optional null sorting behavior. Null uses database default. public OrderBy Default(bool? nullsFirst = null) => new(_propertyName, null, nullsFirst); + + /// Creates an ascending OrderBy specification. + /// Optional null sorting behavior. Null uses database default. public OrderBy Ascending(bool? nullsFirst = null) => new(_propertyName, true, nullsFirst); + + /// Creates a descending OrderBy specification. + /// Optional null sorting behavior. Null uses database default. public OrderBy Descending(bool? nullsFirst = null) => new(_propertyName, false, nullsFirst); // Helper to extract the string name from the expression @@ -76,16 +99,24 @@ private static string GetPropertyName(Expression> expressi /// public class OrderBySelector { + /// Creates an OrderBy using the database default direction for the selected property. + /// A lambda expression selecting the property to order by. + /// Optional null sorting behavior. Null uses database default. public OrderBy By(Expression> expression, bool? nullsFirst = null) => new(GetPropertyName(expression), null, nullsFirst); + /// Creates an ascending OrderBy specification for the selected property. + /// A lambda expression selecting the property to order by. + /// Optional null sorting behavior. Null uses database default. public OrderBy ByAscending(Expression> expression, bool? nullsFirst = null) => new(GetPropertyName(expression), true, nullsFirst); + /// Creates a descending OrderBy specification for the selected property. + /// A lambda expression selecting the property to order by. + /// Optional null sorting behavior. Null uses database default. 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; @@ -103,8 +134,8 @@ 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) + /// Optional null sorting behavior. Null uses database default. + public static OrderBy Ascending(this string columnName, bool? nullsFirst = null) { return new OrderBy(columnName, true, nullsFirst); } @@ -113,8 +144,8 @@ public static OrderBy Ascending(this string columnName, bool nullsFirst = false) /// 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) + /// Optional null sorting behavior. Null uses database default. + public static OrderBy Descending(this string columnName, bool? nullsFirst = null) { return new OrderBy(columnName, false, nullsFirst); } diff --git a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs index 437185e..9c3b854 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableConvention.cs @@ -44,21 +44,21 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde if (attribute.ChunkSkipColumns != null && attribute.ChunkSkipColumns.Length > 0) { - /// Chunk skipping requires compression to be enabled + // Chunk skipping requires compression to be enabled 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 + // 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 + // 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 2fe918f..dc06f79 100644 --- a/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs +++ b/src/Eftdb/Configuration/Hypertable/HypertableTypeBuilder.cs @@ -178,10 +178,10 @@ public static EntityTypeBuilder WithCompressionOrderBy( this EntityTypeBuilder entityTypeBuilder, Func, IEnumerable> orderSelector) where TEntity : class { - var selector = new OrderBySelector(); - var rules = orderSelector(selector); + OrderBySelector selector = new(); + IEnumerable rules = orderSelector(selector); - return entityTypeBuilder.WithCompressionOrderBy(rules.ToArray()); + return entityTypeBuilder.WithCompressionOrderBy([.. rules]); } /// diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index 2fe4d61..44981d1 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -322,7 +322,7 @@ private static string QuoteOrderByList(IEnumerable orderByClauses) { return string.Join(", ", orderByClauses.Select(clause => { - var parts = clause.Split(' ', 2); + string[] parts = clause.Split(' ', 2); string col = parts[0]; string suffix = parts.Length > 1 ? " " + parts[1] : ""; diff --git a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs index 08518db..5847bc4 100644 --- a/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs +++ b/src/Eftdb/Internals/Features/Hypertables/HypertableModelExtractor.cs @@ -67,12 +67,12 @@ public static IEnumerable GetHypertables(IRelationalM if (!string.IsNullOrWhiteSpace(orderByString)) { compressionOrderBy = []; - var clauses = orderByString.Split(',', StringSplitOptions.TrimEntries); + string[] clauses = orderByString.Split(',', StringSplitOptions.TrimEntries); - foreach (var clause in clauses) + foreach (string clause in clauses) { // Split by the first space to separate PropertyName from Directions (ASC/DESC/NULLS) - var parts = clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + string[] parts = clause.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (parts.Length > 0) { string propName = parts[0]; diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index 29f5ab6..da88670 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -452,7 +452,7 @@ public async Task Should_Create_Hypertable_With_CompressionSegmentBy() List settings = await GetCompressionSettingsAsync(context, "segment_by_metrics"); - var tenantSetting = settings.FirstOrDefault(s => s.ColumnName == "TenantId"); + CompressionSettingInfo? tenantSetting = settings.FirstOrDefault(s => s.ColumnName == "TenantId"); Assert.NotNull(tenantSetting); Assert.Equal(1, tenantSetting.SegmentByIndex); @@ -503,12 +503,12 @@ public async Task Should_Create_Hypertable_With_CompressionOrderBy() List settings = await GetCompressionSettingsAsync(context, "order_by_metrics"); // Verify Timestamp (DESC) - var tsSetting = settings.First(s => s.ColumnName == "Timestamp"); + CompressionSettingInfo 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"); + CompressionSettingInfo valSetting = settings.First(s => s.ColumnName == "Value"); Assert.NotNull(valSetting.OrderByIndex); Assert.True(valSetting.IsAscending); // ASC (Default) Assert.True(valSetting.IsNullsFirst); // NULLS FIRST @@ -554,11 +554,11 @@ public async Task Should_Create_Hypertable_With_FullCompressionSettings() List settings = await GetCompressionSettingsAsync(context, "full_comp_metrics"); // DeviceId should be Segment #1 - var deviceSetting = settings.First(s => s.ColumnName == "DeviceId"); + CompressionSettingInfo 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"); + CompressionSettingInfo tsSetting = settings.First(s => s.ColumnName == "Timestamp"); Assert.Equal(1, tsSetting.OrderByIndex); Assert.False(tsSetting.IsAscending); } diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 07d8381..45c47a1 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -214,7 +214,7 @@ public async Task Should_Extract_CompressionSegmentBy() Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); Assert.Single(result); - var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; // Compression should be enabled Assert.True(info.CompressionEnabled); @@ -268,14 +268,14 @@ public async Task Should_Extract_CompressionOrderBy() Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); Assert.Single(result); - var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + HypertableScaffoldingExtractor.HypertableInfo 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]); + Assert.Equal("Timestamp DESC", 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]); } @@ -322,7 +322,7 @@ public async Task Should_Extract_Full_Compression_Configuration() Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); Assert.Single(result); - var info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; + HypertableScaffoldingExtractor.HypertableInfo info = (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "Metrics")]; // SegmentBy Assert.Single(info.CompressionSegmentBy); From 2a5d7230df57d106f369fb4c9b2a2ab9b791169b Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Wed, 4 Feb 2026 17:12:41 +0100 Subject: [PATCH 5/5] test: adds tests for OrderBy.cs to meet codecov criterias --- .../HypertableScaffoldingExtractor.cs | 1 - .../Hypertable}/OrderBy.cs | 3 +- .../Eftdb.Tests/Abstractions/OrderByTests.cs | 404 ++++++++++++++++++ 3 files changed, 405 insertions(+), 3 deletions(-) rename src/Eftdb/{Abstractions => Configuration/Hypertable}/OrderBy.cs (98%) create mode 100644 tests/Eftdb.Tests/Abstractions/OrderByTests.cs diff --git a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs index 2bde759..6a32b6b 100644 --- a/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Scaffolding/HypertableScaffoldingExtractor.cs @@ -29,7 +29,6 @@ List AdditionalDimensions try { - // TODO: Evtl. CompessionSettings und CompressionConfiguration combinen? Dictionary<(string, string), HypertableInfo> hypertables = []; Dictionary<(string, string), bool> compressionSettings = GetCompressionSettings(connection); diff --git a/src/Eftdb/Abstractions/OrderBy.cs b/src/Eftdb/Configuration/Hypertable/OrderBy.cs similarity index 98% rename from src/Eftdb/Abstractions/OrderBy.cs rename to src/Eftdb/Configuration/Hypertable/OrderBy.cs index 5dd5932..1f5f48d 100644 --- a/src/Eftdb/Abstractions/OrderBy.cs +++ b/src/Eftdb/Configuration/Hypertable/OrderBy.cs @@ -1,8 +1,7 @@ using System.Linq.Expressions; using System.Text; -// TODO: Evtl. in .Configuration.Hypertable statt .Abstractions verschieben? -namespace CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable { /// /// Represents an ordering specification for a column. diff --git a/tests/Eftdb.Tests/Abstractions/OrderByTests.cs b/tests/Eftdb.Tests/Abstractions/OrderByTests.cs new file mode 100644 index 0000000..d136852 --- /dev/null +++ b/tests/Eftdb.Tests/Abstractions/OrderByTests.cs @@ -0,0 +1,404 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Abstractions; + +/// +/// Tests that verify OrderBy classes, builders, and extension methods. +/// +public class OrderByTests +{ + #region Test Entity + + private class TestEntity + { + public DateTime Time { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Value { get; set; } + } + + #endregion + + #region OrderByExtensions Tests + + [Fact] + public void Ascending_Should_Create_OrderBy_With_IsAscending_True() + { + // Arrange + string columnName = "timestamp"; + + // Act + OrderBy result = columnName.Ascending(); + + // Assert + Assert.Equal("timestamp", result.ColumnName); + Assert.True(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void Ascending_With_NullsFirst_True_Should_Set_NullsFirst() + { + // Arrange + string columnName = "value"; + + // Act + OrderBy result = columnName.Ascending(nullsFirst: true); + + // Assert + Assert.Equal("value", result.ColumnName); + Assert.True(result.IsAscending); + Assert.True(result.NullsFirst); + } + + [Fact] + public void Ascending_With_NullsFirst_False_Should_Set_NullsFirst() + { + // Arrange + string columnName = "device_id"; + + // Act + OrderBy result = columnName.Ascending(nullsFirst: false); + + // Assert + Assert.Equal("device_id", result.ColumnName); + Assert.True(result.IsAscending); + Assert.False(result.NullsFirst); + } + + [Fact] + public void Descending_Should_Create_OrderBy_With_IsAscending_False() + { + // Arrange + string columnName = "timestamp"; + + // Act + OrderBy result = columnName.Descending(); + + // Assert + Assert.Equal("timestamp", result.ColumnName); + Assert.False(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void Descending_With_NullsFirst_True_Should_Set_NullsFirst() + { + // Arrange + string columnName = "value"; + + // Act + OrderBy result = columnName.Descending(nullsFirst: true); + + // Assert + Assert.Equal("value", result.ColumnName); + Assert.False(result.IsAscending); + Assert.True(result.NullsFirst); + } + + [Fact] + public void Descending_With_NullsFirst_False_Should_Set_NullsFirst() + { + // Arrange + string columnName = "device_id"; + + // Act + OrderBy result = columnName.Descending(nullsFirst: false); + + // Assert + Assert.Equal("device_id", result.ColumnName); + Assert.False(result.IsAscending); + Assert.False(result.NullsFirst); + } + + #endregion + + #region OrderByConfiguration Tests + + [Fact] + public void OrderByConfiguration_Default_Should_Create_OrderBy_With_Null_IsAscending() + { + // Arrange + OrderByConfiguration config = OrderByBuilder.For(x => x.Time); + + // Act + OrderBy result = config.Default(); + + // Assert + Assert.Equal("Time", result.ColumnName); + Assert.Null(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderByConfiguration_Default_With_NullsFirst_True_Should_Set_NullsFirst() + { + // Arrange + OrderByConfiguration config = OrderByBuilder.For(x => x.Value); + + // Act + OrderBy result = config.Default(nullsFirst: true); + + // Assert + Assert.Equal("Value", result.ColumnName); + Assert.Null(result.IsAscending); + Assert.True(result.NullsFirst); + } + + [Fact] + public void OrderByConfiguration_Default_With_NullsFirst_False_Should_Set_NullsFirst() + { + // Arrange + OrderByConfiguration config = OrderByBuilder.For(x => x.DeviceId); + + // Act + OrderBy result = config.Default(nullsFirst: false); + + // Assert + Assert.Equal("DeviceId", result.ColumnName); + Assert.Null(result.IsAscending); + Assert.False(result.NullsFirst); + } + + [Fact] + public void OrderByConfiguration_Ascending_Should_Create_OrderBy_With_IsAscending_True() + { + // Arrange + OrderByConfiguration config = OrderByBuilder.For(x => x.Time); + + // Act + OrderBy result = config.Ascending(); + + // Assert + Assert.Equal("Time", result.ColumnName); + Assert.True(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderByConfiguration_Descending_Should_Create_OrderBy_With_IsAscending_False() + { + // Arrange + OrderByConfiguration config = OrderByBuilder.For(x => x.Value); + + // Act + OrderBy result = config.Descending(); + + // Assert + Assert.Equal("Value", result.ColumnName); + Assert.False(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderByConfiguration_With_Invalid_Expression_Should_Throw_ArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => + OrderByBuilder.For(x => x.Time.ToString())); + + Assert.Contains("Invalid expression", ex.Message); + } + + #endregion + + #region OrderBySelector Tests + + [Fact] + public void OrderBySelector_By_Should_Create_OrderBy_With_Null_IsAscending() + { + // Arrange + OrderBySelector selector = new(); + + // Act + OrderBy result = selector.By(x => x.Time); + + // Assert + Assert.Equal("Time", result.ColumnName); + Assert.Null(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderBySelector_By_With_NullsFirst_True_Should_Set_NullsFirst() + { + // Arrange + OrderBySelector selector = new(); + + // Act + OrderBy result = selector.By(x => x.DeviceId, nullsFirst: true); + + // Assert + Assert.Equal("DeviceId", result.ColumnName); + Assert.Null(result.IsAscending); + Assert.True(result.NullsFirst); + } + + [Fact] + public void OrderBySelector_ByAscending_Should_Create_OrderBy_With_IsAscending_True() + { + // Arrange + OrderBySelector selector = new(); + + // Act + OrderBy result = selector.ByAscending(x => x.Value); + + // Assert + Assert.Equal("Value", result.ColumnName); + Assert.True(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderBySelector_ByDescending_Should_Create_OrderBy_With_IsAscending_False() + { + // Arrange + OrderBySelector selector = new(); + + // Act + OrderBy result = selector.ByDescending(x => x.Time); + + // Assert + Assert.Equal("Time", result.ColumnName); + Assert.False(result.IsAscending); + Assert.Null(result.NullsFirst); + } + + [Fact] + public void OrderBySelector_With_Invalid_Expression_Should_Throw_ArgumentException() + { + // Arrange + OrderBySelector selector = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => + selector.By(x => x.Time.ToString())); + + Assert.Contains("Expression must be a property access", ex.Message); + } + + #endregion + + #region OrderBy.ToSql() Tests - All 9 Combinations + + [Theory] + [InlineData(null, null, "timestamp")] + [InlineData(null, true, "timestamp NULLS FIRST")] + [InlineData(null, false, "timestamp NULLS LAST")] + [InlineData(true, null, "timestamp ASC")] + [InlineData(true, true, "timestamp ASC NULLS FIRST")] + [InlineData(true, false, "timestamp ASC NULLS LAST")] + [InlineData(false, null, "timestamp DESC")] + [InlineData(false, true, "timestamp DESC NULLS FIRST")] + [InlineData(false, false, "timestamp DESC NULLS LAST")] + public void ToSql_Should_Generate_Correct_SQL_For_All_Combinations( + bool? isAscending, + bool? nullsFirst, + string expectedSql) + { + // Arrange + OrderBy orderBy = new("timestamp", isAscending, nullsFirst); + + // Act + string sql = orderBy.ToSql(); + + // Assert + Assert.Equal(expectedSql, sql); + } + + [Fact] + public void ToSql_With_Complex_Column_Name_Should_Preserve_It() + { + // Arrange + OrderBy orderBy = new("\"my_schema\".\"my_table\".\"my_column\"", true, false); + + // Act + string sql = orderBy.ToSql(); + + // Assert + Assert.Equal("\"my_schema\".\"my_table\".\"my_column\" ASC NULLS LAST", sql); + } + + #endregion + + #region OrderByBuilder Tests + + [Fact] + public void OrderByBuilder_For_Should_Create_OrderByConfiguration() + { + // Arrange & Act + OrderByConfiguration config = OrderByBuilder.For(x => x.Time); + OrderBy result = config.Ascending(); + + // Assert + Assert.Equal("Time", result.ColumnName); + Assert.True(result.IsAscending); + } + + [Fact] + public void OrderByBuilder_For_Should_Handle_ValueType_Property() + { + // Arrange & Act + OrderByConfiguration config = OrderByBuilder.For(x => x.Value); + OrderBy result = config.Descending(nullsFirst: true); + + // Assert + Assert.Equal("Value", result.ColumnName); + Assert.False(result.IsAscending); + Assert.True(result.NullsFirst); + } + + [Fact] + public void OrderByBuilder_For_Should_Handle_ReferenceType_Property() + { + // Arrange & Act + OrderByConfiguration config = OrderByBuilder.For(x => x.DeviceId); + OrderBy result = config.Ascending(nullsFirst: false); + + // Assert + Assert.Equal("DeviceId", result.ColumnName); + Assert.True(result.IsAscending); + Assert.False(result.NullsFirst); + } + + #endregion + + #region OrderBy Constructor Tests + + [Fact] + public void OrderBy_Constructor_Should_Set_All_Properties() + { + // Arrange & Act + OrderBy orderBy = new("my_column", true, false); + + // Assert + Assert.Equal("my_column", orderBy.ColumnName); + Assert.True(orderBy.IsAscending); + Assert.False(orderBy.NullsFirst); + } + + [Fact] + public void OrderBy_Constructor_With_Nulls_Should_Set_Null_Properties() + { + // Arrange & Act + OrderBy orderBy = new("another_column", null, null); + + // Assert + Assert.Equal("another_column", orderBy.ColumnName); + Assert.Null(orderBy.IsAscending); + Assert.Null(orderBy.NullsFirst); + } + + [Fact] + public void OrderBy_Constructor_With_Only_ColumnName_Should_Use_Defaults() + { + // Arrange & Act + OrderBy orderBy = new("simple_column"); + + // Assert + Assert.Equal("simple_column", orderBy.ColumnName); + Assert.Null(orderBy.IsAscending); + Assert.Null(orderBy.NullsFirst); + } + + #endregion +}