ConnectionStringService

This commit is contained in:
eelke 2025-08-30 20:38:59 +02:00
parent 0ad39f6ae0
commit b7631ecdd0
2 changed files with 163 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentResults;
namespace pgLabII.PgUtils.ConnectionStrings;
/// <summary>
/// Composite implementation that composes multiple codecs to detect, parse, format and convert
/// PostgreSQL connection strings between formats.
/// </summary>
public sealed class ConnectionStringService : IConnectionStringService
{
private readonly Dictionary<ConnStringFormat, IConnectionStringCodec> _codecs;
private readonly IConnectionStringCodec[] _all;
public ConnectionStringService(IEnumerable<IConnectionStringCodec> codecs)
{
_all = codecs?.ToArray() ?? Array.Empty<IConnectionStringCodec>();
_codecs = _all.GroupBy(c => c.Format).ToDictionary(g => g.Key, g => g.First());
}
/// <summary>
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
/// </summary>
public static ConnectionStringService CreateDefault()
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
public Result<ConnStringFormat> DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail<ConnStringFormat>("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<ConnStringFormat>("Unable to detect format");
}
public Result<ConnectionDescriptor> ParseToDescriptor(string input)
{
var fmtRes = DetectFormat(input);
if (fmtRes.IsFailed) return Result.Fail<ConnectionDescriptor>(fmtRes.Errors);
var fmt = fmtRes.Value;
if (!_codecs.TryGetValue(fmt, out var codec))
return Result.Fail<ConnectionDescriptor>($"No codec registered for {fmt}");
return codec.TryParse(input);
}
public Result<string> FormatFromDescriptor(ConnectionDescriptor descriptor, ConnStringFormat targetFormat)
{
if (!_codecs.TryGetValue(targetFormat, out var codec))
return Result.Fail<string>($"No codec registered for {targetFormat}");
return codec.TryFormat(descriptor);
}
public Result<string> Convert(string input, ConnStringFormat targetFormat)
{
var parseRes = ParseToDescriptor(input);
if (parseRes.IsFailed) return Result.Fail<string>(parseRes.Errors);
return FormatFromDescriptor(parseRes.Value, targetFormat);
}
}