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 + + + +