Improve NpgsqlCodec whitespace and quotation rules

This commit is contained in:
eelke 2025-09-02 18:50:23 +02:00
parent 18e737e865
commit 4c7a6c2666
2 changed files with 44 additions and 21 deletions

View file

@ -46,7 +46,7 @@ public class NpgsqlCodecTests
{ {
Hosts = new [] { new HostEndpoint{ Host = "db.example.com", Port = 5432 } }, Hosts = new [] { new HostEndpoint{ Host = "db.example.com", Port = 5432 } },
Database = "prod db", Database = "prod db",
Username = "bob", Username = "bob ",
Password = "p;ss\"word", Password = "p;ss\"word",
SslMode = SslMode.VerifyFull, SslMode = SslMode.VerifyFull,
ApplicationName = "cli app", ApplicationName = "cli app",
@ -58,11 +58,12 @@ public class NpgsqlCodecTests
var s = res.Value; var s = res.Value;
Assert.Contains("Host=db.example.com", s); Assert.Contains("Host=db.example.com", s);
Assert.Contains("Port=5432", s); Assert.Contains("Port=5432", s);
Assert.Contains("Database=\"prod db\"", s); Assert.Contains("Database=prod db", s);
Assert.Contains("Username=bob", s); Assert.Contains("Username='bob '", s);
Assert.Contains("Password=\"p;ss\"\"word\"", s); // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior
Assert.Contains("Password='p;ss" + '"' + "word'", s);
Assert.Contains("SSL Mode=VerifyFull", s); Assert.Contains("SSL Mode=VerifyFull", s);
Assert.Contains("Application Name=\"cli app\"", s); Assert.Contains("Application Name=cli app", s);
Assert.Contains("Timeout=9", s); Assert.Contains("Timeout=9", s);
Assert.Contains("Search Path=public", s); Assert.Contains("Search Path=public", s);
} }
@ -77,11 +78,12 @@ public class NpgsqlCodecTests
var formatted = codec.TryFormat(parsed.Value); var formatted = codec.TryFormat(parsed.Value);
Assert.True(formatted.IsSuccess); Assert.True(formatted.IsSuccess);
var s = formatted.Value; var s = formatted.Value;
Assert.Contains("Host=\"my host\"", s); Assert.Contains("Host=my host", s);
Assert.Contains("Database=postgres", s); Assert.Contains("Database=postgres", s);
Assert.Contains("Username=me", s); Assert.Contains("Username=me", s);
Assert.Contains("Password=\"with;quote\"\"\"", s); // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior; parsed value contains one double-quote
Assert.Contains("Application Name=\"my app\"", s); Assert.Contains("Password='with;quote" + '"' + "'", s);
Assert.Contains("Application Name=my app", s);
Assert.Contains("SSL Mode=Prefer", s); Assert.Contains("SSL Mode=Prefer", s);
} }
} }

View file

@ -236,24 +236,44 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
private static string FormatPair(string key, string? value) private static string FormatPair(string key, string? value)
{ {
value ??= string.Empty; value ??= string.Empty;
var needsQuotes = NeedsQuoting(value); // Decide if we need quoting following DbConnectionStringBuilder rules:
if (!needsQuotes) return key + "=" + value; // - Empty => quote
return key + "=\"" + EscapeQuoted(value) + "\""; // - Leading or trailing whitespace => quote
// - Contains ';' or '=' => quote
// - Otherwise, no quotes, even if it contains internal whitespace
if (!NeedsQuoting(value))
return key + "=" + value;
// Choose single or double quotes. Prefer the one not present in the value; if both present, pick double and escape.
bool hasSingle = value.Contains('\'');
bool hasDouble = value.Contains('"');
if (!hasSingle)
{
// Use single quotes, escape single quotes by doubling when needed (not needed here since !hasSingle)
return key + "='" + value.Replace("'", "''") + "'";
}
if (!hasDouble)
{
// Use double quotes
return key + "=\"" + value.Replace("\"", "\"\"") + "\"";
}
// Value contains both quote types: default to double quotes and escape doubles by doubling
return key + "=\"" + value.Replace("\"", "\"\"") + "\"";
} }
private static bool NeedsQuoting(string value) private static bool NeedsQuoting(string value)
{ {
if (value.Length == 0) return true; if (value.Length == 0) return true;
foreach (var c in value) // Leading or trailing whitespace requires quoting
{ if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true;
if (char.IsWhiteSpace(c) || c == ';' || c == '=' || c == '"') return true; // Special characters
} if (value.IndexOf(';') >= 0 || value.IndexOf('=') >= 0) return true;
return false; return false;
} }
private static string EscapeQuoted(string value) private static string EscapeQuoted(string value)
{ {
// Double the quotes per standard DbConnectionString rules // Retained for compatibility, but not used directly; prefer inlined replacements in FormatPair
return value.Replace("\"", "\"\""); return value.Replace("\"", "\"\"");
} }
@ -279,19 +299,19 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// read value // read value
string value; string value;
if (i < input.Length && input[i] == '"') if (i < input.Length && (input[i] == '"' || input[i] == '\''))
{ {
i++; // skip opening quote char quote = input[i++]; // opening quote (' or ")
var sb = new StringBuilder(); var sb = new StringBuilder();
while (i < input.Length) while (i < input.Length)
{ {
char c = input[i++]; char c = input[i++];
if (c == '"') if (c == quote)
{ {
if (i < input.Length && input[i] == '"') if (i < input.Length && input[i] == quote)
{ {
// doubled quote -> literal quote // doubled quote -> literal quote
sb.Append('"'); sb.Append(quote);
i++; i++;
continue; continue;
} }
@ -311,6 +331,7 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
{ {
int valStart = i; int valStart = i;
while (i < input.Length && input[i] != ';') i++; while (i < input.Length && input[i] != ';') i++;
// Unquoted value: per DbConnectionStringBuilder, leading/trailing whitespace is ignored
value = input.Substring(valStart, i - valStart).Trim(); value = input.Substring(valStart, i - valStart).Trim();
} }