diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6a883b3..ac326a4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,5 +37,7 @@
+
+
\ No newline at end of file
diff --git a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
index 7aa6519..57bf2d4 100644
--- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
@@ -38,30 +38,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Hosts and Ports
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
{
- var hosts = SplitList(hostVal).ToList();
- List portsPerHost = new();
+ var rawHosts = SplitList(hostVal).ToList();
+ var hosts = new List(rawHosts.Count);
+ var portsPerHost = new List(rawHosts.Count);
+
+ // First, extract inline ports from each host entry (e.g., host:5432 or [::1]:5432)
+ foreach (var raw in rawHosts)
+ {
+ ParseHostPort(raw, out var hostOnly, out var inlinePort);
+ hosts.Add(hostOnly);
+ portsPerHost.Add(inlinePort);
+ }
+
+ // Then, merge values from Port key: single port applies to all hosts missing a port;
+ // list of ports applies 1:1 for hosts that still miss a port. Inline ports take precedence.
if (dict.TryGetValue("Port", out var portVal))
{
var ports = SplitList(portVal).ToList();
- if (ports.Count == 1 && ushort.TryParse(ports[0], out var singlePort))
+ if (ports.Count == 1 && ushort.TryParse(ports[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var singlePort))
{
- foreach (var _ in hosts) portsPerHost.Add(singlePort);
+ for (int i = 0; i < portsPerHost.Count; i++)
+ if (!portsPerHost[i].HasValue)
+ portsPerHost[i] = singlePort;
}
else if (ports.Count == hosts.Count)
{
- foreach (var p in ports)
+ for (int i = 0; i < ports.Count; i++)
{
- if (ushort.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
- portsPerHost.Add(up);
- else
- portsPerHost.Add(null);
+ if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
+ portsPerHost[i] = up;
}
}
}
+
for (int i = 0; i < hosts.Count; i++)
{
- ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
- descriptor.AddHost(hosts[i], port);
+ descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
}
}
@@ -164,6 +176,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
+ private static void ParseHostPort(string hostPart, out string host, out ushort? port)
+ {
+ host = hostPart;
+ port = null;
+ if (string.IsNullOrWhiteSpace(hostPart)) return;
+
+ // IPv6 in brackets: [::1]:5432
+ 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;
+ }
+
+ // Non-IPv6: split on last ':' and ensure right side is numeric
+ 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 bool TryGetFirst(Dictionary dict, out string value, params string[] keys)
{
foreach (var k in keys)
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
index 7e4a3dc..6ae17a0 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
@@ -16,6 +16,9 @@ public sealed class LibpqCodec : IConnectionStringCodec
{
try
{
+ // Reject Npgsql-style strings that use ';' separators when forcing libpq
+ if (input.IndexOf(';') >= 0)
+ return Result.Fail("Semicolons are not valid separators in libpq connection strings");
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
// libpq keywords are case-insensitive; normalize to lower for lookup
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
index fd46bb8..2d8ff34 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
@@ -67,13 +67,29 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
{
while (position < input.Length && char.IsWhiteSpace(input[position]))
position++;
+ // If a semicolon is encountered between pairs (which is not valid in libpq),
+ // treat as immediate EOF so parser stops and leaves trailing data unparsed.
+ if (position < input.Length && input[position] == ';')
+ {
+ position = input.Length; // force EOF
+ }
}
private string UnquotedString(bool forKeyword)
{
int start = position;
- while (++position < input.Length && !char.IsWhiteSpace(input[position]) && (!forKeyword || input[position] != '='))
- { }
+ while (++position < input.Length)
+ {
+ char c = input[position];
+ // Libpq syntax does not use semicolons as pair separators; treat ';' as invalid here
+ if (c == ';')
+ {
+ // Force tokenizer to stop and later cause a parse error by making GetValue/keyword incomplete
+ break;
+ }
+ if (char.IsWhiteSpace(c)) break;
+ if (forKeyword && c == '=') break;
+ }
return input.Substring(start, position - start);
}
diff --git a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
new file mode 100644
index 0000000..40a9279
--- /dev/null
+++ b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using pgLabII.Model;
+using pgLabII.ViewModels;
+using pgLabII.Views;
+using Xunit;
+
+namespace pgLabII.Tests.Views;
+
+public class EditServerConfigurationWindowTests
+{
+ [AvaloniaFact]
+ public void Parse_and_Generate_roundtrip_via_UI_bindings()
+ {
+ // Arrange: initialize Avalonia headless app once for the test
+ var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
+ var window = new EditServerConfigurationWindow(vm);
+
+ // Act: set an URL input, auto mode, then parse
+ vm.InputConnectionString = "postgresql://user:pass@localhost:5433/mydb?sslmode=require";
+ vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
+ vm.ParseConnectionStringCommand.Execute().Subscribe();
+
+ // Assert fields updated
+ Assert.Equal("localhost", vm.Configuration.Host);
+ Assert.Equal((ushort)5433, vm.Configuration.Port);
+ Assert.Equal("mydb", vm.Configuration.InitialDatabase);
+ Assert.Equal("user", vm.Configuration.User.Name);
+ Assert.Equal("pass", vm.Configuration.User.Password);
+ Assert.Equal(Npgsql.SslMode.Require, vm.Configuration.DefaultSslMode);
+
+ // Generate back as libpq and validate
+ vm.OutputFormat = pgLabII.PgUtils.ConnectionStrings.ConnStringFormat.Libpq;
+ vm.GenerateConnectionStringCommand.Execute().Subscribe();
+ var outStr = vm.OutputConnectionString;
+ Assert.Contains("host=localhost", outStr);
+ Assert.Contains("port=5433", outStr);
+ Assert.Contains("dbname=mydb", outStr);
+ Assert.Contains("user=user", outStr);
+ Assert.Contains("password=pass", outStr);
+ Assert.Contains("sslmode=require", outStr);
+
+ window.Close();
+ }
+
+ [AvaloniaFact]
+ public void Forced_format_overrides_auto_detection()
+ {
+ var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
+
+ // A semicolon-separated string that could be auto-detected as Npgsql
+ vm.InputConnectionString = "Host=server;Username=bob;Password=secret;Database=db1;SSL Mode=Require";
+
+ // Force interpret as libpq should fail to parse (libpq uses spaces) and keep defaults
+ vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq;
+ vm.ParseConnectionStringCommand.Execute().Subscribe();
+
+ // Since forced libpq parse would fail, configuration should remain default (Host empty)
+ Assert.True(string.IsNullOrEmpty(vm.Configuration.Host));
+
+ // Now set to Auto and parse again -> should detect Npgsql and parse
+ vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
+ vm.ParseConnectionStringCommand.Execute().Subscribe();
+ Assert.Equal("server", vm.Configuration.Host);
+ Assert.Equal("db1", vm.Configuration.InitialDatabase);
+ Assert.Equal("bob", vm.Configuration.User.Name);
+ }
+
+ [AvaloniaFact]
+ public void Parse_Npgsql_with_inline_host_port_updates_all_fields()
+ {
+ var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
+ vm.InputConnectionString = "Host=host.docker.internal:5432;Database=kms_quartz;Username=postgres;Password=admin;Trust Server Certificate=true";
+ vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
+ vm.ParseConnectionStringCommand.Execute().Subscribe();
+
+ Assert.Equal("host.docker.internal", vm.Configuration.Host);
+ Assert.Equal((ushort)5432, vm.Configuration.Port);
+ Assert.Equal("kms_quartz", vm.Configuration.InitialDatabase);
+ Assert.Equal("postgres", vm.Configuration.User.Name);
+ Assert.Equal("admin", vm.Configuration.User.Password);
+ }
+}
diff --git a/pgLabII.Tests/pgLabII.Tests.csproj b/pgLabII.Tests/pgLabII.Tests.csproj
index f10cf0d..e67a3e1 100644
--- a/pgLabII.Tests/pgLabII.Tests.csproj
+++ b/pgLabII.Tests/pgLabII.Tests.csproj
@@ -15,6 +15,8 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/pgLabII/Infra/LocalDb.cs b/pgLabII/Infra/LocalDb.cs
index 7dd09b4..f665471 100644
--- a/pgLabII/Infra/LocalDb.cs
+++ b/pgLabII/Infra/LocalDb.cs
@@ -24,7 +24,9 @@ public class LocalDb : DbContext
// The following configures EF to create a Sqlite database file in the
// special "local" folder for your platform.
protected override void OnConfiguring(DbContextOptionsBuilder options)
- => options.UseSqlite($"Data Source={DbPath}");
+ {
+ options.UseSqlite($"Data Source={DbPath}");
+ }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/pgLabII/Model/ServerConfiguration.cs b/pgLabII/Model/ServerConfiguration.cs
index b251880..756561e 100644
--- a/pgLabII/Model/ServerConfiguration.cs
+++ b/pgLabII/Model/ServerConfiguration.cs
@@ -7,20 +7,20 @@ using Npgsql;
using pgLabII.ViewModels;
using pgLabII.Views;
using ReactiveUI;
+using ReactiveUI.SourceGenerators;
namespace pgLabII.Model;
-public class ServerConfiguration : ReactiveObject
+public partial class ServerConfiguration : ReactiveObject
{
private Color _color;
private bool _colorEnabled = true;
- private string initialDatabase = "";
- public Guid Id { get; set; } = Guid.NewGuid();
+ [Reactive] private Guid _id = Guid.NewGuid();
///
/// For the user to help him identify the item
///
- public string Name { get; set; } = "";
+ [Reactive] private string _name = "";
public Color Color
{
@@ -50,21 +50,19 @@ public class ServerConfiguration : ReactiveObject
}
}
- public string Host { get; set; } = "";
- public ushort Port { get; set; } = 5432;
- public string InitialDatabase
+ [Reactive] private string _host = "";
+ [Reactive] private ushort _port = 5432;
+ [Reactive] private string _initialDatabase = "";
+
+ [Reactive] private SslMode _sslMode = SslMode.Prefer;
+
+ // Explicit property wrapper to make compiled XAML binding findable
+ public SslMode DefaultSslMode
{
- get => initialDatabase;
- set
- {
- if (initialDatabase != value)
- {
- initialDatabase = value;
- this.RaisePropertyChanged();
- }
- }
+ get => _sslMode;
+ set => this.RaiseAndSetIfChanged(ref _sslMode, value);
}
- public SslMode DefaultSslMode { get; set; } = SslMode.Prefer;
+
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
public ServerUser User { get; set; } = new();
diff --git a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
index 2d27905..aded35c 100644
--- a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
+++ b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
@@ -1,5 +1,11 @@
-using System.Reactive;
+using System;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Linq;
+using Npgsql;
using pgLabII.Model;
+using pgLabII.PgUtils.ConnectionStrings;
+using pgLabII.Services;
using ReactiveUI;
namespace pgLabII.ViewModels;
@@ -8,19 +14,86 @@ public class EditServerConfigurationViewModel : ViewModelBase
{
public ServerConfiguration Configuration { get; set; }
+ // Connection string IO
+ private string _inputConnectionString = string.Empty;
+ public string InputConnectionString
+ {
+ get => _inputConnectionString;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _inputConnectionString, value);
+ // auto-detect when input changes and we are in Auto mode
+ if (ForcedFormat == ForcedFormatOption.Auto)
+ {
+ DetectFormat();
+ }
+ }
+ }
+
+ public enum ForcedFormatOption
+ {
+ Auto,
+ Libpq,
+ Npgsql,
+ Url
+ }
+
+ private ForcedFormatOption _forcedFormat = ForcedFormatOption.Auto;
+ public ForcedFormatOption ForcedFormat
+ {
+ get => _forcedFormat;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _forcedFormat, value);
+ // When forcing off Auto, clear detected label; when switching to Auto, re-detect
+ if (value == ForcedFormatOption.Auto)
+ DetectFormat();
+ else
+ DetectedFormat = null;
+ }
+ }
+
+ private ConnStringFormat? _detectedFormat;
+ public ConnStringFormat? DetectedFormat
+ {
+ get => _detectedFormat;
+ private set => this.RaiseAndSetIfChanged(ref _detectedFormat, value);
+ }
+
+ private ConnStringFormat _outputFormat = ConnStringFormat.Url;
+ public ConnStringFormat OutputFormat
+ {
+ get => _outputFormat;
+ set => this.RaiseAndSetIfChanged(ref _outputFormat, value);
+ }
+
+ private string _outputConnectionString = string.Empty;
+ public string OutputConnectionString
+ {
+ get => _outputConnectionString;
+ set => this.RaiseAndSetIfChanged(ref _outputConnectionString, value);
+ }
+
+ public ReactiveCommand ParseConnectionStringCommand { get; }
+ public ReactiveCommand GenerateConnectionStringCommand { get; }
+ public ReactiveCommand CopyOutputConnectionStringCommand { get; }
+
public ReactiveCommand SaveAndCloseCommand { get; }
public ReactiveCommand CloseCommand { get; }
+ private readonly IConnectionStringService _service;
+
public EditServerConfigurationViewModel()
{
Configuration = new();
+ _service = ConnectionStringService.CreateDefault();
- SaveAndCloseCommand = ReactiveCommand.Create(() =>
- {
- });
- CloseCommand = ReactiveCommand.Create(() =>
- {
- });
+ ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
+ GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
+ CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
+
+ SaveAndCloseCommand = ReactiveCommand.Create(() => { });
+ CloseCommand = ReactiveCommand.Create(() => { });
}
public EditServerConfigurationViewModel(ServerConfiguration configuration)
@@ -29,5 +102,69 @@ public class EditServerConfigurationViewModel : ViewModelBase
Configuration = configuration;
}
+ private void DetectFormat()
+ {
+ if (string.IsNullOrWhiteSpace(InputConnectionString))
+ {
+ DetectedFormat = null;
+ return;
+ }
+ var res = _service.DetectFormat(InputConnectionString);
+ DetectedFormat = res.IsSuccess ? res.Value : null;
+ }
+ private void ParseConnectionString()
+ {
+ if (string.IsNullOrWhiteSpace(InputConnectionString)) return;
+
+ var forced = ForcedFormat;
+ ConnectionDescriptor? descriptor = null;
+ if (forced != ForcedFormatOption.Auto)
+ {
+ IConnectionStringCodec codec = forced switch
+ {
+ ForcedFormatOption.Libpq => new LibpqCodec(),
+ ForcedFormatOption.Npgsql => new NpgsqlCodec(),
+ ForcedFormatOption.Url => new UrlCodec(),
+ _ => new UrlCodec()
+ };
+ var r = codec.TryParse(InputConnectionString);
+ if (r.IsSuccess) descriptor = r.Value;
+ }
+ else
+ {
+ var r = _service.ParseToDescriptor(InputConnectionString);
+ if (r.IsSuccess) descriptor = r.Value;
+ }
+
+ if (descriptor != null)
+ {
+ // Map into our configuration (update existing)
+ ServerConfigurationMapping.FromDescriptor(descriptor, Configuration);
+ // Also set sensible default OutputFormat to the detected/forced one
+ if (forced == ForcedFormatOption.Auto)
+ {
+ if (DetectedFormat.HasValue) OutputFormat = DetectedFormat.Value;
+ }
+ else
+ {
+ OutputFormat = forced switch
+ {
+ ForcedFormatOption.Libpq => ConnStringFormat.Libpq,
+ ForcedFormatOption.Npgsql => ConnStringFormat.Npgsql,
+ ForcedFormatOption.Url => ConnStringFormat.Url,
+ _ => ConnStringFormat.Url
+ };
+ }
+ }
+ }
+
+ private void GenerateConnectionString()
+ {
+ // Build descriptor from current configuration
+ var descriptor = ServerConfigurationMapping.ToDescriptor(Configuration);
+ var r = _service.FormatFromDescriptor(descriptor, OutputFormat);
+ if (r.IsSuccess)
+ OutputConnectionString = r.Value;
+ }
}
diff --git a/pgLabII/ViewModels/ServerListViewModel.cs b/pgLabII/ViewModels/ServerListViewModel.cs
index 562159b..a017016 100644
--- a/pgLabII/ViewModels/ServerListViewModel.cs
+++ b/pgLabII/ViewModels/ServerListViewModel.cs
@@ -18,6 +18,7 @@ public class ServerListViewModel : ViewModelBase
ColorEnabled = true,
Host = "localhost",
Port = 5434,
+ InitialDatabase = "dbname",
User = new ()
{
Name = "postgres",
@@ -46,8 +47,8 @@ public class ServerListViewModel : ViewModelBase
AddServerCommand = ReactiveCommand.Create(() =>
{
- ServerConfiguration sc = new();
- EditServerConfigurationWindow window = new() { DataContext = sc, New = true };
+ EditServerConfigurationViewModel vm = new();
+ EditServerConfigurationWindow window = new() { DataContext = vm, New = true };
window.Show();
});
}
diff --git a/pgLabII/Views/EditServerConfigurationWindow.axaml b/pgLabII/Views/EditServerConfigurationWindow.axaml
index 4b636e2..b5241c1 100644
--- a/pgLabII/Views/EditServerConfigurationWindow.axaml
+++ b/pgLabII/Views/EditServerConfigurationWindow.axaml
@@ -2,37 +2,89 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="650"
xmlns:vm="clr-namespace:pgLabII.ViewModels"
x:DataType="vm:EditServerConfigurationViewModel"
x:Class="pgLabII.Views.EditServerConfigurationWindow"
- Title="EditServerConfiguration"
+ Title="Edit Server Configuration"
SizeToContent="WidthAndHeight">
-
-
-
- Name:
-
-
- Color:
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Disable
+ Allow
+ Prefer
+ Require
+ VerifyCA
+ VerifyFull
+
+
+
+
+
+
+
+
-
- Host:
-
- Port:
-
+
+
+
+
+
+
+
+
+
+ Auto
+ Libpq
+ Npgsql
+ URL
+
+
+
+
+
+
- Database:
-
+
+
+
+
+
+
+
+ Libpq
+ Npgsql
+ URL
+
+
+
+
+
+
-
+
+