Skip to content

Commit 01ef041

Browse files
committed
fix: properly quote compression queries
1 parent f538ace commit 01ef041

3 files changed

Lines changed: 40 additions & 17 deletions

File tree

src/Eftdb/Generators/HypertableOperationGenerator.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,13 @@ public List<string> Generate(CreateHypertableOperation operation)
6565

6666
if (hasSegmentBy)
6767
{
68-
string segmentList = string.Join(", ", operation.CompressionSegmentBy!);
68+
string segmentList = string.Join(", ", operation.CompressionSegmentBy!.Select(QuoteIdentifier));
6969
compressionSettings.Add($"timescaledb.compress_segmentby = '{segmentList}'");
7070
}
7171

7272
if (hasOrderBy)
7373
{
74-
string orderList = string.Join(", ", operation.CompressionOrderBy!);
74+
string orderList = QuoteOrderByList(operation.CompressionOrderBy!);
7575
compressionSettings.Add($"timescaledb.compress_orderby = '{orderList}'");
7676
}
7777

@@ -176,17 +176,16 @@ static bool ListsChanged(IReadOnlyList<string>? oldList, IReadOnlyList<string>?
176176

177177
if (ListsChanged(operation.OldCompressionSegmentBy, operation.CompressionSegmentBy))
178178
{
179-
// If list is null/empty, set to '' to clear setting in DB
180179
string val = (operation.CompressionSegmentBy?.Count > 0)
181-
? $"'{string.Join(", ", operation.CompressionSegmentBy)}'"
180+
? $"'{string.Join(", ", operation.CompressionSegmentBy.Select(QuoteIdentifier))}'"
182181
: "''";
183182
compressionSettings.Add($"timescaledb.compress_segmentby = {val}");
184183
}
185184

186185
if (ListsChanged(operation.OldCompressionOrderBy, operation.CompressionOrderBy))
187186
{
188187
string val = (operation.CompressionOrderBy?.Count > 0)
189-
? $"'{string.Join(", ", operation.CompressionOrderBy)}'"
188+
? $"'{QuoteOrderByList(operation.CompressionOrderBy)}'"
190189
: "''";
191190
compressionSettings.Add($"timescaledb.compress_orderby = {val}");
192191
}
@@ -304,5 +303,31 @@ private static string WrapCommunityFeatures(List<string> sqlStatements)
304303

305304
return sb.ToString();
306305
}
306+
307+
/// <summary>
308+
/// Wraps an identifier in double quotes to preserve case-sensitivity in Postgres.
309+
/// Escapes existing double quotes.
310+
/// Example: TenantId -> "TenantId"
311+
/// </summary>
312+
private static string QuoteIdentifier(string identifier)
313+
{
314+
return $"\"{identifier.Replace("\"", "\"\"")}\"";
315+
}
316+
317+
/// <summary>
318+
/// Quotes the column name within an ORDER BY clause while preserving direction/nulls.
319+
/// Example: Timestamp DESC -> "Timestamp" DESC
320+
/// </summary>
321+
private static string QuoteOrderByList(IEnumerable<string> orderByClauses)
322+
{
323+
return string.Join(", ", orderByClauses.Select(clause =>
324+
{
325+
var parts = clause.Split(' ', 2);
326+
string col = parts[0];
327+
string suffix = parts.Length > 1 ? " " + parts[1] : "";
328+
329+
return QuoteIdentifier(col) + suffix;
330+
}));
331+
}
307332
}
308333
}

tests/Eftdb.Tests/Generators/HypertableOperationGeneratorComprehensiveTests.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,6 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode()
374374
CompressionSegmentBy = ["tenant_id", "device_id"]
375375
};
376376

377-
// Expected: compress=true AND compress_segmentby='tenant_id, device_id'
378377
string expected = @".Sql(@""
379378
SELECT create_hypertable('public.""""segmented_data""""', 'time');
380379
DO $$
@@ -384,7 +383,7 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode()
384383
license := current_setting('timescaledb.license', true);
385384
386385
IF license IS NULL OR license != 'apache' THEN
387-
EXECUTE 'ALTER TABLE """"public"""".""""segmented_data"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''tenant_id, device_id'')';
386+
EXECUTE 'ALTER TABLE """"public"""".""""segmented_data"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""tenant_id"", ""device_id""'')';
388387
ELSE
389388
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
390389
END IF;
@@ -419,7 +418,7 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode()
419418
license := current_setting('timescaledb.license', true);
420419
421420
IF license IS NULL OR license != 'apache' THEN
422-
EXECUTE 'ALTER TABLE """"public"""".""""ordered_data"""" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''time DESC, value ASC NULLS LAST'')';
421+
EXECUTE 'ALTER TABLE """"public"""".""""ordered_data"""" SET (timescaledb.compress = true, timescaledb.compress_orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')';
423422
ELSE
424423
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
425424
END IF;
@@ -453,10 +452,9 @@ public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter()
453452

454453
// Assert
455454
Assert.Contains("ALTER TABLE \"public\".\"full_compression\" SET", result);
456-
// Must contain all settings in one statement
457455
Assert.Contains("timescaledb.compress = true", result);
458-
Assert.Contains("timescaledb.compress_segmentby = ''tenant_id''", result);
459-
Assert.Contains("timescaledb.compress_orderby = ''time DESC''", result);
456+
Assert.Contains("timescaledb.compress_segmentby = ''\"tenant_id\"''", result);
457+
Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result);
460458
}
461459

462460
#endregion
@@ -864,7 +862,7 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode()
864862
license := current_setting('timescaledb.license', true);
865863
866864
IF license IS NULL OR license != 'apache' THEN
867-
EXECUTE 'ALTER TABLE """"public"""".""""metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''device_id'')';
865+
EXECUTE 'ALTER TABLE """"public"""".""""metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""device_id""'')';
868866
ELSE
869867
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
870868
END IF;
@@ -897,7 +895,7 @@ public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL()
897895
// Assert
898896
// Note: EnableCompression=true is NOT generated if it hasn't changed state (implicit false->false or true->true)
899897
// But we do expect the update to the specific setting.
900-
Assert.Contains("timescaledb.compress_orderby = ''time DESC''", result);
898+
Assert.Contains("timescaledb.compress_orderby = ''\"time\" DESC''", result);
901899
}
902900

903901
[Fact]
@@ -970,7 +968,7 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter()
970968
// Assert
971969
// Should be a single ALTER TABLE statement with 3 settings
972970
Assert.Contains("ALTER TABLE \"public\".\"metrics\" SET", result);
973-
Assert.Contains("timescaledb.compress_segmentby = ''new_col''", result);
971+
Assert.Contains("timescaledb.compress_segmentby = ''\"new_col\"''", result);
974972
Assert.Contains("timescaledb.compress_orderby = ''''", result);
975973
}
976974

tests/Eftdb.Tests/Generators/HypertableOperationGeneratorTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre
187187
license := current_setting('timescaledb.license', true);
188188
189189
IF license IS NULL OR license != 'apache' THEN
190-
EXECUTE 'ALTER TABLE """"public"""".""""CompressedTable"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''TenantId, DeviceId'', timescaledb.compress_orderby = ''Timestamp DESC, Value ASC NULLS LAST'')';
190+
EXECUTE 'ALTER TABLE """"public"""".""""CompressedTable"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.compress_orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')';
191191
ELSE
192192
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
193193
END IF;
@@ -222,7 +222,7 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql()
222222
license := current_setting('timescaledb.license', true);
223223
224224
IF license IS NULL OR license != 'apache' THEN
225-
EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''DeviceId'')';
225+
EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress = true, timescaledb.compress_segmentby = ''""DeviceId""'')';
226226
ELSE
227227
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
228228
END IF;
@@ -257,7 +257,7 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql()
257257
license := current_setting('timescaledb.license', true);
258258
259259
IF license IS NULL OR license != 'apache' THEN
260-
EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_orderby = ''Timestamp DESC'')';
260+
EXECUTE 'ALTER TABLE """"public"""".""""Metrics"""" SET (timescaledb.compress_orderby = ''""Timestamp"" DESC'')';
261261
ELSE
262262
RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition';
263263
END IF;

0 commit comments

Comments
 (0)