From c68e3ba5ef2f3e95d7640f2e627848072266cdde Mon Sep 17 00:00:00 2001 From: Anton Schieber Date: Tue, 18 Nov 2025 09:04:28 +1000 Subject: [PATCH 1/4] src: generator: hypertable: edition support Add support for the apache and community editions of timescaledb in the hypertable generator. --- .../HypertableOperationGenerator.cs | 99 ++++++++++++++----- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index d3004f0..0c65cb2 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using System.Text; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -16,7 +17,6 @@ public HypertableOperationGenerator(bool isDesignTime = false) } sqlHelper = new SqlBuilderHelper(quoteString); - } public List Generate(CreateHypertableOperation operation) @@ -24,48 +24,51 @@ public List Generate(CreateHypertableOperation operation) string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema); - string migrateDataParam = operation.MigrateData ? ", migrate_data => true" : ""; + List statements = []; + List communityStatements = []; - List statements = - [ - $"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'{migrateDataParam});" - ]; + // Build create_hypertable with chunk_time_interval if provided + var createHypertableCall = new StringBuilder(); + createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'"); + createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : ""); - // ChunkTimeInterval if (!string.IsNullOrEmpty(operation.ChunkTimeInterval)) { // Check if the interval is a plain number (e.g., for microseconds). if (long.TryParse(operation.ChunkTimeInterval, out _)) { // If it's a number, don't wrap it in quotes. - statements.Add($"SELECT set_chunk_time_interval({qualifiedTableName}, {operation.ChunkTimeInterval}::bigint);"); + createHypertableCall.Append($", chunk_time_interval => {operation.ChunkTimeInterval}::bigint"); } else { // If it's a string like '7 days', wrap it in quotes. - statements.Add($"SELECT set_chunk_time_interval({qualifiedTableName}, INTERVAL '{operation.ChunkTimeInterval}');"); + createHypertableCall.Append($", chunk_time_interval => INTERVAL '{operation.ChunkTimeInterval}'"); } } - // EnableCompression + createHypertableCall.Append(");"); + statements.Add(createHypertableCall.ToString()); + + // EnableCompression (Community Edition only) if (operation.EnableCompression || operation.ChunkSkipColumns?.Count > 0) { bool enableCompression = operation.EnableCompression || operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0; - statements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {enableCompression.ToString().ToLower()});"); + communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {enableCompression.ToString().ToLower()});"); } - // ChunkSkipColumns + // ChunkSkipColumns (Community Edition only) if (operation.ChunkSkipColumns != null && operation.ChunkSkipColumns.Count > 0) { - statements.Add("SET timescaledb.enable_chunk_skipping = 'ON';"); + communityStatements.Add("SET timescaledb.enable_chunk_skipping = 'ON';"); foreach (string column in operation.ChunkSkipColumns) { - statements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); } } - // AdditionalDimensions + // AdditionalDimensions (Available in both editions) if (operation.AdditionalDimensions != null && operation.AdditionalDimensions.Count > 0) { foreach (Dimension dimension in operation.AdditionalDimensions) @@ -87,6 +90,11 @@ public List Generate(CreateHypertableOperation operation) } } + // Add wrapped community statements if any exist + if (communityStatements.Count > 0) + { + statements.Add(WrapCommunityFeatures(communityStatements)); + } return statements; } @@ -96,45 +104,52 @@ public List Generate(AlterHypertableOperation operation) string qualifiedIdentifier = sqlHelper.QualifiedIdentifier(operation.TableName, operation.Schema); List statements = []; + List communityStatements = []; - // Check for ChunkTimeInterval change + // Check for ChunkTimeInterval change (Available in both editions) if (operation.ChunkTimeInterval != operation.OldChunkTimeInterval) { + var setChunkTimeInterval = new StringBuilder(); + setChunkTimeInterval.Append($"SELECT set_chunk_time_interval({qualifiedTableName}, "); + // Check if the interval is a plain number (e.g., for microseconds). if (long.TryParse(operation.ChunkTimeInterval, out _)) { // If it's a number, don't wrap it in quotes. - statements.Add($"SELECT set_chunk_time_interval({qualifiedTableName}, {operation.ChunkTimeInterval}::bigint);"); + setChunkTimeInterval.Append($"{operation.ChunkTimeInterval}::bigint"); } else { // If it's a string like '7 days', wrap it in quotes. - statements.Add($"SELECT set_chunk_time_interval({qualifiedTableName}, INTERVAL '{operation.ChunkTimeInterval}');"); + setChunkTimeInterval.Append($"INTERVAL '{operation.ChunkTimeInterval}'"); } + + setChunkTimeInterval.Append(");"); + statements.Add(setChunkTimeInterval.ToString()); } - // Check for EnableCompression change + // 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(); if (newCompressionState != oldCompressionState) { string compressionValue = newCompressionState.ToString().ToLower(); - statements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {compressionValue});"); + communityStatements.Add($"ALTER TABLE {qualifiedIdentifier} SET (timescaledb.compress = {compressionValue});"); } - // Handle ChunkSkipColumns + // Handle ChunkSkipColumns (Community Edition only) IReadOnlyList newColumns = operation.ChunkSkipColumns ?? []; IReadOnlyList oldColumns = operation.OldChunkSkipColumns ?? []; List addedColumns = [.. newColumns.Except(oldColumns)]; if (addedColumns.Count != 0) { - statements.Add("SET timescaledb.enable_chunk_skipping = 'ON';"); + communityStatements.Add("SET timescaledb.enable_chunk_skipping = 'ON';"); foreach (string column in addedColumns) { - statements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT enable_chunk_skipping({qualifiedTableName}, '{column}');"); } } @@ -143,7 +158,7 @@ public List Generate(AlterHypertableOperation operation) { foreach (string column in removedColumns) { - statements.Add($"SELECT disable_chunk_skipping({qualifiedTableName}, '{column}');"); + communityStatements.Add($"SELECT disable_chunk_skipping({qualifiedTableName}, '{column}');"); } } @@ -194,8 +209,40 @@ public List Generate(AlterHypertableOperation operation) statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); } + + // Add wrapped community statements if any exist + if (communityStatements.Count > 0) + { + statements.Add(WrapCommunityFeatures(communityStatements)); + } return statements; } - } -} + /// + /// Wraps multiple SQL statements in a single license check block to ensure they only run on Community Edition + /// + private string WrapCommunityFeatures(List sqlStatements) + { + var sb = new StringBuilder(); + sb.AppendLine("DO $$"); + sb.AppendLine("DECLARE"); + sb.AppendLine(" license TEXT;"); + sb.AppendLine("BEGIN"); + sb.AppendLine(" license := current_setting('timescaledb.license', true);"); + sb.AppendLine(" "); + sb.AppendLine(" IF license IS NULL OR license != 'apache' THEN"); + + foreach (string sql in sqlStatements) + { + sb.AppendLine($" {sql}"); + } + + sb.AppendLine(" ELSE"); + sb.AppendLine(" RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition';"); + sb.AppendLine(" END IF;"); + sb.AppendLine("END $$;"); + + return sb.ToString(); + } + } +} \ No newline at end of file From eb726c03b1680df46480f2ed67ebf0c2fbe3abfa Mon Sep 17 00:00:00 2001 From: Anton Schieber Date: Tue, 18 Nov 2025 09:42:07 +1000 Subject: [PATCH 2/4] test: generator: update test with new edition changes --- .../HypertableOperationGeneratorTests.cs | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs index 618f72b..758a26d 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -64,12 +64,22 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() }; string expected = @".Sql(@"" - SELECT create_hypertable('custom_schema.""""FullTable""""', 'EventTime'); - SELECT set_chunk_time_interval('custom_schema.""""FullTable""""', INTERVAL '1 day'); - ALTER TABLE """"custom_schema"""".""""FullTable"""" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('custom_schema.""""FullTable""""', 'DeviceId'); + SELECT create_hypertable('custom_schema.""""FullTable""""', 'EventTime', chunk_time_interval => INTERVAL '1 day'); SELECT add_dimension('custom_schema.""""FullTable""""', by_hash('LocationId', 4)); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"custom_schema"""".""""FullTable"""" SET (timescaledb.compress = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""""FullTable""""', 'DeviceId'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -94,9 +104,20 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso }; string expected = @".Sql(@"" - ALTER TABLE """"custom_schema"""".""""Metrics"""" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('custom_schema.""""Metrics""""', 'device_id'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"custom_schema"""".""""Metrics"""" SET (timescaledb.compress = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""""Metrics""""', 'device_id'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -121,7 +142,18 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() }; string expected = @".Sql(@"" - ALTER TABLE """"public"""".""""SensorData"""" SET (timescaledb.compress = true); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"public"""".""""SensorData"""" SET (timescaledb.compress = true); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -144,9 +176,20 @@ public void Generate_Alter_when_adding_and_removing_skip_columns_generates_corre }; string expected = @".Sql(@"" - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('metrics_schema.""""Metrics""""', 'service'); - SELECT disable_chunk_skipping('metrics_schema.""""Metrics""""', 'region'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('metrics_schema.""""Metrics""""', 'service'); + SELECT disable_chunk_skipping('metrics_schema.""""Metrics""""', 'region'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -193,8 +236,19 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress ChunkSkipColumns = [] }; string expected = @".Sql(@"" - ALTER TABLE """"public"""".""""Logs"""" SET (timescaledb.compress = false); - SELECT disable_chunk_skipping('public.""""Logs""""', 'trace_id'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"public"""".""""Logs"""" SET (timescaledb.compress = false); + SELECT disable_chunk_skipping('public.""""Logs""""', 'trace_id'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act From ce02d7d4f14e62718a4b2adec92aae4e612b0e17 Mon Sep 17 00:00:00 2001 From: Anton Schieber Date: Thu, 27 Nov 2025 08:30:24 +1000 Subject: [PATCH 3/4] test: eftdb: generator: update with edition checks The checks will now account for editions by checking the current version. --- ...bleOperationGeneratorComprehensiveTests.cs | 62 ++++++++++++++++--- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs index 61c9677..207f4d3 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -61,8 +61,7 @@ public void DesignTime_Create_WithRangeDimension_GeneratesCorrectCode() }; string expected = @".Sql(@"" - SELECT create_hypertable('public.""""events""""', 'event_time'); - SELECT set_chunk_time_interval('public.""""events""""', INTERVAL '1 day'); + SELECT create_hypertable('public.""""events""""', 'event_time', chunk_time_interval => INTERVAL '1 day'); SELECT add_dimension('public.""""events""""', by_range('received_time', INTERVAL '7 days')); "")"; @@ -115,8 +114,7 @@ public void DesignTime_Create_WithChunkTimeIntervalAsMicroseconds_GeneratesCorre }; string expected = @".Sql(@"" - SELECT create_hypertable('public.""""high_freq_data""""', 'ts'); - SELECT set_chunk_time_interval('public.""""high_freq_data""""', 86400000000::bigint); + SELECT create_hypertable('public.""""high_freq_data""""', 'ts', chunk_time_interval => 86400000000::bigint); "")"; // Act @@ -140,7 +138,18 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo string expected = @".Sql(@"" SELECT create_hypertable('public.""""compressed_data""""', 'time'); - ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -415,7 +424,18 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() }; string expected = @".Sql(@"" - ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -438,9 +458,20 @@ public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() }; string expected = @".Sql(@"" - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('public.""""add_skip""""', 'col2'); - SELECT enable_chunk_skipping('public.""""add_skip""""', 'col3'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('public.""""add_skip""""', 'col2'); + SELECT enable_chunk_skipping('public.""""add_skip""""', 'col3'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act @@ -463,7 +494,18 @@ public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() }; string expected = @".Sql(@"" - SELECT disable_chunk_skipping('public.""""remove_skip""""', 'remove_this'); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + SELECT disable_chunk_skipping('public.""""remove_skip""""', 'remove_this'); + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + END IF; + END $$; "")"; // Act From c7edb5dafc1d594ccc93bb41fdb198cad2453199 Mon Sep 17 00:00:00 2001 From: Sebastian Ederer Date: Thu, 27 Nov 2025 20:04:44 +0100 Subject: [PATCH 4/4] feat: generator: hypertable: use EXECUTE for edition support (fixes #12) Use EXECUTE statements inside DO $$ blocks to fix PL/pgSQL limitations where SELECT statements cannot return data without a destination. - Wrap Community-only features in runtime license check - Escape single quotes for EXECUTE string literals - Move chunk_time_interval into create_hypertable() parameters - Update warning message to reference Apache Edition --- .../WriteRecordsBenchmarkBase.cs | 6 +- .../TimescaleDatabaseModelFactory.cs | 6 +- src/Eftdb/Abstractions/TimescaleCopyConfig.cs | 10 +-- .../ContinuousAggregateOperationGenerator.cs | 3 +- .../HypertableOperationGenerator.cs | 17 ++--- src/Eftdb/TimescaleDbCopyExtensions.cs | 2 +- ...bleOperationGeneratorComprehensiveTests.cs | 32 +++++----- .../HypertableOperationGeneratorTests.cs | 64 +++++++++++-------- 8 files changed, 75 insertions(+), 65 deletions(-) diff --git a/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs b/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs index 4a3d0d8..ce5e85d 100644 --- a/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs +++ b/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs @@ -46,13 +46,13 @@ public async Task GlobalCleanup() public void IterationSetup() { Trades.Clear(); - var random = new Random(); + Random random = new(); string[] tickers = ["AAPL", "GOOGL", "MSFT", "TSLA", "AMZN"]; - var baseTimestamp = DateTime.UtcNow.AddMinutes(-30); + DateTime baseTimestamp = DateTime.UtcNow.AddMinutes(-30); for (int i = 0; i < NumberOfRecords; i++) { - var trade = CreateTradeInstance(i, baseTimestamp, tickers[random.Next(tickers.Length)], random); + T trade = CreateTradeInstance(i, baseTimestamp, tickers[random.Next(tickers.Length)], random); Trades.Add(trade); } diff --git a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs index 86f833e..8fef8bd 100644 --- a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs +++ b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs @@ -28,9 +28,7 @@ public override DatabaseModel Create(DbConnection connection, DatabaseModelFacto DatabaseModel databaseModel = base.Create(connection, options); // Extract all TimescaleDB features from the database - var allFeatureData = _features - .Select(feature => feature.Extractor.Extract(connection)) - .ToList(); + List> allFeatureData = [.. _features.Select(feature => feature.Extractor.Extract(connection))]; // Apply annotations to tables/views in the model foreach (DatabaseTable table in databaseModel.Tables) @@ -42,7 +40,7 @@ public override DatabaseModel Create(DbConnection connection, DatabaseModelFacto // Apply each feature's annotations if the table has that feature for (int i = 0; i < _features.Count; i++) { - var featureData = allFeatureData[i]; + Dictionary<(string Schema, string TableName), object> featureData = allFeatureData[i]; if (featureData.TryGetValue(tableKey, out object? featureInfo)) { _features[i].Applier.ApplyAnnotations(table, featureInfo); diff --git a/src/Eftdb/Abstractions/TimescaleCopyConfig.cs b/src/Eftdb/Abstractions/TimescaleCopyConfig.cs index a815cc2..6b91a46 100644 --- a/src/Eftdb/Abstractions/TimescaleCopyConfig.cs +++ b/src/Eftdb/Abstractions/TimescaleCopyConfig.cs @@ -58,10 +58,10 @@ public TimescaleCopyConfig() if (MapClrTypeToNpgsqlDbType(property.PropertyType, out NpgsqlDbType dbType)) { // Auto-discover properties and create compiled getters for them. - var parameter = Expression.Parameter(typeof(T), "x"); - var member = Expression.Property(parameter, property); - var conversion = Expression.Convert(member, typeof(object)); - var lambda = Expression.Lambda>(conversion, parameter); + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + MemberExpression member = Expression.Property(parameter, property); + UnaryExpression conversion = Expression.Convert(member, typeof(object)); + Expression> lambda = Expression.Lambda>(conversion, parameter); ColumnMappings[property.Name] = (lambda.Compile(), dbType); } @@ -124,7 +124,7 @@ public TimescaleCopyConfig MapColumn(string columnName, Expression Generate(CreateContinuousAggregateOperation operation) } // Build the complete CREATE MATERIALIZED VIEW statement as a single string - var sqlBuilder = new System.Text.StringBuilder(); + StringBuilder sqlBuilder = new(); sqlBuilder.Append($"CREATE MATERIALIZED VIEW {qualifiedIdentifier}"); sqlBuilder.AppendLine(); sqlBuilder.Append($"WITH ({string.Join(", ", withOptions)}) AS"); diff --git a/src/Eftdb/Generators/HypertableOperationGenerator.cs b/src/Eftdb/Generators/HypertableOperationGenerator.cs index 0c65cb2..4a39817 100644 --- a/src/Eftdb/Generators/HypertableOperationGenerator.cs +++ b/src/Eftdb/Generators/HypertableOperationGenerator.cs @@ -28,7 +28,7 @@ public List Generate(CreateHypertableOperation operation) List communityStatements = []; // Build create_hypertable with chunk_time_interval if provided - var createHypertableCall = new StringBuilder(); + StringBuilder createHypertableCall = new(); createHypertableCall.Append($"SELECT create_hypertable({qualifiedTableName}, '{operation.TimeColumnName}'"); createHypertableCall.Append(operation.MigrateData ? ", migrate_data => true" : ""); @@ -109,7 +109,7 @@ public List Generate(AlterHypertableOperation operation) // Check for ChunkTimeInterval change (Available in both editions) if (operation.ChunkTimeInterval != operation.OldChunkTimeInterval) { - var setChunkTimeInterval = new StringBuilder(); + StringBuilder setChunkTimeInterval = new(); setChunkTimeInterval.Append($"SELECT set_chunk_time_interval({qualifiedTableName}, "); // Check if the interval is a plain number (e.g., for microseconds). @@ -209,7 +209,6 @@ public List Generate(AlterHypertableOperation operation) statements.Add($"-- WARNING: TimescaleDB does not support removing dimensions. The following dimensions cannot be removed: {dimensionList}"); } - // Add wrapped community statements if any exist if (communityStatements.Count > 0) { @@ -219,11 +218,11 @@ public List Generate(AlterHypertableOperation operation) } /// - /// Wraps multiple SQL statements in a single license check block to ensure they only run on Community Edition + /// Wraps multiple SQL statements in a single license check block to ensure they only run on Community Edition. /// - private string WrapCommunityFeatures(List sqlStatements) + private static string WrapCommunityFeatures(List sqlStatements) { - var sb = new StringBuilder(); + StringBuilder sb = new(); sb.AppendLine("DO $$"); sb.AppendLine("DECLARE"); sb.AppendLine(" license TEXT;"); @@ -234,11 +233,13 @@ private string WrapCommunityFeatures(List sqlStatements) foreach (string sql in sqlStatements) { - sb.AppendLine($" {sql}"); + // Remove trailing semicolon and escape single quotes for EXECUTE + string cleanSql = sql.TrimEnd(';').Replace("'", "''"); + sb.AppendLine($" EXECUTE '{cleanSql}';"); } sb.AppendLine(" ELSE"); - sb.AppendLine(" RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition';"); + sb.AppendLine(" RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';"); sb.AppendLine(" END IF;"); sb.AppendLine("END $$;"); diff --git a/src/Eftdb/TimescaleDbCopyExtensions.cs b/src/Eftdb/TimescaleDbCopyExtensions.cs index 3324c2f..84734b8 100644 --- a/src/Eftdb/TimescaleDbCopyExtensions.cs +++ b/src/Eftdb/TimescaleDbCopyExtensions.cs @@ -55,7 +55,7 @@ public static async Task BulkCopyAsync( await writer.StartRowAsync(); // Write each configured column in the specified order - foreach (var (Getter, DbType) in config.ColumnMappings.Values) + foreach ((Func? Getter, NpgsqlTypes.NpgsqlDbType DbType) in config.ColumnMappings.Values) { object? value = Getter(item); await writer.WriteAsync(value, DbType); diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs index 207f4d3..b2df5d4 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs @@ -143,11 +143,11 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true); + EXECUTE 'ALTER TABLE """"public"""".""""compressed_data"""" SET (timescaledb.compress = true)'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -429,11 +429,11 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false); + EXECUTE 'ALTER TABLE """"public"""".""""decompress"""" SET (timescaledb.compress = false)'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -463,13 +463,13 @@ public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('public.""""add_skip""""', 'col2'); - SELECT enable_chunk_skipping('public.""""add_skip""""', 'col3'); + EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; + EXECUTE 'SELECT enable_chunk_skipping(''public.""""add_skip""""'', ''col2'')'; + EXECUTE 'SELECT enable_chunk_skipping(''public.""""add_skip""""'', ''col3'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -499,11 +499,11 @@ public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - SELECT disable_chunk_skipping('public.""""remove_skip""""', 'remove_this'); + EXECUTE 'SELECT disable_chunk_skipping(''public.""""remove_skip""""'', ''remove_this'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -592,8 +592,8 @@ public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() string result = GetRuntimeSql(operation); // Assert - Must SET enable_chunk_skipping = 'ON' before enable_chunk_skipping() - Assert.Contains("SET timescaledb.enable_chunk_skipping = 'ON'", result); - Assert.Contains("enable_chunk_skipping('public.\"skip_test\"', 'new_col')", result); + Assert.Contains("SET timescaledb.enable_chunk_skipping = ''ON''", result); + Assert.Contains("enable_chunk_skipping(''public.\"skip_test\"'', ''new_col'')", result); } #endregion diff --git a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs index 758a26d..8c98604 100644 --- a/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs @@ -71,13 +71,13 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"custom_schema"""".""""FullTable"""" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('custom_schema.""""FullTable""""', 'DeviceId'); + EXECUTE 'ALTER TABLE """"custom_schema"""".""""FullTable"""" SET (timescaledb.compress = true)'; + EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""FullTable""""'', ''DeviceId'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -109,13 +109,13 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"custom_schema"""".""""Metrics"""" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('custom_schema.""""Metrics""""', 'device_id'); + EXECUTE 'ALTER TABLE """"custom_schema"""".""""Metrics"""" SET (timescaledb.compress = true)'; + EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""Metrics""""'', ''device_id'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -147,11 +147,11 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"public"""".""""SensorData"""" SET (timescaledb.compress = true); + EXECUTE 'ALTER TABLE """"public"""".""""SensorData"""" SET (timescaledb.compress = true)'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -181,13 +181,13 @@ public void Generate_Alter_when_adding_and_removing_skip_columns_generates_corre license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('metrics_schema.""""Metrics""""', 'service'); - SELECT disable_chunk_skipping('metrics_schema.""""Metrics""""', 'region'); + EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; + EXECUTE 'SELECT enable_chunk_skipping(''metrics_schema.""""Metrics""""'', ''service'')'; + EXECUTE 'SELECT disable_chunk_skipping(''metrics_schema.""""Metrics""""'', ''region'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -241,12 +241,12 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress license TEXT; BEGIN license := current_setting('timescaledb.license', true); - + IF license IS NULL OR license != 'apache' THEN - ALTER TABLE """"public"""".""""Logs"""" SET (timescaledb.compress = false); - SELECT disable_chunk_skipping('public.""""Logs""""', 'trace_id'); + EXECUTE 'ALTER TABLE """"public"""".""""Logs"""" SET (timescaledb.compress = false)'; + EXECUTE 'SELECT disable_chunk_skipping(''public.""""Logs""""'', ''trace_id'')'; ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Enterprise Edition'; + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; END IF; END $$; "")"; @@ -326,12 +326,22 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com }; string expected = @".Sql(@"" - SELECT create_hypertable('custom_schema.""""CompleteTable""""', 'EventTime', migrate_data => true); - SELECT set_chunk_time_interval('custom_schema.""""CompleteTable""""', INTERVAL '1 day'); - ALTER TABLE """"custom_schema"""".""""CompleteTable"""" SET (timescaledb.compress = true); - SET timescaledb.enable_chunk_skipping = 'ON'; - SELECT enable_chunk_skipping('custom_schema.""""CompleteTable""""', 'DeviceId'); + SELECT create_hypertable('custom_schema.""""CompleteTable""""', 'EventTime', migrate_data => true, chunk_time_interval => INTERVAL '1 day'); SELECT add_dimension('custom_schema.""""CompleteTable""""', by_hash('LocationId', 4)); + DO $$ + DECLARE + license TEXT; + BEGIN + license := current_setting('timescaledb.license', true); + + IF license IS NULL OR license != 'apache' THEN + EXECUTE 'ALTER TABLE """"custom_schema"""".""""CompleteTable"""" SET (timescaledb.compress = true)'; + EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; + EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""""CompleteTable""""'', ''DeviceId'')'; + ELSE + RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; + END IF; + END $$; "")"; // Act