diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs
new file mode 100644
index 0000000..303d5da
--- /dev/null
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs
@@ -0,0 +1,29 @@
+using System.Data.Common;
+using Npgsql;
+
+namespace pgLabII.PgUtils.Tests.ConnectionStrings;
+
+public class DbConnectionStringBuilderTests
+{
+ [Theory]
+ [InlineData("abc", "abc")]
+ [InlineData(" abc ", "abc")]
+ [InlineData("\"abc \"", "abc ")]
+ public void TestDecode(string input, string expected)
+ {
+ DbConnectionStringBuilder sb = new() { ConnectionString = $"key={input}" };
+ string result = (string)sb["key"];
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("abc", "key=abc")]
+ [InlineData("abc ", "key=\"abc \"")]
+ [InlineData("a\"c", "key='a\"c'")]
+ public void TestEncode(string input, string expected)
+ {
+ DbConnectionStringBuilder sb = new();
+ sb["key"] = input;
+ Assert.Equal(expected, sb.ConnectionString);
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
index 79e63d0..ba67313 100644
--- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
@@ -1,4 +1,6 @@
-using System.Globalization;
+using System.Collections;
+using System.Data.Common;
+using System.Globalization;
using System.Text;
using FluentResults;
using Npgsql;
@@ -6,19 +8,7 @@ using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
///
-/// Parser/formatter for Npgsql-style .NET connection strings. We intentionally do not
-/// rely on NpgsqlConnectionStringBuilder here because:
-/// - We need a lossless, format-agnostic round-trip to our ConnectionDescriptor, including
-/// unknown/extension keys and per-host port lists. NpgsqlConnectionStringBuilder normalizes
-/// names, may drop unknown keys or coerce values, which breaks lossless conversions.
-/// - We support multi-host with per-host ports and want to preserve the original textual
-/// representation across conversions. The builder flattens/rewrites these details.
-/// - We aim to keep pgLabII.PgUtils independent from Npgsql's evolving parsing rules and
-/// version-specific behaviors to ensure stable UX and deterministic tests.
-/// - We need symmetric formatting matching our other codecs (libpq/URL/JDBC) and consistent
-/// quoting rules across formats.
-/// If required, we still reference Npgsql for enums and interop types, but parsing/formatting
-/// is done by this small, well-tested custom codec for full control and stability.
+/// Parser/formatter for Npgsql-style .NET connection strings.
///
public sealed class NpgsqlCodec : IConnectionStringCodec
{
@@ -116,16 +106,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
{
try
{
- var parts = new List();
+ var parts = new DbConnectionStringBuilder();
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
- parts.Add(FormatPair("Host", hostList));
+ parts["Host"] = hostList;
var ports = descriptor.Hosts.Select(h => h.Port).Where(p => p.HasValue).Select(p => p!.Value).Distinct().ToList();
if (ports.Count == 1)
{
- parts.Add(FormatPair("Port", ports[0].ToString(CultureInfo.InvariantCulture)));
+ parts["Port"] = ports[0].ToString(CultureInfo.InvariantCulture);
}
else if (ports.Count == 0)
{
@@ -136,31 +126,24 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Per-host ports if provided 1:1
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
if (perHost.All(s => !string.IsNullOrEmpty(s)))
- parts.Add(FormatPair("Port", string.Join(',', perHost)));
+ parts["Port"] = string.Join(',', perHost);
}
}
if (!string.IsNullOrEmpty(descriptor.Database))
- parts.Add(FormatPair("Database", descriptor.Database));
+ parts["Database"] = descriptor.Database;
if (!string.IsNullOrEmpty(descriptor.Username))
- parts.Add(FormatPair("Username", descriptor.Username));
+ parts["Username"] = descriptor.Username;
if (!string.IsNullOrEmpty(descriptor.Password))
- parts.Add(FormatPair("Password", descriptor.Password));
+ parts["Password"] = descriptor.Password;
if (descriptor.SslMode.HasValue)
- parts.Add(FormatPair("SSL Mode", FormatSslMode(descriptor.SslMode.Value)));
+ parts["SSL Mode"] = FormatSslMode(descriptor.SslMode.Value);
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
- parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
+ parts["Application Name"] = descriptor.ApplicationName;
if (descriptor.TimeoutSeconds.HasValue)
- parts.Add(FormatPair("Timeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
+ parts["Timeout"] = descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture);
- var emittedKeys = new HashSet(parts.Select(p => p.Split('=')[0].Trim()), StringComparer.OrdinalIgnoreCase);
- foreach (var kv in descriptor.Properties)
- {
- if (!emittedKeys.Contains(kv.Key))
- parts.Add(FormatPair(kv.Key, kv.Value));
- }
-
- return Result.Ok(string.Join(";", parts));
+ return Result.Ok(parts.ConnectionString);
}
catch (Exception ex)
{
@@ -232,116 +215,12 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
_ => "Prefer"
};
- // Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled
- private static string FormatPair(string key, string? value)
- {
- value ??= string.Empty;
- // Decide if we need quoting following DbConnectionStringBuilder rules:
- // - Empty => quote
- // - 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)
- {
- if (value.Length == 0) return true;
- // Leading or trailing whitespace requires quoting
- if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true;
- // Special characters
- if (value.IndexOf(';') >= 0 || value.IndexOf('=') >= 0) return true;
- return false;
- }
-
- private static string EscapeQuoted(string value)
- {
- // Retained for compatibility, but not used directly; prefer inlined replacements in FormatPair
- return value.Replace("\"", "\"\"");
- }
-
private static Dictionary Tokenize(string input)
{
- // Simple tokenizer for .NET connection strings: key=value pairs separated by semicolons; values may be quoted with double quotes
+ DbConnectionStringBuilder db = new() { ConnectionString = input };
var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- int i = 0;
- void SkipWs() { while (i < input.Length && char.IsWhiteSpace(input[i])) i++; }
-
- while (true)
- {
- SkipWs();
- if (i >= input.Length) break;
-
- // read key
- int keyStart = i;
- while (i < input.Length && input[i] != '=') i++;
- if (i >= input.Length) { break; }
- var key = input.Substring(keyStart, i - keyStart).Trim();
- i++; // skip '='
- SkipWs();
-
- // read value
- string value;
- if (i < input.Length && (input[i] == '"' || input[i] == '\''))
- {
- char quote = input[i++]; // opening quote (' or ")
- var sb = new StringBuilder();
- while (i < input.Length)
- {
- char c = input[i++];
- if (c == quote)
- {
- if (i < input.Length && input[i] == quote)
- {
- // doubled quote -> literal quote
- sb.Append(quote);
- i++;
- continue;
- }
- else
- {
- break; // end quoted value
- }
- }
- else
- {
- sb.Append(c);
- }
- }
- value = sb.ToString();
- }
- else
- {
- int valStart = i;
- while (i < input.Length && input[i] != ';') i++;
- // Unquoted value: per DbConnectionStringBuilder, leading/trailing whitespace is ignored
- value = input.Substring(valStart, i - valStart).Trim();
- }
-
- dict[key] = value;
-
- // skip to next, if ; present, consume one
- while (i < input.Length && input[i] != ';') i++;
- if (i < input.Length && input[i] == ';') i++;
- }
-
+ foreach (string k in db.Keys)
+ dict.Add(k, (string)db[k]);
return dict;
}
}