From 2ebd9c26de6dba49f9fb9577318d50b134608662 Mon Sep 17 00:00:00 2001 From: Brian Pursley Date: Thu, 12 Mar 2026 17:42:39 -0400 Subject: [PATCH 1/3] Fix hstore SQL literal generation for null and escaped values --- .../Mapping/NpgsqlHstoreTypeMapping.cs | 37 +++++++++++-------- .../Storage/NpgsqlTypeMappingTest.cs | 23 ++++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs index 3d6eef2f0..d0f378077 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs @@ -66,29 +66,36 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p protected override string GenerateNonNullSqlLiteral(object value) { var sb = new StringBuilder("HSTORE '"); - foreach (var kv in (IReadOnlyDictionary)value) + + if (value is not IReadOnlyDictionary dict) { - sb.Append('"'); - sb.Append(kv.Key); // TODO: Escape - sb.Append("\"=>"); - if (kv.Value is null) - { - sb.Append("NULL"); - } - else - { - sb.Append('"'); - sb.Append(kv.Value); // TODO: Escape - sb.Append("\","); - } + throw new ArgumentException($"Expected a dictionary, but got {value.GetType()}", nameof(value)); } - sb.Remove(sb.Length - 1, 1); + foreach (var kv in dict) + { + sb.Append(QuoteHStoreString(kv.Key)); + sb.Append("=>"); + sb.Append(kv.Value is null ? "NULL" : QuoteHStoreString(kv.Value)); + sb.Append(','); + } + + if (sb[^1] == ',') + { + sb.Remove(sb.Length - 1, 1); + } sb.Append('\''); return sb.ToString(); } + // Quote string for use in an HStore literal. + // Escape \ as \\ + // Escape " as \" + // Escape ' as '' + private static string QuoteHStoreString(string s) + => string.Concat("\"", s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("'", "''"), "\""); + private static ValueComparer? GetComparer(Type clrType) { if (clrType == typeof(Dictionary)) diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index bfefb835a..b0e3c3782 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -454,6 +454,29 @@ public void GenerateSqlLiteral_returns_hstore_literal() """HSTORE '"k1"=>"v1","k2"=>"v2"'""", GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", "v1" }, { "k2", "v2" } })); + [Fact] + public void GenerateSqlLiteral_returns_hstore_literal_when_first_value_is_null() + => Assert.Equal( + """HSTORE '"k1"=>NULL,"k2"=>"v2"'""", + GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", null }, { "k2", "v2" } })); + + [Fact] + public void GenerateSqlLiteral_returns_hstore_literal_with_escaped_keys_and_values() + => Assert.Equal( + """HSTORE '"k\"1\""=>"v\"1\"","k\\2"=>"v\\2","k''3''"=>"v''3''"'""", + GetMapping("hstore").GenerateSqlLiteral(new Dictionary + { + { "k\"1\"", "v\"1\"" }, + { "k\\2", "v\\2" }, + { "k'3'", "v'3'" } + })); + + [Fact] + public void GenerateSqlLiteral_returns_hstore_literal_from_empty_dictionary() + => Assert.Equal( + "HSTORE ''", + GetMapping("hstore").GenerateSqlLiteral(new Dictionary())); + [Fact] public void GenerateSqlLiteral_returns_BigInteger_literal() { From f1eac9028f8f699dc37c9fbb8611633171f3a21f Mon Sep 17 00:00:00 2001 From: Brian Pursley Date: Thu, 12 Mar 2026 18:14:34 -0400 Subject: [PATCH 2/3] Update test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index b0e3c3782..487195af5 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -458,7 +458,7 @@ public void GenerateSqlLiteral_returns_hstore_literal() public void GenerateSqlLiteral_returns_hstore_literal_when_first_value_is_null() => Assert.Equal( """HSTORE '"k1"=>NULL,"k2"=>"v2"'""", - GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", null }, { "k2", "v2" } })); + GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", null }, { "k2", "v2" } })); [Fact] public void GenerateSqlLiteral_returns_hstore_literal_with_escaped_keys_and_values() From 67b2555672a776b3e2fc11a80947932312ea73bb Mon Sep 17 00:00:00 2001 From: Brian Pursley Date: Thu, 12 Mar 2026 18:23:45 -0400 Subject: [PATCH 3/3] Revert "Update test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs" This reverts commit f1eac9028f8f699dc37c9fbb8611633171f3a21f. --- test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index 487195af5..b0e3c3782 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -458,7 +458,7 @@ public void GenerateSqlLiteral_returns_hstore_literal() public void GenerateSqlLiteral_returns_hstore_literal_when_first_value_is_null() => Assert.Equal( """HSTORE '"k1"=>NULL,"k2"=>"v2"'""", - GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", null }, { "k2", "v2" } })); + GetMapping("hstore").GenerateSqlLiteral(new Dictionary { { "k1", null }, { "k2", "v2" } })); [Fact] public void GenerateSqlLiteral_returns_hstore_literal_with_escaped_keys_and_values()