JDBC string support
This commit is contained in:
parent
1d53ca2fc2
commit
0090f39910
7 changed files with 396 additions and 28 deletions
|
|
@ -21,18 +21,22 @@ public sealed class ConnectionStringService : IConnectionStringService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
|
||||
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url, Jdbc).
|
||||
/// </summary>
|
||||
public static ConnectionStringService CreateDefault()
|
||||
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
|
||||
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec(), new JdbcCodec() });
|
||||
|
||||
public Result<ConnStringFormat> DetectFormat(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return Result.Fail<ConnStringFormat>("Empty input");
|
||||
|
||||
// URL: postgresql:// or postgres://
|
||||
// URL: postgresql:// or postgres:// or JDBC jdbc:postgresql://
|
||||
var trimmed = input.TrimStart();
|
||||
if (trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Result.Ok(ConnStringFormat.Jdbc);
|
||||
}
|
||||
if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
293
pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
Normal file
293
pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentResults;
|
||||
using Npgsql;
|
||||
|
||||
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 (var part in SplitHosts(authority))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(part)) continue;
|
||||
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('?');
|
||||
var path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
|
||||
query = qIdx >= 0 ? pathAndQuery.Substring(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 = ParseQuery(query);
|
||||
|
||||
// Map known properties
|
||||
if (TryFirst(queryDict, out var ssl, "sslmode", "ssl"))
|
||||
builder.SslMode = ParseSslMode(ssl);
|
||||
if (TryFirst(queryDict, out var app, "applicationName", "application_name"))
|
||||
builder.ApplicationName = app;
|
||||
if (TryFirst(queryDict, out var tout, "loginTimeout", "connectTimeout", "connect_timeout"))
|
||||
{
|
||||
if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var t))
|
||||
builder.TimeoutSeconds = t;
|
||||
}
|
||||
|
||||
// Preserve extras
|
||||
var mapped = new HashSet<string>(new[] { "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 != null && 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", FormatSslMode(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 IEnumerable<string> SplitHosts(string authority)
|
||||
{
|
||||
return authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string FormatHost(HostEndpoint h)
|
||||
{
|
||||
var host = h.Host;
|
||||
if (host.Contains(':') && !host.StartsWith("["))
|
||||
{
|
||||
// IPv6 literal must be bracketed
|
||||
host = "[" + host + "]";
|
||||
}
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,20 +2,23 @@
|
|||
|
||||
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-30)
|
||||
## 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.
|
||||
- Tests for both codecs: parse, format, round-trip, edge quoting.
|
||||
- `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:
|
||||
- URL (postgresql://) codec ✓
|
||||
- JDBC (jdbc:postgresql://) codec
|
||||
- Composite `ConnectionStringService` (detect + convert) ✓
|
||||
- Mapping helpers to/from `ServerConfiguration` ✓
|
||||
|
||||
## Updated Plan
|
||||
|
||||
|
|
@ -26,26 +29,26 @@ Not yet implemented:
|
|||
- 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:
|
||||
- Implement `ConnectionStringService` composing codecs, detecting formats, converting via `ConnectionDescriptor`, and resolving alias priorities.
|
||||
5. Mapping with application model:
|
||||
- Add mapping utilities between `ConnectionDescriptor` and `ServerConfiguration` (primary host/port, db, SSL mode), with sensible defaults.
|
||||
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.
|
||||
- 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 and JDBC codecs; composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding).
|
||||
- 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.
|
||||
- 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 URL (postgresql://) codec with unit tests. Scope:
|
||||
- Parse: `postgresql://[user[:password]@]host1[:port1][,hostN[:portN]]/[database]?param=value&...`
|
||||
- Support percent-decoding for user, password, database, and query values.
|
||||
- Handle IPv6 literals in `[::1]` form; allow multiple hosts with optional per-host ports.
|
||||
- Map common params: `sslmode`, `application_name`, `connect_timeout` and preserve other query params in `Properties`.
|
||||
- Format: Build a URL using percent-encoding where required; emit multi-hosts and parameters from `Properties` not already emitted.
|
||||
- Tests: basic parse/format, quoting/percent-encoding, multi-host with mixed ports, round-trips.
|
||||
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, implement the composite `ConnectionStringService` to detect/convert across libpq, Npgsql, and URL formats.
|
||||
After that, consider minor documentation polish and any gaps in edge-case validation discovered while adding JDBC support.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue