From b7631ecdd0045ebb2445bec96d1f6c1998b78580 Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 30 Aug 2025 20:38:59 +0200 Subject: [PATCH] ConnectionStringService --- .../ConnectionStringServiceTests.cs | 73 +++++++++++++++ .../ConnectionStringService.cs | 90 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs create mode 100644 pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs new file mode 100644 index 0000000..4342c08 --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs @@ -0,0 +1,73 @@ +using pgLabII.PgUtils.ConnectionStrings; + +namespace pgLabII.PgUtils.Tests.ConnectionStrings; + +public class ConnectionStringServiceTests +{ + private readonly ConnectionStringService svc = ConnectionStringService.CreateDefault(); + + [Fact] + public void DetectFormat_Url() + { + var r = svc.DetectFormat("postgresql://user@localhost/db"); + Assert.True(r.IsSuccess); + Assert.Equal(ConnStringFormat.Url, r.Value); + } + + [Fact] + public void DetectFormat_Npgsql() + { + var r = svc.DetectFormat("Host=localhost;Database=db;Username=u"); + Assert.True(r.IsSuccess); + Assert.Equal(ConnStringFormat.Npgsql, r.Value); + } + + [Fact] + public void DetectFormat_Libpq() + { + var r = svc.DetectFormat("host=localhost dbname=db user=u"); + Assert.True(r.IsSuccess); + Assert.Equal(ConnStringFormat.Libpq, r.Value); + } + + [Fact] + public void ParseToDescriptor_DispatchesByFormat() + { + var r1 = svc.ParseToDescriptor("host=localhost dbname=db user=u"); + Assert.True(r1.IsSuccess); + var r2 = svc.ParseToDescriptor("Host=localhost;Database=db;Username=u"); + Assert.True(r2.IsSuccess); + var r3 = svc.ParseToDescriptor("postgresql://u@localhost/db"); + Assert.True(r3.IsSuccess); + } + + [Fact] + public void Convert_Libpq_to_Npgsql() + { + var input = "host=localhost port=5433 dbname=mydb user=alice password=secret sslmode=require"; + var r = svc.Convert(input, ConnStringFormat.Npgsql); + Assert.True(r.IsSuccess); + var s = r.Value; + Assert.Contains("Host=localhost", s); + Assert.Contains("Port=5433", s); + Assert.Contains("Database=mydb", s); + Assert.Contains("Username=alice", s); + Assert.Contains("Password=secret", s); + Assert.Contains("SSL Mode=Require", s); + } + + [Fact] + public void Convert_Url_to_Libpq() + { + var input = "postgresql://bob:pwd@host1:5432,host2:5433/db?application_name=cli&sslmode=prefer"; + var r = svc.Convert(input, ConnStringFormat.Libpq); + Assert.True(r.IsSuccess); + var s = r.Value; + Assert.Contains("host=host1,host2", s); + // Ports differ (5432 vs 5433), libpq formatter emits a single port only when all ports are the same + Assert.DoesNotContain("port=", s); + Assert.Contains("dbname=db", s); + Assert.Contains("user=bob", s); + Assert.Contains("sslmode=prefer", s); + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs new file mode 100644 index 0000000..11e69b3 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentResults; + +namespace pgLabII.PgUtils.ConnectionStrings; + +/// +/// Composite implementation that composes multiple codecs to detect, parse, format and convert +/// PostgreSQL connection strings between formats. +/// +public sealed class ConnectionStringService : IConnectionStringService +{ + private readonly Dictionary _codecs; + private readonly IConnectionStringCodec[] _all; + + public ConnectionStringService(IEnumerable codecs) + { + _all = codecs?.ToArray() ?? Array.Empty(); + _codecs = _all.GroupBy(c => c.Format).ToDictionary(g => g.Key, g => g.First()); + } + + /// + /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url). + /// + public static ConnectionStringService CreateDefault() + => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() }); + + public Result DetectFormat(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return Result.Fail("Empty input"); + + // URL: postgresql:// or postgres:// + var trimmed = input.TrimStart(); + if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase)) + { + return Result.Ok(ConnStringFormat.Url); + } + + // Heuristic for Npgsql: presence of ';' separator and typical keys like Host= or Username= + // Also many .NET conn strings end with a trailing semicolon but not required. + if (trimmed.Contains(';')) + { + var firstEq = trimmed.IndexOf('='); + var firstSemi = trimmed.IndexOf(';'); + if (firstEq > 0 && (firstSemi > firstEq || firstSemi == -1)) + { + // quick key sample + var key = trimmed.Substring(0, firstEq).Trim(); + if (key.Length > 0) + return Result.Ok(ConnStringFormat.Npgsql); + } + } + + // Fallback to libpq if it looks like space-separated key=value or single pair + // Very loose heuristic: contains '=' and either a space-separated sequence or quotes + if (trimmed.Contains('=')) + { + return Result.Ok(ConnStringFormat.Libpq); + } + + return Result.Fail("Unable to detect format"); + } + + public Result ParseToDescriptor(string input) + { + var fmtRes = DetectFormat(input); + if (fmtRes.IsFailed) return Result.Fail(fmtRes.Errors); + var fmt = fmtRes.Value; + if (!_codecs.TryGetValue(fmt, out var codec)) + return Result.Fail($"No codec registered for {fmt}"); + return codec.TryParse(input); + } + + public Result FormatFromDescriptor(ConnectionDescriptor descriptor, ConnStringFormat targetFormat) + { + if (!_codecs.TryGetValue(targetFormat, out var codec)) + return Result.Fail($"No codec registered for {targetFormat}"); + return codec.TryFormat(descriptor); + } + + public Result Convert(string input, ConnStringFormat targetFormat) + { + var parseRes = ParseToDescriptor(input); + if (parseRes.IsFailed) return Result.Fail(parseRes.Errors); + return FormatFromDescriptor(parseRes.Value, targetFormat); + } +}