Change NpgsqlCodec to rely on the DbConnectionStringBuilder class for basic parsing and formatting.
This commit is contained in:
parent
4c7a6c2666
commit
d78de23ebc
2 changed files with 47 additions and 139 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Globalization;
|
using System.Collections;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -6,19 +8,7 @@ using Npgsql;
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parser/formatter for Npgsql-style .NET connection strings. We intentionally do not
|
/// Parser/formatter for Npgsql-style .NET connection strings.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NpgsqlCodec : IConnectionStringCodec
|
public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
{
|
{
|
||||||
|
|
@ -116,16 +106,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parts = new List<string>();
|
var parts = new DbConnectionStringBuilder();
|
||||||
|
|
||||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
||||||
{
|
{
|
||||||
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
|
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();
|
var ports = descriptor.Hosts.Select(h => h.Port).Where(p => p.HasValue).Select(p => p!.Value).Distinct().ToList();
|
||||||
if (ports.Count == 1)
|
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)
|
else if (ports.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -136,31 +126,24 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
// Per-host ports if provided 1:1
|
// Per-host ports if provided 1:1
|
||||||
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
|
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
|
||||||
if (perHost.All(s => !string.IsNullOrEmpty(s)))
|
if (perHost.All(s => !string.IsNullOrEmpty(s)))
|
||||||
parts.Add(FormatPair("Port", string.Join(',', perHost)));
|
parts["Port"] = string.Join(',', perHost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(descriptor.Database))
|
if (!string.IsNullOrEmpty(descriptor.Database))
|
||||||
parts.Add(FormatPair("Database", descriptor.Database));
|
parts["Database"] = descriptor.Database;
|
||||||
if (!string.IsNullOrEmpty(descriptor.Username))
|
if (!string.IsNullOrEmpty(descriptor.Username))
|
||||||
parts.Add(FormatPair("Username", descriptor.Username));
|
parts["Username"] = descriptor.Username;
|
||||||
if (!string.IsNullOrEmpty(descriptor.Password))
|
if (!string.IsNullOrEmpty(descriptor.Password))
|
||||||
parts.Add(FormatPair("Password", descriptor.Password));
|
parts["Password"] = descriptor.Password;
|
||||||
if (descriptor.SslMode.HasValue)
|
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))
|
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||||
parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
|
parts["Application Name"] = descriptor.ApplicationName;
|
||||||
if (descriptor.TimeoutSeconds.HasValue)
|
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<string>(parts.Select(p => p.Split('=')[0].Trim()), StringComparer.OrdinalIgnoreCase);
|
return Result.Ok(parts.ConnectionString);
|
||||||
foreach (var kv in descriptor.Properties)
|
|
||||||
{
|
|
||||||
if (!emittedKeys.Contains(kv.Key))
|
|
||||||
parts.Add(FormatPair(kv.Key, kv.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Ok(string.Join(";", parts));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -232,116 +215,12 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
_ => "Prefer"
|
_ => "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<string, string> Tokenize(string input)
|
private static Dictionary<string, string> 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<string, string>(StringComparer.OrdinalIgnoreCase);
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
int i = 0;
|
foreach (string k in db.Keys)
|
||||||
void SkipWs() { while (i < input.Length && char.IsWhiteSpace(input[i])) i++; }
|
dict.Add(k, (string)db[k]);
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue