connection string/url parsing and generation in the server configuration dialog
This commit is contained in:
parent
a5cb6ef7d4
commit
1d53ca2fc2
11 changed files with 410 additions and 62 deletions
|
|
@ -37,5 +37,7 @@
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
|
||||||
|
<PackageVersion Include="Avalonia.Headless" Version="11.3.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.3.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -38,30 +38,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
// Hosts and Ports
|
// Hosts and Ports
|
||||||
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
|
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
|
||||||
{
|
{
|
||||||
var hosts = SplitList(hostVal).ToList();
|
var rawHosts = SplitList(hostVal).ToList();
|
||||||
List<ushort?> portsPerHost = new();
|
var hosts = new List<string>(rawHosts.Count);
|
||||||
|
var portsPerHost = new List<ushort?>(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))
|
if (dict.TryGetValue("Port", out var portVal))
|
||||||
{
|
{
|
||||||
var ports = SplitList(portVal).ToList();
|
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)
|
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))
|
if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
||||||
portsPerHost.Add(up);
|
portsPerHost[i] = up;
|
||||||
else
|
|
||||||
portsPerHost.Add(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < hosts.Count; i++)
|
for (int i = 0; i < hosts.Count; i++)
|
||||||
{
|
{
|
||||||
ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
|
descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
|
||||||
descriptor.AddHost(hosts[i], port);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +176,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||||
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
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<string, string> dict, out string value, params string[] keys)
|
private static bool TryGetFirst(Dictionary<string, string> dict, out string value, params string[] keys)
|
||||||
{
|
{
|
||||||
foreach (var k in keys)
|
foreach (var k in keys)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Reject Npgsql-style strings that use ';' separators when forcing libpq
|
||||||
|
if (input.IndexOf(';') >= 0)
|
||||||
|
return Result.Fail<ConnectionDescriptor>("Semicolons are not valid separators in libpq connection strings");
|
||||||
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
||||||
|
|
||||||
// libpq keywords are case-insensitive; normalize to lower for lookup
|
// libpq keywords are case-insensitive; normalize to lower for lookup
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,29 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
{
|
{
|
||||||
while (position < input.Length && char.IsWhiteSpace(input[position]))
|
while (position < input.Length && char.IsWhiteSpace(input[position]))
|
||||||
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)
|
private string UnquotedString(bool forKeyword)
|
||||||
{
|
{
|
||||||
int start = position;
|
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);
|
return input.Substring(start, position - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
87
pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
Normal file
87
pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Avalonia.Headless" />
|
||||||
|
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ public class LocalDb : DbContext
|
||||||
// The following configures EF to create a Sqlite database file in the
|
// The following configures EF to create a Sqlite database file in the
|
||||||
// special "local" folder for your platform.
|
// special "local" folder for your platform.
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder options)
|
protected override void OnConfiguring(DbContextOptionsBuilder options)
|
||||||
=> options.UseSqlite($"Data Source={DbPath}");
|
{
|
||||||
|
options.UseSqlite($"Data Source={DbPath}");
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,20 @@ using Npgsql;
|
||||||
using pgLabII.ViewModels;
|
using pgLabII.ViewModels;
|
||||||
using pgLabII.Views;
|
using pgLabII.Views;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
|
using ReactiveUI.SourceGenerators;
|
||||||
|
|
||||||
namespace pgLabII.Model;
|
namespace pgLabII.Model;
|
||||||
|
|
||||||
public class ServerConfiguration : ReactiveObject
|
public partial class ServerConfiguration : ReactiveObject
|
||||||
{
|
{
|
||||||
private Color _color;
|
private Color _color;
|
||||||
private bool _colorEnabled = true;
|
private bool _colorEnabled = true;
|
||||||
private string initialDatabase = "";
|
|
||||||
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
[Reactive] private Guid _id = Guid.NewGuid();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For the user to help him identify the item
|
/// For the user to help him identify the item
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; } = "";
|
[Reactive] private string _name = "";
|
||||||
|
|
||||||
public Color Color
|
public Color Color
|
||||||
{
|
{
|
||||||
|
|
@ -50,21 +50,19 @@ public class ServerConfiguration : ReactiveObject
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Host { get; set; } = "";
|
[Reactive] private string _host = "";
|
||||||
public ushort Port { get; set; } = 5432;
|
[Reactive] private ushort _port = 5432;
|
||||||
public string InitialDatabase
|
[Reactive] private string _initialDatabase = "";
|
||||||
|
|
||||||
|
[Reactive] private SslMode _sslMode = SslMode.Prefer;
|
||||||
|
|
||||||
|
// Explicit property wrapper to make compiled XAML binding findable
|
||||||
|
public SslMode DefaultSslMode
|
||||||
{
|
{
|
||||||
get => initialDatabase;
|
get => _sslMode;
|
||||||
set
|
set => this.RaiseAndSetIfChanged(ref _sslMode, value);
|
||||||
{
|
|
||||||
if (initialDatabase != value)
|
|
||||||
{
|
|
||||||
initialDatabase = value;
|
|
||||||
this.RaisePropertyChanged();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
public SslMode DefaultSslMode { get; set; } = SslMode.Prefer;
|
|
||||||
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
|
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
|
||||||
|
|
||||||
public ServerUser User { get; set; } = new();
|
public ServerUser User { get; set; } = new();
|
||||||
|
|
|
||||||
|
|
@ -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.Model;
|
||||||
|
using pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
using pgLabII.Services;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
|
|
||||||
namespace pgLabII.ViewModels;
|
namespace pgLabII.ViewModels;
|
||||||
|
|
@ -8,19 +14,86 @@ public class EditServerConfigurationViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public ServerConfiguration Configuration { get; set; }
|
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<Unit, Unit> ParseConnectionStringCommand { get; }
|
||||||
|
public ReactiveCommand<Unit, Unit> GenerateConnectionStringCommand { get; }
|
||||||
|
public ReactiveCommand<Unit, Unit> CopyOutputConnectionStringCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
|
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
|
||||||
public ReactiveCommand<Unit, Unit> CloseCommand { get; }
|
public ReactiveCommand<Unit, Unit> CloseCommand { get; }
|
||||||
|
|
||||||
|
private readonly IConnectionStringService _service;
|
||||||
|
|
||||||
public EditServerConfigurationViewModel()
|
public EditServerConfigurationViewModel()
|
||||||
{
|
{
|
||||||
Configuration = new();
|
Configuration = new();
|
||||||
|
_service = ConnectionStringService.CreateDefault();
|
||||||
|
|
||||||
SaveAndCloseCommand = ReactiveCommand.Create(() =>
|
ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
|
||||||
{
|
GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
|
||||||
});
|
CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
|
||||||
CloseCommand = ReactiveCommand.Create(() =>
|
|
||||||
{
|
SaveAndCloseCommand = ReactiveCommand.Create(() => { });
|
||||||
});
|
CloseCommand = ReactiveCommand.Create(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
public EditServerConfigurationViewModel(ServerConfiguration configuration)
|
public EditServerConfigurationViewModel(ServerConfiguration configuration)
|
||||||
|
|
@ -29,5 +102,69 @@ public class EditServerConfigurationViewModel : ViewModelBase
|
||||||
Configuration = configuration;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public class ServerListViewModel : ViewModelBase
|
||||||
ColorEnabled = true,
|
ColorEnabled = true,
|
||||||
Host = "localhost",
|
Host = "localhost",
|
||||||
Port = 5434,
|
Port = 5434,
|
||||||
|
InitialDatabase = "dbname",
|
||||||
User = new ()
|
User = new ()
|
||||||
{
|
{
|
||||||
Name = "postgres",
|
Name = "postgres",
|
||||||
|
|
@ -46,8 +47,8 @@ public class ServerListViewModel : ViewModelBase
|
||||||
|
|
||||||
AddServerCommand = ReactiveCommand.Create(() =>
|
AddServerCommand = ReactiveCommand.Create(() =>
|
||||||
{
|
{
|
||||||
ServerConfiguration sc = new();
|
EditServerConfigurationViewModel vm = new();
|
||||||
EditServerConfigurationWindow window = new() { DataContext = sc, New = true };
|
EditServerConfigurationWindow window = new() { DataContext = vm, New = true };
|
||||||
window.Show();
|
window.Show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,89 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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"
|
xmlns:vm="clr-namespace:pgLabII.ViewModels"
|
||||||
x:DataType="vm:EditServerConfigurationViewModel"
|
x:DataType="vm:EditServerConfigurationViewModel"
|
||||||
x:Class="pgLabII.Views.EditServerConfigurationWindow"
|
x:Class="pgLabII.Views.EditServerConfigurationWindow"
|
||||||
Title="Edit Server Configuration"
|
Title="Edit Server Configuration"
|
||||||
SizeToContent="WidthAndHeight">
|
SizeToContent="WidthAndHeight">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
|
||||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
|
||||||
<vm:EditServerConfigurationViewModel />
|
<vm:EditServerConfigurationViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<StackPanel >
|
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*">
|
||||||
<TextBlock>Name:</TextBlock>
|
<!-- Basic Details -->
|
||||||
<TextBox Text="{Binding Configuration.Name}"/>
|
<StackPanel Grid.Row="0" Spacing="6">
|
||||||
|
<TextBlock FontWeight="Bold" Text="Details" Margin="0,0,0,4"/>
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Name"/>
|
||||||
|
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Configuration.Name}"/>
|
||||||
|
|
||||||
<TextBlock>Color:</TextBlock>
|
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Color"/>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||||
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
|
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
|
||||||
<ColorPicker Color="{Binding Configuration.Color}"
|
<ColorPicker Color="{Binding Configuration.Color}"/>
|
||||||
/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock>Host:</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Database"/>
|
||||||
<TextBox Text="{Binding Configuration.Host}"/>
|
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Configuration.InitialDatabase}"/>
|
||||||
|
|
||||||
<TextBlock>Port:</TextBlock>
|
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="SSL Mode"/>
|
||||||
<NumericUpDown Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
|
<ComboBox Grid.Row="3" Grid.Column="1" SelectedIndex="{Binding Configuration.DefaultSslMode}">
|
||||||
|
<!-- Order must match Npgsql.SslMode enum: Disable(0), Allow(1), Prefer(2), Require(3), VerifyCA(4), VerifyFull(5) -->
|
||||||
|
<ComboBoxItem>Disable</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Allow</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Prefer</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Require</ComboBoxItem>
|
||||||
|
<ComboBoxItem>VerifyCA</ComboBoxItem>
|
||||||
|
<ComboBoxItem>VerifyFull</ComboBoxItem>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
<TextBlock>Database:</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Host"/>
|
||||||
<TextBox Text="{Binding Configuration.InitialDatabase}"/>
|
<TextBox Grid.Row="0" Grid.Column="3" Text="{Binding Configuration.Host}"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Port"/>
|
||||||
|
<NumericUpDown Grid.Row="1" Grid.Column="3" Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Input Connection String -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||||
|
<TextBlock FontWeight="Bold" Text="Connection string input"/>
|
||||||
|
<TextBlock Text="Paste an existing connection string (libpq, Npgsql, or URL)."/>
|
||||||
|
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" Text="{Binding InputConnectionString}"/>
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Interpret as"/>
|
||||||
|
<!-- Bind SelectedIndex to enum (Auto=0, Libpq=1, Npgsql=2, Url=3) -->
|
||||||
|
<ComboBox Grid.Column="1" SelectedIndex="{Binding ForcedFormat}">
|
||||||
|
<ComboBoxItem>Auto</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Libpq</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Npgsql</ComboBoxItem>
|
||||||
|
<ComboBoxItem>URL</ComboBoxItem>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Grid.Column="2" Margin="12,0,8,0" VerticalAlignment="Center" Text="Detected:"/>
|
||||||
|
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{Binding DetectedFormat}"/>
|
||||||
|
<Button Grid.Column="4" Margin="12,0,0,0" Content="Parse into fields" Command="{Binding ParseConnectionStringCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Output Connection String -->
|
||||||
|
<StackPanel Grid.Row="2" Margin="0,12,0,0" Spacing="6">
|
||||||
|
<TextBlock FontWeight="Bold" Text="Connection string output"/>
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Format"/>
|
||||||
|
<!-- ConnStringFormat: Libpq=0, Npgsql=1, Url=2 -->
|
||||||
|
<ComboBox Grid.Column="1" SelectedIndex="{Binding OutputFormat}">
|
||||||
|
<ComboBoxItem>Libpq</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Npgsql</ComboBoxItem>
|
||||||
|
<ComboBoxItem>URL</ComboBoxItem>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Grid.Column="2" Margin="12,0,0,0" Content="Generate" Command="{Binding GenerateConnectionStringCommand}"/>
|
||||||
|
<Button Grid.Column="3" Margin="6,0,0,0" Content="Copy" Command="{Binding CopyOutputConnectionStringCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" IsReadOnly="True" Text="{Binding OutputConnectionString}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Spacer / Future buttons row could go here -->
|
||||||
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue