diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
index 4342c08..5674377 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
@@ -14,6 +14,14 @@ public class ConnectionStringServiceTests
Assert.Equal(ConnStringFormat.Url, r.Value);
}
+ [Fact]
+ public void DetectFormat_Jdbc()
+ {
+ var r = svc.DetectFormat("jdbc:postgresql://localhost/db");
+ Assert.True(r.IsSuccess);
+ Assert.Equal(ConnStringFormat.Jdbc, r.Value);
+ }
+
[Fact]
public void DetectFormat_Npgsql()
{
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs
new file mode 100644
index 0000000..5bf2e1c
--- /dev/null
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs
@@ -0,0 +1,55 @@
+using pgLabII.PgUtils.ConnectionStrings;
+
+namespace pgLabII.PgUtils.Tests.ConnectionStrings;
+
+public class JdbcCodecTests
+{
+ [Fact]
+ public void Parse_Basic()
+ {
+ var codec = new JdbcCodec();
+ var r = codec.TryParse("jdbc:postgresql://localhost:5433/mydb?sslmode=require&applicationName=app&connectTimeout=12");
+ Assert.True(r.IsSuccess);
+ var d = r.Value;
+ Assert.Single(d.Hosts);
+ Assert.Equal("localhost", d.Hosts[0].Host);
+ Assert.Equal((ushort)5433, d.Hosts[0].Port);
+ Assert.Equal("mydb", d.Database);
+ Assert.Equal(Npgsql.SslMode.Require, d.SslMode);
+ Assert.Equal("app", d.ApplicationName);
+ Assert.Equal(12, d.TimeoutSeconds);
+ }
+
+ [Fact]
+ public void Parse_MultiHost_MixedPorts()
+ {
+ var codec = new JdbcCodec();
+ var r = codec.TryParse("jdbc:postgresql://host1:5432,[::1]:5544,host3/db");
+ Assert.True(r.IsSuccess);
+ var d = r.Value;
+ Assert.Equal(3, d.Hosts.Count);
+ Assert.Equal("host1", d.Hosts[0].Host);
+ Assert.Equal((ushort)5432, d.Hosts[0].Port);
+ Assert.Equal("::1", d.Hosts[1].Host);
+ Assert.Equal((ushort)5544, d.Hosts[1].Port);
+ Assert.Equal("host3", d.Hosts[2].Host);
+ Assert.Null(d.Hosts[2].Port);
+ Assert.Equal("db", d.Database);
+ }
+
+ [Fact]
+ public void Format_RoundTrip()
+ {
+ var codec = new JdbcCodec();
+ var parsed = codec.TryParse("jdbc:postgresql://hostA,hostB:5555/test_db?applicationName=cli¶m=x%20y");
+ Assert.True(parsed.IsSuccess);
+ var formatted = codec.TryFormat(parsed.Value);
+ Assert.True(formatted.IsSuccess);
+ var parsed2 = codec.TryParse(formatted.Value);
+ Assert.True(parsed2.IsSuccess);
+ Assert.Equal(parsed.Value.Hosts.Count, parsed2.Value.Hosts.Count);
+ Assert.Equal(parsed.Value.Database, parsed2.Value.Database);
+ Assert.Equal("cli", parsed2.Value.ApplicationName);
+ Assert.Equal("x y", parsed2.Value.Properties["param"]);
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
index 11e69b3..b715866 100644
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
@@ -21,18 +21,22 @@ public sealed class ConnectionStringService : IConnectionStringService
}
///
- /// 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).
///
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 DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail("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))
{
diff --git a/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
new file mode 100644
index 0000000..6d46bc2
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed class JdbcCodec : IConnectionStringCodec
+{
+ public ConnStringFormat Format => ConnStringFormat.Jdbc;
+ public string FormatName => "JDBC";
+
+ public Result TryParse(string input)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return Result.Fail("Empty JDBC url");
+ var trimmed = input.Trim();
+ if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
+ return Result.Fail("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(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(ex.Message);
+ }
+ }
+
+ public Result 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(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(ex.Message);
+ }
+ }
+
+ private static IEnumerable 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 ParseQuery(string query)
+ {
+ var dict = new Dictionary(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 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 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 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
+ };
+ }
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/PLAN.md b/pgLabII.PgUtils/ConnectionStrings/PLAN.md
index b30d7fa..10ffcf0 100644
--- a/pgLabII.PgUtils/ConnectionStrings/PLAN.md
+++ b/pgLabII.PgUtils/ConnectionStrings/PLAN.md
@@ -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.
diff --git a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
index aded35c..4351a45 100644
--- a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
+++ b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
@@ -35,7 +35,8 @@ public class EditServerConfigurationViewModel : ViewModelBase
Auto,
Libpq,
Npgsql,
- Url
+ Url,
+ Jdbc
}
private ForcedFormatOption _forcedFormat = ForcedFormatOption.Auto;
@@ -126,6 +127,7 @@ public class EditServerConfigurationViewModel : ViewModelBase
ForcedFormatOption.Libpq => new LibpqCodec(),
ForcedFormatOption.Npgsql => new NpgsqlCodec(),
ForcedFormatOption.Url => new UrlCodec(),
+ ForcedFormatOption.Jdbc => new JdbcCodec(),
_ => new UrlCodec()
};
var r = codec.TryParse(InputConnectionString);
@@ -153,6 +155,7 @@ public class EditServerConfigurationViewModel : ViewModelBase
ForcedFormatOption.Libpq => ConnStringFormat.Libpq,
ForcedFormatOption.Npgsql => ConnStringFormat.Npgsql,
ForcedFormatOption.Url => ConnStringFormat.Url,
+ ForcedFormatOption.Jdbc => ConnStringFormat.Jdbc,
_ => ConnStringFormat.Url
};
}
diff --git a/pgLabII/Views/EditServerConfigurationWindow.axaml b/pgLabII/Views/EditServerConfigurationWindow.axaml
index b5241c1..0280015 100644
--- a/pgLabII/Views/EditServerConfigurationWindow.axaml
+++ b/pgLabII/Views/EditServerConfigurationWindow.axaml
@@ -51,16 +51,17 @@
-
+
-
+
Auto
Libpq
Npgsql
URL
+ JDBC
@@ -73,11 +74,12 @@
-
+
Libpq
Npgsql
URL
+ JDBC