171 lines
6.3 KiB
C#
171 lines
6.3 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using FluentResults;
|
|
|
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
|
|
|
/// <summary>
|
|
/// Codec for JDBC PostgreSQL URLs: jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&...
|
|
/// - Supports multiple hosts with optional per-host ports, IPv6 bracketed literals.
|
|
/// - Percent-decodes database and query param values on parse; encodes on format.
|
|
/// - Recognizes sslmode/ssl, applicationName, loginTimeout/connectTimeout and maps to descriptor.
|
|
/// - Preserves unrecognized parameters in Properties.
|
|
/// </summary>
|
|
public sealed class JdbcCodec : IConnectionStringCodec
|
|
{
|
|
public ConnStringFormat Format => ConnStringFormat.Jdbc;
|
|
public string FormatName => "JDBC";
|
|
|
|
public Result<ConnectionDescriptor> TryParse(string input)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input))
|
|
return Result.Fail<ConnectionDescriptor>("Empty JDBC url");
|
|
var trimmed = input.Trim();
|
|
if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
|
|
return Result.Fail<ConnectionDescriptor>("JDBC url must start with jdbc:postgresql://");
|
|
|
|
// Strip scheme
|
|
var rest = trimmed.Substring("jdbc:postgresql://".Length);
|
|
|
|
// Split authority and path+query
|
|
string authority = rest;
|
|
string pathAndQuery = string.Empty;
|
|
var slashIdx = rest.IndexOf('/');
|
|
if (slashIdx >= 0)
|
|
{
|
|
authority = rest.Substring(0, slashIdx);
|
|
pathAndQuery = rest.Substring(slashIdx); // includes '/'
|
|
}
|
|
|
|
var builder = new ConnectionDescriptorBuilder();
|
|
|
|
// Parse hosts (comma separated)
|
|
foreach (string part in CodecCommon.SplitHosts(authority))
|
|
{
|
|
CodecCommon.ParseHostPort(part, out var host, out ushort? port);
|
|
if (!string.IsNullOrEmpty(host))
|
|
builder.AddHost(host!, port);
|
|
}
|
|
|
|
// Parse database and query
|
|
string? database = null;
|
|
string query = string.Empty;
|
|
if (!string.IsNullOrEmpty(pathAndQuery))
|
|
{
|
|
int qIdx = pathAndQuery.IndexOf('?');
|
|
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
|
|
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
|
|
if (path.Length > 0)
|
|
{
|
|
if (path[0] == '/') path = path.Substring(1);
|
|
if (path.Length > 0)
|
|
database = Uri.UnescapeDataString(path);
|
|
}
|
|
}
|
|
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
|
|
|
var queryDict = CodecCommon.ParseQuery(query);
|
|
|
|
// Map known properties
|
|
if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl"))
|
|
builder.SslMode = CodecCommon.ParseSslModeLoose(ssl);
|
|
if (TryFirst(queryDict, out string? app, "applicationName", "application_name"))
|
|
builder.ApplicationName = app;
|
|
if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout"))
|
|
{
|
|
if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t))
|
|
builder.TimeoutSeconds = t;
|
|
}
|
|
|
|
// Preserve extras
|
|
var mapped = new HashSet<string>(["sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout"
|
|
], StringComparer.OrdinalIgnoreCase);
|
|
foreach (var kv in queryDict)
|
|
{
|
|
if (!mapped.Contains(kv.Key))
|
|
builder.Properties[kv.Key] = kv.Value;
|
|
}
|
|
|
|
return Result.Ok(builder.Build());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result.Fail<ConnectionDescriptor>(ex.Message);
|
|
}
|
|
}
|
|
|
|
public Result<string> TryFormat(ConnectionDescriptor descriptor)
|
|
{
|
|
try
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("jdbc:postgresql://");
|
|
|
|
if (descriptor.Hosts.Count > 0)
|
|
{
|
|
sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost)));
|
|
}
|
|
|
|
// Path with database
|
|
if (!string.IsNullOrEmpty(descriptor.Database))
|
|
{
|
|
sb.Append('/');
|
|
sb.Append(Uri.EscapeDataString(descriptor.Database));
|
|
}
|
|
|
|
// Query parameters
|
|
var qp = new List<(string k, string v)>();
|
|
if (descriptor.SslMode.HasValue)
|
|
{
|
|
qp.Add(("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
|
}
|
|
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
|
{
|
|
qp.Add(("applicationName", descriptor.ApplicationName));
|
|
}
|
|
if (descriptor.TimeoutSeconds.HasValue)
|
|
{
|
|
qp.Add(("connectTimeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
// Add extras not already present
|
|
var emitted = new HashSet<string>(qp.Select(x => x.k), StringComparer.OrdinalIgnoreCase);
|
|
foreach (var kv in descriptor.Properties)
|
|
{
|
|
if (!emitted.Contains(kv.Key))
|
|
qp.Add((kv.Key, kv.Value));
|
|
}
|
|
|
|
if (qp.Count > 0)
|
|
{
|
|
sb.Append('?');
|
|
sb.Append(string.Join('&', qp.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v ?? string.Empty))));
|
|
}
|
|
|
|
return Result.Ok(sb.ToString());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result.Fail<string>(ex.Message);
|
|
}
|
|
}
|
|
|
|
private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h);
|
|
|
|
private static bool TryFirst(
|
|
Dictionary<string, string> dict,
|
|
[MaybeNullWhen(false)] out string value,
|
|
params string[] keys)
|
|
{
|
|
foreach (string k in keys)
|
|
{
|
|
if (dict.TryGetValue(k, out value))
|
|
return true;
|
|
}
|
|
value = string.Empty;
|
|
return false;
|
|
}
|
|
}
|