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; } }