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