Fix libpq parsing and refactors/code cleanup
This commit is contained in:
parent
0090f39910
commit
739d6bd65a
12 changed files with 234 additions and 543 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
using pgLabII.PgUtils.ConnectionStrings;
|
using FluentResults;
|
||||||
|
using pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
||||||
|
|
||||||
|
|
@ -22,20 +24,25 @@ public class PqConnectionStringParserTests
|
||||||
public void Success()
|
public void Success()
|
||||||
{
|
{
|
||||||
var parser = new PqConnectionStringParser(tokenizer);
|
var parser = new PqConnectionStringParser(tokenizer);
|
||||||
IDictionary<string, string> output = parser.Parse();
|
Result<IDictionary<string, string>> output = parser.Parse();
|
||||||
|
ResultAssert.Success(output, v =>
|
||||||
Assert.Single(output);
|
{
|
||||||
Assert.True(output.TryGetValue(kw, out string? result));
|
Assert.Single(v);
|
||||||
Assert.Equal(val, result);
|
Assert.True(v.TryGetValue(kw, out string? result));
|
||||||
|
Assert.Equal(val, result);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StaticParse()
|
public void StaticParse()
|
||||||
{
|
{
|
||||||
var output = PqConnectionStringParser.Parse("foo=bar");
|
Result<IDictionary<string, string>> output = PqConnectionStringParser.Parse("foo=bar");
|
||||||
Assert.Single(output);
|
ResultAssert.Success(output, v =>
|
||||||
Assert.True(output.TryGetValue("foo", out string? result));
|
{
|
||||||
Assert.Equal("bar", result);
|
Assert.Single(v);
|
||||||
|
Assert.True(v.TryGetValue("foo", out string? result));
|
||||||
|
Assert.Equal("bar", result);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// There are few tests here as this is a predictive parser and all error handling is done
|
// There are few tests here as this is a predictive parser and all error handling is done
|
||||||
// in the tokenizer
|
// in the tokenizer
|
||||||
|
|
|
||||||
101
pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs
Normal file
101
pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper utilities for codecs to reduce duplication (SSL mode mapping, host:port parsing/formatting,
|
||||||
|
/// URL query parsing, and .NET/libpq quoting helpers).
|
||||||
|
/// </summary>
|
||||||
|
internal static class CodecCommon
|
||||||
|
{
|
||||||
|
// SSL mapping
|
||||||
|
public static SslMode ParseSslModeLoose(string s)
|
||||||
|
=> s.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"disable" => SslMode.Disable,
|
||||||
|
"allow" => SslMode.Allow,
|
||||||
|
"prefer" => SslMode.Prefer,
|
||||||
|
"require" => SslMode.Require,
|
||||||
|
"verify-ca" or "verifyca" => SslMode.VerifyCA,
|
||||||
|
"verify-full" or "verifyfull" => SslMode.VerifyFull,
|
||||||
|
_ => throw new ArgumentException($"Not a valid SSL Mode: {s}")
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string FormatSslModeUrlLike(SslMode mode) => mode switch
|
||||||
|
{
|
||||||
|
SslMode.Disable => "disable",
|
||||||
|
SslMode.Allow => "allow",
|
||||||
|
SslMode.Prefer => "prefer",
|
||||||
|
SslMode.Require => "require",
|
||||||
|
SslMode.VerifyCA => "verify-ca",
|
||||||
|
SslMode.VerifyFull => "verify-full",
|
||||||
|
_ => "prefer"
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// host:port parsing for plain or [IPv6]:port
|
||||||
|
public static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
||||||
|
{
|
||||||
|
host = hostPart;
|
||||||
|
port = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(hostPart))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (hostPart[0] == '[')
|
||||||
|
{
|
||||||
|
int end = hostPart.IndexOf(']');
|
||||||
|
if (end > 0)
|
||||||
|
{
|
||||||
|
host = hostPart[1..end];
|
||||||
|
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
||||||
|
{
|
||||||
|
string ps = hostPart[(end + 2)..];
|
||||||
|
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||||
|
port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int colon = hostPart.LastIndexOf(':');
|
||||||
|
if (colon > 0 && colon < hostPart.Length - 1)
|
||||||
|
{
|
||||||
|
var ps = hostPart.Substring(colon + 1);
|
||||||
|
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||||
|
{
|
||||||
|
host = hostPart.Substring(0, colon);
|
||||||
|
port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatHost(HostEndpoint h)
|
||||||
|
{
|
||||||
|
var host = h.Host;
|
||||||
|
if (host.Contains(':') && !host.StartsWith("["))
|
||||||
|
host = "[" + host + "]"; // IPv6
|
||||||
|
|
||||||
|
return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string[] SplitHosts(string hostList)
|
||||||
|
=> hostList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
public static Dictionary<string, string> ParseQuery(string query)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrEmpty(query)) return dict;
|
||||||
|
foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var parts = kv.Split('=', 2);
|
||||||
|
var key = Uri.UnescapeDataString(parts[0]);
|
||||||
|
var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||||
|
dict[key] = val;
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
|
public sealed class ConnectionDescriptorBuilder
|
||||||
|
{
|
||||||
|
private List<HostEndpoint> Hosts { get; } = [];
|
||||||
|
public string? Database { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public SslMode? SslMode { get; set; }
|
||||||
|
public string? ApplicationName { get; set; }
|
||||||
|
public int? TimeoutSeconds { get; set; }
|
||||||
|
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void AddHost(string host, ushort? port)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(host)) return;
|
||||||
|
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionDescriptor Build()
|
||||||
|
{
|
||||||
|
return new ConnectionDescriptor
|
||||||
|
{
|
||||||
|
Hosts = Hosts,
|
||||||
|
Database = Database,
|
||||||
|
Username = Username,
|
||||||
|
Password = Password,
|
||||||
|
SslMode = SslMode,
|
||||||
|
ApplicationName = ApplicationName,
|
||||||
|
TimeoutSeconds = TimeoutSeconds,
|
||||||
|
Properties = Properties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
@ -46,11 +47,11 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
var builder = new ConnectionDescriptorBuilder();
|
var builder = new ConnectionDescriptorBuilder();
|
||||||
|
|
||||||
// Parse hosts (comma separated)
|
// Parse hosts (comma separated)
|
||||||
foreach (var part in SplitHosts(authority))
|
foreach (string part in CodecCommon.SplitHosts(authority))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(part)) continue;
|
CodecCommon.ParseHostPort(part, out var host, out ushort? port);
|
||||||
ParseHostPort(part, out var host, out ushort? port);
|
if (!string.IsNullOrEmpty(host))
|
||||||
if (!string.IsNullOrEmpty(host)) builder.AddHost(host!, port);
|
builder.AddHost(host!, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse database and query
|
// Parse database and query
|
||||||
|
|
@ -59,8 +60,8 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
if (!string.IsNullOrEmpty(pathAndQuery))
|
if (!string.IsNullOrEmpty(pathAndQuery))
|
||||||
{
|
{
|
||||||
int qIdx = pathAndQuery.IndexOf('?');
|
int qIdx = pathAndQuery.IndexOf('?');
|
||||||
var path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
|
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
|
||||||
query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
|
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
|
||||||
if (path.Length > 0)
|
if (path.Length > 0)
|
||||||
{
|
{
|
||||||
if (path[0] == '/') path = path.Substring(1);
|
if (path[0] == '/') path = path.Substring(1);
|
||||||
|
|
@ -70,21 +71,22 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
||||||
|
|
||||||
var queryDict = ParseQuery(query);
|
var queryDict = CodecCommon.ParseQuery(query);
|
||||||
|
|
||||||
// Map known properties
|
// Map known properties
|
||||||
if (TryFirst(queryDict, out var ssl, "sslmode", "ssl"))
|
if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl"))
|
||||||
builder.SslMode = ParseSslMode(ssl);
|
builder.SslMode = CodecCommon.ParseSslModeLoose(ssl);
|
||||||
if (TryFirst(queryDict, out var app, "applicationName", "application_name"))
|
if (TryFirst(queryDict, out string? app, "applicationName", "application_name"))
|
||||||
builder.ApplicationName = app;
|
builder.ApplicationName = app;
|
||||||
if (TryFirst(queryDict, out var tout, "loginTimeout", "connectTimeout", "connect_timeout"))
|
if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout"))
|
||||||
{
|
{
|
||||||
if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var t))
|
if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t))
|
||||||
builder.TimeoutSeconds = t;
|
builder.TimeoutSeconds = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve extras
|
// Preserve extras
|
||||||
var mapped = new HashSet<string>(new[] { "sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout" }, StringComparer.OrdinalIgnoreCase);
|
var mapped = new HashSet<string>(["sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout"
|
||||||
|
], StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var kv in queryDict)
|
foreach (var kv in queryDict)
|
||||||
{
|
{
|
||||||
if (!mapped.Contains(kv.Key))
|
if (!mapped.Contains(kv.Key))
|
||||||
|
|
@ -106,7 +108,7 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append("jdbc:postgresql://");
|
sb.Append("jdbc:postgresql://");
|
||||||
|
|
||||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
if (descriptor.Hosts.Count > 0)
|
||||||
{
|
{
|
||||||
sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost)));
|
sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost)));
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +124,7 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
var qp = new List<(string k, string v)>();
|
var qp = new List<(string k, string v)>();
|
||||||
if (descriptor.SslMode.HasValue)
|
if (descriptor.SslMode.HasValue)
|
||||||
{
|
{
|
||||||
qp.Add(("sslmode", FormatSslMode(descriptor.SslMode.Value)));
|
qp.Add(("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||||
{
|
{
|
||||||
|
|
@ -155,139 +157,19 @@ public sealed class JdbcCodec : IConnectionStringCodec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SplitHosts(string authority)
|
private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h);
|
||||||
{
|
|
||||||
return authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatHost(HostEndpoint h)
|
private static bool TryFirst(
|
||||||
|
Dictionary<string, string> dict,
|
||||||
|
[MaybeNullWhen(false)] out string value,
|
||||||
|
params string[] keys)
|
||||||
{
|
{
|
||||||
var host = h.Host;
|
foreach (string k in keys)
|
||||||
if (host.Contains(':') && !host.StartsWith("["))
|
|
||||||
{
|
{
|
||||||
// IPv6 literal must be bracketed
|
if (dict.TryGetValue(k, out value))
|
||||||
host = "[" + host + "]";
|
return true;
|
||||||
}
|
|
||||||
return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
|
||||||
{
|
|
||||||
host = hostPart;
|
|
||||||
port = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(hostPart)) return;
|
|
||||||
|
|
||||||
if (hostPart[0] == '[')
|
|
||||||
{
|
|
||||||
int end = hostPart.IndexOf(']');
|
|
||||||
if (end > 0)
|
|
||||||
{
|
|
||||||
host = hostPart.Substring(1, end - 1);
|
|
||||||
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
|
||||||
{
|
|
||||||
var ps = hostPart.Substring(end + 2);
|
|
||||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
|
||||||
port = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int colon = hostPart.LastIndexOf(':');
|
|
||||||
if (colon > 0 && colon < hostPart.Length - 1)
|
|
||||||
{
|
|
||||||
var ps = hostPart.Substring(colon + 1);
|
|
||||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
|
||||||
{
|
|
||||||
host = hostPart.Substring(0, colon);
|
|
||||||
port = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> ParseQuery(string query)
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (string.IsNullOrEmpty(query)) return dict;
|
|
||||||
foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
var parts = kv.Split('=', 2);
|
|
||||||
var key = Uri.UnescapeDataString(parts[0]);
|
|
||||||
var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
|
||||||
dict[key] = val;
|
|
||||||
}
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryFirst(Dictionary<string, string> dict, out string value, params string[] keys)
|
|
||||||
{
|
|
||||||
foreach (var k in keys)
|
|
||||||
{
|
|
||||||
if (dict.TryGetValue(k, out value)) return true;
|
|
||||||
}
|
}
|
||||||
value = string.Empty;
|
value = string.Empty;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SslMode ParseSslMode(string s)
|
|
||||||
{
|
|
||||||
switch (s.Trim().ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "disable": return SslMode.Disable;
|
|
||||||
case "allow": return SslMode.Allow;
|
|
||||||
case "prefer": return SslMode.Prefer;
|
|
||||||
case "require": return SslMode.Require;
|
|
||||||
case "verify-ca":
|
|
||||||
case "verifyca": return SslMode.VerifyCA;
|
|
||||||
case "verify-full":
|
|
||||||
case "verifyfull": return SslMode.VerifyFull;
|
|
||||||
default: throw new ArgumentException($"Not a valid SSL Mode: {s}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatSslMode(SslMode mode)
|
|
||||||
{
|
|
||||||
return mode switch
|
|
||||||
{
|
|
||||||
SslMode.Disable => "disable",
|
|
||||||
SslMode.Allow => "allow",
|
|
||||||
SslMode.Prefer => "prefer",
|
|
||||||
SslMode.Require => "require",
|
|
||||||
SslMode.VerifyCA => "verify-ca",
|
|
||||||
SslMode.VerifyFull => "verify-full",
|
|
||||||
_ => "prefer"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ConnectionDescriptorBuilder
|
|
||||||
{
|
|
||||||
public List<HostEndpoint> Hosts { get; } = new();
|
|
||||||
public string? Database { get; set; }
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Password { get; set; }
|
|
||||||
public SslMode? SslMode { get; set; }
|
|
||||||
public string? ApplicationName { get; set; }
|
|
||||||
public int? TimeoutSeconds { get; set; }
|
|
||||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public void AddHost(string host, ushort? port)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(host)) return;
|
|
||||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConnectionDescriptor Build()
|
|
||||||
{
|
|
||||||
return new ConnectionDescriptor
|
|
||||||
{
|
|
||||||
Hosts = Hosts,
|
|
||||||
Database = Database,
|
|
||||||
Username = Username,
|
|
||||||
Password = Password,
|
|
||||||
SslMode = SslMode,
|
|
||||||
ApplicationName = ApplicationName,
|
|
||||||
TimeoutSeconds = TimeoutSeconds,
|
|
||||||
Properties = Properties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
using System;
|
using System.Globalization;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -222,35 +219,18 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SslMode ParseSslMode(string s)
|
private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s);
|
||||||
{
|
|
||||||
switch (s.Trim().ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "disable": return SslMode.Disable;
|
|
||||||
case "allow": return SslMode.Allow;
|
|
||||||
case "prefer": return SslMode.Prefer;
|
|
||||||
case "require": return SslMode.Require;
|
|
||||||
case "verify-ca":
|
|
||||||
case "verifyca": return SslMode.VerifyCA;
|
|
||||||
case "verify-full":
|
|
||||||
case "verifyfull": return SslMode.VerifyFull;
|
|
||||||
default: throw new ArgumentException($"Not a valid SSL Mode: {s}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatSslMode(SslMode mode)
|
private static string FormatSslMode(SslMode mode) => mode switch
|
||||||
{
|
{
|
||||||
return mode switch
|
SslMode.Disable => "Disable",
|
||||||
{
|
SslMode.Allow => "Allow",
|
||||||
SslMode.Disable => "Disable",
|
SslMode.Prefer => "Prefer",
|
||||||
SslMode.Allow => "Allow",
|
SslMode.Require => "Require",
|
||||||
SslMode.Prefer => "Prefer",
|
SslMode.VerifyCA => "VerifyCA",
|
||||||
SslMode.Require => "Require",
|
SslMode.VerifyFull => "VerifyFull",
|
||||||
SslMode.VerifyCA => "VerifyCA",
|
_ => "Prefer"
|
||||||
SslMode.VerifyFull => "VerifyFull",
|
};
|
||||||
_ => "Prefer"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled
|
// 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)
|
private static string FormatPair(string key, string? value)
|
||||||
|
|
@ -343,37 +323,4 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConnectionDescriptorBuilder
|
|
||||||
{
|
|
||||||
public List<HostEndpoint> Hosts { get; } = new();
|
|
||||||
public string? Database { get; set; }
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Password { get; set; }
|
|
||||||
public SslMode? SslMode { get; set; }
|
|
||||||
public string? ApplicationName { get; set; }
|
|
||||||
public int? TimeoutSeconds { get; set; }
|
|
||||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public void AddHost(string host, ushort? port)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(host)) return;
|
|
||||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConnectionDescriptor Build()
|
|
||||||
{
|
|
||||||
return new ConnectionDescriptor
|
|
||||||
{
|
|
||||||
Hosts = Hosts,
|
|
||||||
Database = Database,
|
|
||||||
Username = Username,
|
|
||||||
Password = Password,
|
|
||||||
SslMode = SslMode,
|
|
||||||
ApplicationName = ApplicationName,
|
|
||||||
TimeoutSeconds = TimeoutSeconds,
|
|
||||||
Properties = Properties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Connection Strings Plan
|
|
||||||
|
|
||||||
This document tracks the plan for supporting multiple PostgreSQL connection string formats, converting between them, and mapping to/from a canonical model.
|
|
||||||
|
|
||||||
## Current Status (2025-08-31)
|
|
||||||
|
|
||||||
Implemented:
|
|
||||||
- Abstractions: `ConnStringFormat`, `HostEndpoint`, `ConnectionDescriptor`, `IConnectionStringCodec`, `IConnectionStringService`.
|
|
||||||
- Codecs:
|
|
||||||
- `LibpqCodec` (libpq): parse/format; multi-host; `sslmode`, `application_name`, `connect_timeout`; quoting/escaping; preserves extras.
|
|
||||||
- `NpgsqlCodec` (.NET/Npgsql): parse/format; alias recognition; multi-host with single or per-host ports; `SSL Mode`, `Application Name`, `Timeout`; double-quote rules; preserves extras.
|
|
||||||
- `UrlCodec` (postgresql://): parse/format; userinfo, multi-host with per-host ports, IPv6 `[::1]` handling, database path, percent-decoding/encoding, common params mapping, preserves extras.
|
|
||||||
- Composite `ConnectionStringService` (detect + convert) composing Libpq, Npgsql, and Url codecs.
|
|
||||||
- Mapping helpers to/from `ServerConfiguration` (primary host/port, database, SSL mode) with sensible defaults.
|
|
||||||
- Tests:
|
|
||||||
- Unit tests for Libpq, Npgsql, and Url codecs (parse/format/round-trip/edge quoting and percent-encoding).
|
|
||||||
- ConnectionStringService detection/conversion tests.
|
|
||||||
- ServerConfiguration mapping tests.
|
|
||||||
|
|
||||||
Not yet implemented:
|
|
||||||
- JDBC (jdbc:postgresql://) codec
|
|
||||||
|
|
||||||
## Updated Plan
|
|
||||||
|
|
||||||
1. Define canonical model and interfaces for connection strings. ✓
|
|
||||||
2. Establish normalization strategy for parameter aliases and extra `Properties` handling. ✓
|
|
||||||
3. Implement format-specific codecs:
|
|
||||||
- libpq codec (parse/format; multi-host, quoting, sslmode, timeout, extras). ✓
|
|
||||||
- Npgsql codec (parse/format; aliases, multi-host/ports, quoting, ssl mode, timeout, extras). ✓
|
|
||||||
- URL (postgresql://) codec (parse/format; userinfo, host[:port], db, query params, percent-encoding). ✓
|
|
||||||
- JDBC (jdbc:postgresql://) codec (parse/format; hosts, ports, db, properties; URL-like semantics).
|
|
||||||
4. Composite conversion service. ✓
|
|
||||||
5. Mapping with application model. ✓
|
|
||||||
6. Validation and UX:
|
|
||||||
- Validation for malformed inputs & edge cases (mismatched host/port counts, invalid SSL mode, missing db/host, IPv6 bracket handling). ✓
|
|
||||||
- Ensure sensitive fields (password) are masked in logs/preview). ✓
|
|
||||||
7. Tests:
|
|
||||||
- Unit tests for URL codec (parse/format/round-trip/percent-encoding). ✓
|
|
||||||
- Tests for composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding). ✓
|
|
||||||
- Unit tests for JDBC codec.
|
|
||||||
8. Documentation:
|
|
||||||
- Keep this plan updated and enrich XML docs on codecs/service including alias mappings and quoting/escaping rules per format. *
|
|
||||||
|
|
||||||
## Next Small Step
|
|
||||||
|
|
||||||
Implement the JDBC (jdbc:postgresql://) codec with unit tests. Scope:
|
|
||||||
- Parse: `jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&...`
|
|
||||||
- Support multiple hosts with optional per-host ports; IPv6 bracket handling.
|
|
||||||
- Recognize common properties (sslmode/SSL, applicationName, loginTimeout/connectTimeout) and preserve unrecognized properties.
|
|
||||||
- Ensure URL-like semantics consistent with UrlCodec percent-decoding/encoding.
|
|
||||||
- Format: Build JDBC URL from ConnectionDescriptor; emit multi-hosts and properties from `Properties` not already emitted.
|
|
||||||
- Tests: basic parse/format, multi-host with mixed ports, percent-encoding, round-trips; cross-format conversions via ConnectionStringService.
|
|
||||||
|
|
||||||
After that, consider minor documentation polish and any gaps in edge-case validation discovered while adding JDBC support.
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
using System;
|
using System.Text;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
|
|
@ -16,14 +12,13 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Reject Npgsql-style strings that use ';' separators when forcing libpq
|
Result<IDictionary<string, string>> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
||||||
if (input.IndexOf(';') >= 0)
|
if (kv.IsFailed)
|
||||||
return Result.Fail<ConnectionDescriptor>("Semicolons are not valid separators in libpq connection strings");
|
return kv.ToResult();
|
||||||
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
|
||||||
|
|
||||||
// libpq keywords are case-insensitive; normalize to lower for lookup
|
// libpq keywords are case-insensitive; normalize to lower for lookup
|
||||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var pair in kv)
|
foreach (var pair in kv.Value)
|
||||||
dict[pair.Key] = pair.Value;
|
dict[pair.Key] = pair.Value;
|
||||||
|
|
||||||
var descriptor = new ConnectionDescriptorBuilder();
|
var descriptor = new ConnectionDescriptorBuilder();
|
||||||
|
|
@ -31,7 +26,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
if (dict.TryGetValue("host", out var host))
|
if (dict.TryGetValue("host", out var host))
|
||||||
{
|
{
|
||||||
// libpq supports host lists separated by commas
|
// libpq supports host lists separated by commas
|
||||||
var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
string[] hosts = CodecCommon.SplitHosts(host);
|
||||||
ushort? portForAll = null;
|
ushort? portForAll = null;
|
||||||
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
||||||
portForAll = p;
|
portForAll = p;
|
||||||
|
|
@ -40,10 +35,10 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
descriptor.AddHost(h, portForAll);
|
descriptor.AddHost(h, portForAll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
|
if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
|
||||||
{
|
{
|
||||||
// If hostaddr is provided without host, include as host entries as well
|
// If hostaddr is provided without a host, include as host entries as well
|
||||||
var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
string[] hosts = CodecCommon.SplitHosts(hostaddr);
|
||||||
ushort? portForAll = null;
|
ushort? portForAll = null;
|
||||||
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
||||||
portForAll = p;
|
portForAll = p;
|
||||||
|
|
@ -61,7 +56,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
descriptor.Password = pass;
|
descriptor.Password = pass;
|
||||||
|
|
||||||
if (dict.TryGetValue("sslmode", out var sslStr))
|
if (dict.TryGetValue("sslmode", out var sslStr))
|
||||||
descriptor.SslMode = ParseSslMode(sslStr);
|
descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr);
|
||||||
if (dict.TryGetValue("application_name", out var app))
|
if (dict.TryGetValue("application_name", out var app))
|
||||||
descriptor.ApplicationName = app;
|
descriptor.ApplicationName = app;
|
||||||
if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds))
|
if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds))
|
||||||
|
|
@ -93,7 +88,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
var parts = new List<string>();
|
var parts = new List<string>();
|
||||||
|
|
||||||
// Hosts and port
|
// Hosts and port
|
||||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
if (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.Add(FormatPair("host", hostList));
|
||||||
|
|
@ -110,7 +105,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
if (!string.IsNullOrEmpty(descriptor.Password))
|
if (!string.IsNullOrEmpty(descriptor.Password))
|
||||||
parts.Add(FormatPair("password", descriptor.Password));
|
parts.Add(FormatPair("password", descriptor.Password));
|
||||||
if (descriptor.SslMode.HasValue)
|
if (descriptor.SslMode.HasValue)
|
||||||
parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value)));
|
parts.Add(FormatPair("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||||
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
|
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
|
||||||
if (descriptor.TimeoutSeconds.HasValue)
|
if (descriptor.TimeoutSeconds.HasValue)
|
||||||
|
|
@ -132,34 +127,6 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SslMode ParseSslMode(string s)
|
|
||||||
{
|
|
||||||
return s.Trim().ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"disable" => SslMode.Disable,
|
|
||||||
"allow" => SslMode.Allow,
|
|
||||||
"prefer" => SslMode.Prefer,
|
|
||||||
"require" => SslMode.Require,
|
|
||||||
"verify-ca" => SslMode.VerifyCA,
|
|
||||||
"verify-full" => SslMode.VerifyFull,
|
|
||||||
_ => throw new ArgumentException($"Not a valid SSL mode: {s}")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatSslMode(SslMode mode)
|
|
||||||
{
|
|
||||||
return mode switch
|
|
||||||
{
|
|
||||||
SslMode.Disable => "disable",
|
|
||||||
SslMode.Allow => "allow",
|
|
||||||
SslMode.Prefer => "prefer",
|
|
||||||
SslMode.Require => "require",
|
|
||||||
SslMode.VerifyCA => "verify-ca",
|
|
||||||
SslMode.VerifyFull => "verify-full",
|
|
||||||
_ => "prefer"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatPair(string key, string? value)
|
private static string FormatPair(string key, string? value)
|
||||||
{
|
{
|
||||||
value ??= string.Empty;
|
value ??= string.Empty;
|
||||||
|
|
@ -170,56 +137,17 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
|
|
||||||
private static bool NeedsQuoting(string value)
|
private static bool NeedsQuoting(string value)
|
||||||
{
|
{
|
||||||
if (value.Length == 0) return true;
|
return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\');
|
||||||
foreach (var c in value)
|
|
||||||
{
|
|
||||||
if (char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\')
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EscapeValue(string value)
|
private static string EscapeValue(string value)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var c in value)
|
foreach (char c in value)
|
||||||
{
|
{
|
||||||
if (c == '\'' || c == '\\') sb.Append('\\');
|
if (c == '\'' || c == '\\') sb.Append('\\');
|
||||||
sb.Append(c);
|
sb.Append(c);
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConnectionDescriptorBuilder
|
|
||||||
{
|
|
||||||
public List<HostEndpoint> Hosts { get; } = new();
|
|
||||||
public string? Database { get; set; }
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Password { get; set; }
|
|
||||||
public SslMode? SslMode { get; set; }
|
|
||||||
public string? ApplicationName { get; set; }
|
|
||||||
public int? TimeoutSeconds { get; set; }
|
|
||||||
public Dictionary<string,string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public void AddHost(string host, ushort? port)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(host)) return;
|
|
||||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConnectionDescriptor Build()
|
|
||||||
{
|
|
||||||
return new ConnectionDescriptor
|
|
||||||
{
|
|
||||||
Hosts = Hosts,
|
|
||||||
Database = Database,
|
|
||||||
Username = Username,
|
|
||||||
Password = Password,
|
|
||||||
SslMode = SslMode,
|
|
||||||
ApplicationName = ApplicationName,
|
|
||||||
TimeoutSeconds = TimeoutSeconds,
|
|
||||||
Properties = Properties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ public ref struct PqConnectionStringParser
|
||||||
//service
|
//service
|
||||||
//target_session_attrs
|
//target_session_attrs
|
||||||
|
|
||||||
public static IDictionary<string, string> Parse(string input)
|
public static Result<IDictionary<string, string>> Parse(string input)
|
||||||
{
|
{
|
||||||
return new PqConnectionStringParser(
|
return new PqConnectionStringParser(
|
||||||
new PqConnectionStringTokenizer(input)
|
new PqConnectionStringTokenizer(input)
|
||||||
|
|
@ -63,12 +63,16 @@ public ref struct PqConnectionStringParser
|
||||||
this._tokenizer = tokenizer;
|
this._tokenizer = tokenizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IDictionary<string, string> Parse()
|
public Result<IDictionary<string, string>> Parse()
|
||||||
{
|
{
|
||||||
_result.Clear();
|
_result.Clear();
|
||||||
|
|
||||||
while (!_tokenizer.IsEof)
|
while (!_tokenizer.IsEof)
|
||||||
ParsePair();
|
{
|
||||||
|
var result = ParsePair();
|
||||||
|
if (result.IsFailed)
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
return _result;
|
return _result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,12 +67,6 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
{
|
{
|
||||||
while (position < input.Length && char.IsWhiteSpace(input[position]))
|
while (position < input.Length && char.IsWhiteSpace(input[position]))
|
||||||
position++;
|
position++;
|
||||||
// If a semicolon is encountered between pairs (which is not valid in libpq),
|
|
||||||
// treat as immediate EOF so parser stops and leaves trailing data unparsed.
|
|
||||||
if (position < input.Length && input[position] == ';')
|
|
||||||
{
|
|
||||||
position = input.Length; // force EOF
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string UnquotedString(bool forKeyword)
|
private string UnquotedString(bool forKeyword)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
using System;
|
using System.Globalization;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
|
|
@ -73,13 +68,12 @@ public sealed class UrlCodec : IConnectionStringCodec
|
||||||
builder.Password = Uri.UnescapeDataString(up[1]);
|
builder.Password = Uri.UnescapeDataString(up[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse hosts (may be comma-separated)
|
// Parse hosts (maybe comma-separated)
|
||||||
foreach (var hostPart in SplitHosts(authority))
|
foreach (string hostPart in CodecCommon.SplitHosts(authority))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(hostPart)) continue;
|
CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port);
|
||||||
ParseHostPort(hostPart, out var host, out ushort? port);
|
|
||||||
if (!string.IsNullOrEmpty(host))
|
if (!string.IsNullOrEmpty(host))
|
||||||
builder.AddHost(host!, port);
|
builder.AddHost(host, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse path (database) and query
|
// Parse path (database) and query
|
||||||
|
|
@ -88,24 +82,25 @@ public sealed class UrlCodec : IConnectionStringCodec
|
||||||
if (!string.IsNullOrEmpty(pathAndQuery))
|
if (!string.IsNullOrEmpty(pathAndQuery))
|
||||||
{
|
{
|
||||||
// pathAndQuery like /db?x=y
|
// pathAndQuery like /db?x=y
|
||||||
var qIdx = pathAndQuery.IndexOf('?');
|
int qIdx = pathAndQuery.IndexOf('?');
|
||||||
string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
|
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
|
||||||
query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
|
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
|
||||||
if (path.Length > 0)
|
if (path.Length > 0)
|
||||||
{
|
{
|
||||||
// strip leading '/'
|
// strip leading '/'
|
||||||
if (path[0] == '/') path = path.Substring(1);
|
if (path[0] == '/')
|
||||||
|
path = path[1..];
|
||||||
if (path.Length > 0)
|
if (path.Length > 0)
|
||||||
database = Uri.UnescapeDataString(path);
|
database = Uri.UnescapeDataString(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
||||||
|
|
||||||
var queryDict = ParseQuery(query);
|
var queryDict = CodecCommon.ParseQuery(query);
|
||||||
|
|
||||||
// Map known params
|
// Map known params
|
||||||
if (queryDict.TryGetValue("sslmode", out var sslVal))
|
if (queryDict.TryGetValue("sslmode", out var sslVal))
|
||||||
builder.SslMode = ParseSslMode(sslVal);
|
builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal);
|
||||||
if (queryDict.TryGetValue("application_name", out var app))
|
if (queryDict.TryGetValue("application_name", out var app))
|
||||||
builder.ApplicationName = app;
|
builder.ApplicationName = app;
|
||||||
if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts))
|
if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts))
|
||||||
|
|
@ -146,7 +141,7 @@ public sealed class UrlCodec : IConnectionStringCodec
|
||||||
}
|
}
|
||||||
|
|
||||||
// hosts
|
// hosts
|
||||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
if (descriptor.Hosts.Count > 0)
|
||||||
{
|
{
|
||||||
var hostParts = new List<string>(descriptor.Hosts.Count);
|
var hostParts = new List<string>(descriptor.Hosts.Count);
|
||||||
foreach (var h in descriptor.Hosts)
|
foreach (var h in descriptor.Hosts)
|
||||||
|
|
@ -170,7 +165,7 @@ public sealed class UrlCodec : IConnectionStringCodec
|
||||||
// query
|
// query
|
||||||
var queryPairs = new List<string>();
|
var queryPairs = new List<string>();
|
||||||
if (descriptor.SslMode.HasValue)
|
if (descriptor.SslMode.HasValue)
|
||||||
queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value)));
|
queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||||
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
|
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
|
||||||
if (descriptor.TimeoutSeconds.HasValue)
|
if (descriptor.TimeoutSeconds.HasValue)
|
||||||
|
|
@ -202,153 +197,4 @@ public sealed class UrlCodec : IConnectionStringCodec
|
||||||
=> key.Equals("sslmode", StringComparison.OrdinalIgnoreCase)
|
=> key.Equals("sslmode", StringComparison.OrdinalIgnoreCase)
|
||||||
|| key.Equals("application_name", StringComparison.OrdinalIgnoreCase)
|
|| key.Equals("application_name", StringComparison.OrdinalIgnoreCase)
|
||||||
|| key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase);
|
|| key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static IEnumerable<string> SplitHosts(string authority)
|
|
||||||
{
|
|
||||||
// authority may contain comma-separated hosts, each may be IPv6 [..] with optional :port
|
|
||||||
// We split on commas that are not inside brackets
|
|
||||||
var parts = new List<string>();
|
|
||||||
int depth = 0;
|
|
||||||
int start = 0;
|
|
||||||
for (int i = 0; i < authority.Length; i++)
|
|
||||||
{
|
|
||||||
char c = authority[i];
|
|
||||||
if (c == '[') depth++;
|
|
||||||
else if (c == ']') depth = Math.Max(0, depth - 1);
|
|
||||||
else if (c == ',' && depth == 0)
|
|
||||||
{
|
|
||||||
parts.Add(authority.Substring(start, i - start));
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// last
|
|
||||||
if (start <= authority.Length)
|
|
||||||
parts.Add(authority.Substring(start));
|
|
||||||
return parts.Select(p => p.Trim()).Where(p => p.Length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
|
||||||
{
|
|
||||||
host = string.Empty; port = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(hostPart)) return;
|
|
||||||
|
|
||||||
if (hostPart[0] == '[')
|
|
||||||
{
|
|
||||||
// IPv6 literal [....]:port?
|
|
||||||
int end = hostPart.IndexOf(']');
|
|
||||||
if (end < 0)
|
|
||||||
{
|
|
||||||
host = hostPart; // let it pass raw
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var h = hostPart.Substring(1, end - 1);
|
|
||||||
host = h;
|
|
||||||
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
|
||||||
{
|
|
||||||
var ps = hostPart.Substring(end + 2);
|
|
||||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
|
||||||
port = up;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// non-IPv6, split last ':' as port if numeric
|
|
||||||
int colon = hostPart.LastIndexOf(':');
|
|
||||||
if (colon > 0 && colon < hostPart.Length - 1)
|
|
||||||
{
|
|
||||||
var ps = hostPart.Substring(colon + 1);
|
|
||||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
|
||||||
{
|
|
||||||
port = up;
|
|
||||||
host = hostPart.Substring(0, colon);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
host = hostPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SslMode ParseSslMode(string s)
|
|
||||||
{
|
|
||||||
switch (s.Trim().ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "disable": return SslMode.Disable;
|
|
||||||
case "allow": return SslMode.Allow;
|
|
||||||
case "prefer": return SslMode.Prefer;
|
|
||||||
case "require": return SslMode.Require;
|
|
||||||
case "verify-ca":
|
|
||||||
case "verifyca": return SslMode.VerifyCA;
|
|
||||||
case "verify-full":
|
|
||||||
case "verifyfull": return SslMode.VerifyFull;
|
|
||||||
default: throw new ArgumentException($"Not a valid sslmode: {s}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatSslMode(SslMode mode)
|
|
||||||
{
|
|
||||||
return mode switch
|
|
||||||
{
|
|
||||||
SslMode.Disable => "disable",
|
|
||||||
SslMode.Allow => "allow",
|
|
||||||
SslMode.Prefer => "prefer",
|
|
||||||
SslMode.Require => "require",
|
|
||||||
SslMode.VerifyCA => "verify-ca",
|
|
||||||
SslMode.VerifyFull => "verify-full",
|
|
||||||
_ => "prefer"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ConnectionDescriptorBuilder
|
|
||||||
{
|
|
||||||
public List<HostEndpoint> Hosts { get; } = new();
|
|
||||||
public string? Database { get; set; }
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Password { get; set; }
|
|
||||||
public SslMode? SslMode { get; set; }
|
|
||||||
public string? ApplicationName { get; set; }
|
|
||||||
public int? TimeoutSeconds { get; set; }
|
|
||||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public void AddHost(string host, ushort? port)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(host)) return;
|
|
||||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConnectionDescriptor Build()
|
|
||||||
{
|
|
||||||
return new ConnectionDescriptor
|
|
||||||
{
|
|
||||||
Hosts = Hosts,
|
|
||||||
Database = Database,
|
|
||||||
Username = Username,
|
|
||||||
Password = Password,
|
|
||||||
SslMode = SslMode,
|
|
||||||
ApplicationName = ApplicationName,
|
|
||||||
TimeoutSeconds = TimeoutSeconds,
|
|
||||||
Properties = Properties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> ParseQuery(string query)
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (string.IsNullOrEmpty(query)) return dict;
|
|
||||||
var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
foreach (var pair in pairs)
|
|
||||||
{
|
|
||||||
var idx = pair.IndexOf('=');
|
|
||||||
if (idx < 0)
|
|
||||||
{
|
|
||||||
var k = Uri.UnescapeDataString(pair);
|
|
||||||
dict[k] = string.Empty;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var k = Uri.UnescapeDataString(pair.Substring(0, idx));
|
|
||||||
var v = Uri.UnescapeDataString(pair.Substring(idx + 1));
|
|
||||||
dict[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Avalonia.Threading;
|
|
||||||
using pgLabII.Model;
|
using pgLabII.Model;
|
||||||
using pgLabII.ViewModels;
|
using pgLabII.ViewModels;
|
||||||
using pgLabII.Views;
|
using pgLabII.Views;
|
||||||
|
|
@ -52,10 +48,10 @@ public class EditServerConfigurationWindowTests
|
||||||
{
|
{
|
||||||
var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
|
var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
|
||||||
|
|
||||||
// A semicolon-separated string that could be auto-detected as Npgsql
|
// Use a string with quoted values that libpq would struggle with due to incorrect quoting
|
||||||
vm.InputConnectionString = "Host=server;Username=bob;Password=secret;Database=db1;SSL Mode=Require";
|
vm.InputConnectionString = "Host=\"server with spaces\";Username=\"bob\";Password=\"secret\";Database=\"db1\"";
|
||||||
|
|
||||||
// Force interpret as libpq should fail to parse (libpq uses spaces) and keep defaults
|
// Force interpret as libpq should fail to parse (libpq expects single quotes, not double quotes for quoting)
|
||||||
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq;
|
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq;
|
||||||
vm.ParseConnectionStringCommand.Execute().Subscribe();
|
vm.ParseConnectionStringCommand.Execute().Subscribe();
|
||||||
|
|
||||||
|
|
@ -65,7 +61,7 @@ public class EditServerConfigurationWindowTests
|
||||||
// Now set to Auto and parse again -> should detect Npgsql and parse
|
// Now set to Auto and parse again -> should detect Npgsql and parse
|
||||||
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
|
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
|
||||||
vm.ParseConnectionStringCommand.Execute().Subscribe();
|
vm.ParseConnectionStringCommand.Execute().Subscribe();
|
||||||
Assert.Equal("server", vm.Configuration.Host);
|
Assert.Equal("server with spaces", vm.Configuration.Host);
|
||||||
Assert.Equal("db1", vm.Configuration.InitialDatabase);
|
Assert.Equal("db1", vm.Configuration.InitialDatabase);
|
||||||
Assert.Equal("bob", vm.Configuration.User.Name);
|
Assert.Equal("bob", vm.Configuration.User.Name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
pgLabII.sln.DotSettings
Normal file
4
pgLabII.sln.DotSettings
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=hostaddr/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=libpq/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=sslmode/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue