diff --git a/.ai-guidelines.md b/.ai-guidelines.md
index 82b8c08..b7e1d1d 100644
--- a/.ai-guidelines.md
+++ b/.ai-guidelines.md
@@ -1,7 +1,8 @@
# pgLabII AI Assistant Guidelines
## Project Context
-This is a .NET 8/C# 13 Avalonia cross-platform application for document management.
+This is a .NET 9/C# 14 Avalonia cross-platform application for querying and inspecting
+postgresql databases. It should also be a good editor for SQL files.
### Architecture Overview
- **Main Project**: pgLabII (Avalonia UI)
@@ -12,16 +13,18 @@ This is a .NET 8/C# 13 Avalonia cross-platform application for document manageme
## Coding Standards
### C# Guidelines
-- Use C# 13 features and modern .NET patterns
+- Use C# 14 features and modern .NET patterns
- Prefer primary constructors for dependency injection
- Use `var` for obvious types, explicit types for clarity
- Implement proper async/await patterns for I/O operations
- Use nullable reference types consistently
- Prefer "target-typed new"
- Prefer "list initializer" over new
+- Package versions are managed centrally in Directory.Packages.props
### Avalonia-Specific
- Follow MVVM pattern strictly
+- x:Name does work for making components accessible in code-behind
- ViewModels should inherit from appropriate base classes
- Use ReactiveUI patterns where applicable
- Make use of annotations to generate bindable properties
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.Desktop/pgLabII.Desktop.csproj b/pgLabII.Desktop/pgLabII.Desktop.csproj
index 9002229..e989c75 100644
--- a/pgLabII.Desktop/pgLabII.Desktop.csproj
+++ b/pgLabII.Desktop/pgLabII.Desktop.csproj
@@ -20,6 +20,7 @@
None
All
+
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
index 4342c08..5674377 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
@@ -14,6 +14,14 @@ public class ConnectionStringServiceTests
Assert.Equal(ConnStringFormat.Url, r.Value);
}
+ [Fact]
+ public void DetectFormat_Jdbc()
+ {
+ var r = svc.DetectFormat("jdbc:postgresql://localhost/db");
+ Assert.True(r.IsSuccess);
+ Assert.Equal(ConnStringFormat.Jdbc, r.Value);
+ }
+
[Fact]
public void DetectFormat_Npgsql()
{
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs
new file mode 100644
index 0000000..303d5da
--- /dev/null
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs
@@ -0,0 +1,29 @@
+using System.Data.Common;
+using Npgsql;
+
+namespace pgLabII.PgUtils.Tests.ConnectionStrings;
+
+public class DbConnectionStringBuilderTests
+{
+ [Theory]
+ [InlineData("abc", "abc")]
+ [InlineData(" abc ", "abc")]
+ [InlineData("\"abc \"", "abc ")]
+ public void TestDecode(string input, string expected)
+ {
+ DbConnectionStringBuilder sb = new() { ConnectionString = $"key={input}" };
+ string result = (string)sb["key"];
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("abc", "key=abc")]
+ [InlineData("abc ", "key=\"abc \"")]
+ [InlineData("a\"c", "key='a\"c'")]
+ public void TestEncode(string input, string expected)
+ {
+ DbConnectionStringBuilder sb = new();
+ sb["key"] = input;
+ Assert.Equal(expected, sb.ConnectionString);
+ }
+}
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs
new file mode 100644
index 0000000..5bf2e1c
--- /dev/null
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs
@@ -0,0 +1,55 @@
+using pgLabII.PgUtils.ConnectionStrings;
+
+namespace pgLabII.PgUtils.Tests.ConnectionStrings;
+
+public class JdbcCodecTests
+{
+ [Fact]
+ public void Parse_Basic()
+ {
+ var codec = new JdbcCodec();
+ var r = codec.TryParse("jdbc:postgresql://localhost:5433/mydb?sslmode=require&applicationName=app&connectTimeout=12");
+ Assert.True(r.IsSuccess);
+ var d = r.Value;
+ Assert.Single(d.Hosts);
+ Assert.Equal("localhost", d.Hosts[0].Host);
+ Assert.Equal((ushort)5433, d.Hosts[0].Port);
+ Assert.Equal("mydb", d.Database);
+ Assert.Equal(Npgsql.SslMode.Require, d.SslMode);
+ Assert.Equal("app", d.ApplicationName);
+ Assert.Equal(12, d.TimeoutSeconds);
+ }
+
+ [Fact]
+ public void Parse_MultiHost_MixedPorts()
+ {
+ var codec = new JdbcCodec();
+ var r = codec.TryParse("jdbc:postgresql://host1:5432,[::1]:5544,host3/db");
+ Assert.True(r.IsSuccess);
+ var d = r.Value;
+ Assert.Equal(3, d.Hosts.Count);
+ Assert.Equal("host1", d.Hosts[0].Host);
+ Assert.Equal((ushort)5432, d.Hosts[0].Port);
+ Assert.Equal("::1", d.Hosts[1].Host);
+ Assert.Equal((ushort)5544, d.Hosts[1].Port);
+ Assert.Equal("host3", d.Hosts[2].Host);
+ Assert.Null(d.Hosts[2].Port);
+ Assert.Equal("db", d.Database);
+ }
+
+ [Fact]
+ public void Format_RoundTrip()
+ {
+ var codec = new JdbcCodec();
+ var parsed = codec.TryParse("jdbc:postgresql://hostA,hostB:5555/test_db?applicationName=cli¶m=x%20y");
+ Assert.True(parsed.IsSuccess);
+ var formatted = codec.TryFormat(parsed.Value);
+ Assert.True(formatted.IsSuccess);
+ var parsed2 = codec.TryParse(formatted.Value);
+ Assert.True(parsed2.IsSuccess);
+ Assert.Equal(parsed.Value.Hosts.Count, parsed2.Value.Hosts.Count);
+ Assert.Equal(parsed.Value.Database, parsed2.Value.Database);
+ Assert.Equal("cli", parsed2.Value.ApplicationName);
+ Assert.Equal("x y", parsed2.Value.Properties["param"]);
+ }
+}
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs
index 5f29a89..11fa216 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/NpgsqlCodecTests.cs
@@ -46,7 +46,7 @@ public class NpgsqlCodecTests
{
Hosts = new [] { new HostEndpoint{ Host = "db.example.com", Port = 5432 } },
Database = "prod db",
- Username = "bob",
+ Username = "bob ",
Password = "p;ss\"word",
SslMode = SslMode.VerifyFull,
ApplicationName = "cli app",
@@ -58,11 +58,12 @@ public class NpgsqlCodecTests
var s = res.Value;
Assert.Contains("Host=db.example.com", s);
Assert.Contains("Port=5432", s);
- Assert.Contains("Database=\"prod db\"", s);
- Assert.Contains("Username=bob", s);
- Assert.Contains("Password=\"p;ss\"\"word\"", s);
+ Assert.Contains("Database=prod db", s);
+ Assert.Contains("Username='bob '", s);
+ // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior
+ Assert.Contains("Password='p;ss" + '"' + "word'", s);
Assert.Contains("SSL Mode=VerifyFull", s);
- Assert.Contains("Application Name=\"cli app\"", s);
+ Assert.Contains("Application Name=cli app", s);
Assert.Contains("Timeout=9", s);
Assert.Contains("Search Path=public", s);
}
@@ -77,11 +78,12 @@ public class NpgsqlCodecTests
var formatted = codec.TryFormat(parsed.Value);
Assert.True(formatted.IsSuccess);
var s = formatted.Value;
- Assert.Contains("Host=\"my host\"", s);
+ Assert.Contains("Host=my host", s);
Assert.Contains("Database=postgres", s);
Assert.Contains("Username=me", s);
- Assert.Contains("Password=\"with;quote\"\"\"", s);
- Assert.Contains("Application Name=\"my app\"", s);
+ // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior; parsed value contains one double-quote
+ Assert.Contains("Password='with;quote" + '"' + "'", s);
+ Assert.Contains("Application Name=my app", s);
Assert.Contains("SSL Mode=Prefer", s);
}
}
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs
index d953ffc..37c3f11 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs
@@ -1,4 +1,6 @@
-using pgLabII.PgUtils.ConnectionStrings;
+using FluentResults;
+using pgLabII.PgUtils.ConnectionStrings;
+using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
@@ -22,20 +24,25 @@ public class PqConnectionStringParserTests
public void Success()
{
var parser = new PqConnectionStringParser(tokenizer);
- IDictionary output = parser.Parse();
-
- Assert.Single(output);
- Assert.True(output.TryGetValue(kw, out string? result));
- Assert.Equal(val, result);
+ Result> output = parser.Parse();
+ ResultAssert.Success(output, v =>
+ {
+ Assert.Single(v);
+ Assert.True(v.TryGetValue(kw, out string? result));
+ Assert.Equal(val, result);
+ });
}
[Fact]
public void StaticParse()
{
- var output = PqConnectionStringParser.Parse("foo=bar");
- Assert.Single(output);
- Assert.True(output.TryGetValue("foo", out string? result));
- Assert.Equal("bar", result);
+ Result> output = PqConnectionStringParser.Parse("foo=bar");
+ ResultAssert.Success(output, v =>
+ {
+ Assert.Single(v);
+ Assert.True(v.TryGetValue("foo", out string? result));
+ Assert.Equal("bar", result);
+ });
}
// There are few tests here as this is a predictive parser and all error handling is done
// in the tokenizer
diff --git a/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs b/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs
new file mode 100644
index 0000000..bef3c04
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using Npgsql;
+
+namespace pgLabII.PgUtils.ConnectionStrings;
+
+///
+/// Shared helper utilities for codecs to reduce duplication (SSL mode mapping, host:port parsing/formatting,
+/// URL query parsing, and .NET/libpq quoting helpers).
+///
+internal static class CodecCommon
+{
+ // SSL mapping
+ public static SslMode ParseSslModeLoose(string s)
+ => s.Trim().ToLowerInvariant() switch
+ {
+ "disable" => SslMode.Disable,
+ "allow" => SslMode.Allow,
+ "prefer" => SslMode.Prefer,
+ "require" => SslMode.Require,
+ "verify-ca" or "verifyca" => SslMode.VerifyCA,
+ "verify-full" or "verifyfull" => SslMode.VerifyFull,
+ _ => throw new ArgumentException($"Not a valid SSL Mode: {s}")
+ };
+
+ public static string FormatSslModeUrlLike(SslMode mode) => mode switch
+ {
+ SslMode.Disable => "disable",
+ SslMode.Allow => "allow",
+ SslMode.Prefer => "prefer",
+ SslMode.Require => "require",
+ SslMode.VerifyCA => "verify-ca",
+ SslMode.VerifyFull => "verify-full",
+ _ => "prefer"
+ };
+
+
+ // host:port parsing for plain or [IPv6]:port
+ public static void ParseHostPort(string hostPart, out string host, out ushort? port)
+ {
+ host = hostPart;
+ port = null;
+ if (string.IsNullOrWhiteSpace(hostPart))
+ return;
+
+ if (hostPart[0] == '[')
+ {
+ int end = hostPart.IndexOf(']');
+ if (end > 0)
+ {
+ host = hostPart[1..end];
+ if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
+ {
+ string ps = hostPart[(end + 2)..];
+ if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
+ port = p;
+ }
+ }
+ return;
+ }
+ 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;
+ }
+ }
+ }
+
+ public static string FormatHost(HostEndpoint h)
+ {
+ var host = h.Host;
+ if (host.Contains(':') && !host.StartsWith("["))
+ host = "[" + host + "]"; // IPv6
+
+ return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host;
+ }
+
+ public static string[] SplitHosts(string hostList)
+ => hostList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ public static Dictionary ParseQuery(string query)
+ {
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrEmpty(query)) return dict;
+ foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var parts = kv.Split('=', 2);
+ var key = Uri.UnescapeDataString(parts[0]);
+ var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
+ dict[key] = val;
+ }
+ return dict;
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs
index f8dfe8c..3b39478 100644
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs
@@ -8,8 +8,6 @@ namespace pgLabII.PgUtils.ConnectionStrings;
///
public sealed class ConnectionDescriptor
{
- public string? Name { get; init; }
-
// Primary hosts (support multi-host). If empty, implies localhost default.
public IReadOnlyList Hosts { get; init; } = new List();
@@ -26,4 +24,4 @@ public sealed class ConnectionDescriptor
// Additional parameters preserved across conversions
public IReadOnlyDictionary Properties { get; init; } =
new Dictionary();
-}
\ No newline at end of file
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs
new file mode 100644
index 0000000..86f2703
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs
@@ -0,0 +1,36 @@
+using Npgsql;
+
+namespace pgLabII.PgUtils.ConnectionStrings;
+
+public sealed class ConnectionDescriptorBuilder
+{
+ private List Hosts { get; } = [];
+ public string? Database { get; set; }
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+ public SslMode? SslMode { get; set; }
+ public string? ApplicationName { get; set; }
+ public int? TimeoutSeconds { get; set; }
+ public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ public void AddHost(string host, ushort? port)
+ {
+ if (string.IsNullOrWhiteSpace(host)) return;
+ Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
+ }
+
+ public ConnectionDescriptor Build()
+ {
+ return new ConnectionDescriptor
+ {
+ Hosts = Hosts,
+ Database = Database,
+ Username = Username,
+ Password = Password,
+ SslMode = SslMode,
+ ApplicationName = ApplicationName,
+ TimeoutSeconds = TimeoutSeconds,
+ Properties = Properties
+ };
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
index 11e69b3..b715866 100644
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
@@ -21,18 +21,22 @@ public sealed class ConnectionStringService : IConnectionStringService
}
///
- /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
+ /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url, Jdbc).
///
public static ConnectionStringService CreateDefault()
- => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
+ => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec(), new JdbcCodec() });
public Result DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail("Empty input");
- // URL: postgresql:// or postgres://
+ // URL: postgresql:// or postgres:// or JDBC jdbc:postgresql://
var trimmed = input.TrimStart();
+ if (trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
+ {
+ return Result.Ok(ConnStringFormat.Jdbc);
+ }
if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
{
diff --git a/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
new file mode 100644
index 0000000..1274f9c
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using FluentResults;
+using Npgsql;
+
+namespace pgLabII.PgUtils.ConnectionStrings;
+
+///
+/// Codec for JDBC PostgreSQL URLs: jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&...
+/// - Supports multiple hosts with optional per-host ports, IPv6 bracketed literals.
+/// - Percent-decodes database and query param values on parse; encodes on format.
+/// - Recognizes sslmode/ssl, applicationName, loginTimeout/connectTimeout and maps to descriptor.
+/// - Preserves unrecognized parameters in Properties.
+///
+public sealed class JdbcCodec : IConnectionStringCodec
+{
+ public ConnStringFormat Format => ConnStringFormat.Jdbc;
+ public string FormatName => "JDBC";
+
+ public Result TryParse(string input)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return Result.Fail("Empty JDBC url");
+ var trimmed = input.Trim();
+ if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
+ return Result.Fail("JDBC url must start with jdbc:postgresql://");
+
+ // Strip scheme
+ var rest = trimmed.Substring("jdbc:postgresql://".Length);
+
+ // Split authority and path+query
+ string authority = rest;
+ string pathAndQuery = string.Empty;
+ var slashIdx = rest.IndexOf('/');
+ if (slashIdx >= 0)
+ {
+ authority = rest.Substring(0, slashIdx);
+ pathAndQuery = rest.Substring(slashIdx); // includes '/'
+ }
+
+ var builder = new ConnectionDescriptorBuilder();
+
+ // Parse hosts (comma separated)
+ foreach (string part in CodecCommon.SplitHosts(authority))
+ {
+ CodecCommon.ParseHostPort(part, out var host, out ushort? port);
+ if (!string.IsNullOrEmpty(host))
+ builder.AddHost(host!, port);
+ }
+
+ // Parse database and query
+ string? database = null;
+ string query = string.Empty;
+ if (!string.IsNullOrEmpty(pathAndQuery))
+ {
+ int qIdx = pathAndQuery.IndexOf('?');
+ string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
+ query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
+ if (path.Length > 0)
+ {
+ if (path[0] == '/') path = path.Substring(1);
+ if (path.Length > 0)
+ database = Uri.UnescapeDataString(path);
+ }
+ }
+ if (!string.IsNullOrEmpty(database)) builder.Database = database;
+
+ var queryDict = CodecCommon.ParseQuery(query);
+
+ // Map known properties
+ if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl"))
+ builder.SslMode = CodecCommon.ParseSslModeLoose(ssl);
+ if (TryFirst(queryDict, out string? app, "applicationName", "application_name"))
+ builder.ApplicationName = app;
+ if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout"))
+ {
+ if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t))
+ builder.TimeoutSeconds = t;
+ }
+
+ // Preserve extras
+ var mapped = new HashSet(["sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout"
+ ], StringComparer.OrdinalIgnoreCase);
+ foreach (var kv in queryDict)
+ {
+ if (!mapped.Contains(kv.Key))
+ builder.Properties[kv.Key] = kv.Value;
+ }
+
+ return Result.Ok(builder.Build());
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail(ex.Message);
+ }
+ }
+
+ public Result TryFormat(ConnectionDescriptor descriptor)
+ {
+ try
+ {
+ var sb = new StringBuilder();
+ sb.Append("jdbc:postgresql://");
+
+ if (descriptor.Hosts.Count > 0)
+ {
+ sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost)));
+ }
+
+ // Path with database
+ if (!string.IsNullOrEmpty(descriptor.Database))
+ {
+ sb.Append('/');
+ sb.Append(Uri.EscapeDataString(descriptor.Database));
+ }
+
+ // Query parameters
+ var qp = new List<(string k, string v)>();
+ if (descriptor.SslMode.HasValue)
+ {
+ qp.Add(("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
+ }
+ if (!string.IsNullOrEmpty(descriptor.ApplicationName))
+ {
+ qp.Add(("applicationName", descriptor.ApplicationName));
+ }
+ if (descriptor.TimeoutSeconds.HasValue)
+ {
+ qp.Add(("connectTimeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ // Add extras not already present
+ var emitted = new HashSet(qp.Select(x => x.k), StringComparer.OrdinalIgnoreCase);
+ foreach (var kv in descriptor.Properties)
+ {
+ if (!emitted.Contains(kv.Key))
+ qp.Add((kv.Key, kv.Value));
+ }
+
+ if (qp.Count > 0)
+ {
+ sb.Append('?');
+ sb.Append(string.Join('&', qp.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v ?? string.Empty))));
+ }
+
+ return Result.Ok(sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail(ex.Message);
+ }
+ }
+
+ private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h);
+
+ private static bool TryFirst(
+ Dictionary dict,
+ [MaybeNullWhen(false)] out string value,
+ params string[] keys)
+ {
+ foreach (string k in keys)
+ {
+ if (dict.TryGetValue(k, out value))
+ return true;
+ }
+ value = string.Empty;
+ return false;
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
index 7aa6519..ba67313 100644
--- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
@@ -1,7 +1,6 @@
-using System;
-using System.Collections.Generic;
+using System.Collections;
+using System.Data.Common;
using System.Globalization;
-using System.Linq;
using System.Text;
using FluentResults;
using Npgsql;
@@ -9,19 +8,7 @@ using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
///
-/// Parser/formatter for Npgsql-style .NET connection strings. We intentionally do not
-/// rely on NpgsqlConnectionStringBuilder here because:
-/// - We need a lossless, format-agnostic round-trip to our ConnectionDescriptor, including
-/// unknown/extension keys and per-host port lists. NpgsqlConnectionStringBuilder normalizes
-/// names, may drop unknown keys or coerce values, which breaks lossless conversions.
-/// - We support multi-host with per-host ports and want to preserve the original textual
-/// representation across conversions. The builder flattens/rewrites these details.
-/// - We aim to keep pgLabII.PgUtils independent from Npgsql's evolving parsing rules and
-/// version-specific behaviors to ensure stable UX and deterministic tests.
-/// - We need symmetric formatting matching our other codecs (libpq/URL/JDBC) and consistent
-/// quoting rules across formats.
-/// If required, we still reference Npgsql for enums and interop types, but parsing/formatting
-/// is done by this small, well-tested custom codec for full control and stability.
+/// Parser/formatter for Npgsql-style .NET connection strings.
///
public sealed class NpgsqlCodec : IConnectionStringCodec
{
@@ -38,30 +25,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);
}
}
@@ -107,16 +106,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
{
try
{
- var parts = new List();
+ var parts = new DbConnectionStringBuilder();
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
- parts.Add(FormatPair("Host", hostList));
+ parts["Host"] = hostList;
var ports = descriptor.Hosts.Select(h => h.Port).Where(p => p.HasValue).Select(p => p!.Value).Distinct().ToList();
if (ports.Count == 1)
{
- parts.Add(FormatPair("Port", ports[0].ToString(CultureInfo.InvariantCulture)));
+ parts["Port"] = ports[0].ToString(CultureInfo.InvariantCulture);
}
else if (ports.Count == 0)
{
@@ -127,31 +126,24 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Per-host ports if provided 1:1
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
if (perHost.All(s => !string.IsNullOrEmpty(s)))
- parts.Add(FormatPair("Port", string.Join(',', perHost)));
+ parts["Port"] = string.Join(',', perHost);
}
}
if (!string.IsNullOrEmpty(descriptor.Database))
- parts.Add(FormatPair("Database", descriptor.Database));
+ parts["Database"] = descriptor.Database;
if (!string.IsNullOrEmpty(descriptor.Username))
- parts.Add(FormatPair("Username", descriptor.Username));
+ parts["Username"] = descriptor.Username;
if (!string.IsNullOrEmpty(descriptor.Password))
- parts.Add(FormatPair("Password", descriptor.Password));
+ parts["Password"] = descriptor.Password;
if (descriptor.SslMode.HasValue)
- parts.Add(FormatPair("SSL Mode", FormatSslMode(descriptor.SslMode.Value)));
+ parts["SSL Mode"] = FormatSslMode(descriptor.SslMode.Value);
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
- parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
+ parts["Application Name"] = descriptor.ApplicationName;
if (descriptor.TimeoutSeconds.HasValue)
- parts.Add(FormatPair("Timeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
+ parts["Timeout"] = descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture);
- var emittedKeys = new HashSet(parts.Select(p => p.Split('=')[0].Trim()), StringComparer.OrdinalIgnoreCase);
- foreach (var kv in descriptor.Properties)
- {
- if (!emittedKeys.Contains(kv.Key))
- parts.Add(FormatPair(kv.Key, kv.Value));
- }
-
- return Result.Ok(string.Join(";", parts));
+ return Result.Ok(parts.ConnectionString);
}
catch (Exception ex)
{
@@ -164,6 +156,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)
@@ -174,158 +202,25 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return false;
}
- private static SslMode ParseSslMode(string s)
- {
- switch (s.Trim().ToLowerInvariant())
- {
- case "disable": return SslMode.Disable;
- case "allow": return SslMode.Allow;
- case "prefer": return SslMode.Prefer;
- case "require": return SslMode.Require;
- case "verify-ca":
- case "verifyca": return SslMode.VerifyCA;
- case "verify-full":
- case "verifyfull": return SslMode.VerifyFull;
- default: throw new ArgumentException($"Not a valid SSL Mode: {s}");
- }
- }
+ private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s);
- private static string FormatSslMode(SslMode mode)
+ private static string FormatSslMode(SslMode mode) => mode switch
{
- return mode switch
- {
- SslMode.Disable => "Disable",
- SslMode.Allow => "Allow",
- SslMode.Prefer => "Prefer",
- SslMode.Require => "Require",
- SslMode.VerifyCA => "VerifyCA",
- SslMode.VerifyFull => "VerifyFull",
- _ => "Prefer"
- };
- }
-
- // Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled
- private static string FormatPair(string key, string? value)
- {
- value ??= string.Empty;
- var needsQuotes = NeedsQuoting(value);
- if (!needsQuotes) return key + "=" + value;
- return key + "=\"" + EscapeQuoted(value) + "\"";
- }
-
- private static bool NeedsQuoting(string value)
- {
- if (value.Length == 0) return true;
- foreach (var c in value)
- {
- if (char.IsWhiteSpace(c) || c == ';' || c == '=' || c == '"') return true;
- }
- return false;
- }
-
- private static string EscapeQuoted(string value)
- {
- // Double the quotes per standard DbConnectionString rules
- return value.Replace("\"", "\"\"");
- }
+ SslMode.Disable => "Disable",
+ SslMode.Allow => "Allow",
+ SslMode.Prefer => "Prefer",
+ SslMode.Require => "Require",
+ SslMode.VerifyCA => "VerifyCA",
+ SslMode.VerifyFull => "VerifyFull",
+ _ => "Prefer"
+ };
private static Dictionary Tokenize(string input)
{
- // Simple tokenizer for .NET connection strings: key=value pairs separated by semicolons; values may be quoted with double quotes
+ DbConnectionStringBuilder db = new() { ConnectionString = input };
var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- int i = 0;
- void SkipWs() { while (i < input.Length && char.IsWhiteSpace(input[i])) i++; }
-
- while (true)
- {
- SkipWs();
- if (i >= input.Length) break;
-
- // read key
- int keyStart = i;
- while (i < input.Length && input[i] != '=') i++;
- if (i >= input.Length) { break; }
- var key = input.Substring(keyStart, i - keyStart).Trim();
- i++; // skip '='
- SkipWs();
-
- // read value
- string value;
- if (i < input.Length && input[i] == '"')
- {
- i++; // skip opening quote
- var sb = new StringBuilder();
- while (i < input.Length)
- {
- char c = input[i++];
- if (c == '"')
- {
- if (i < input.Length && input[i] == '"')
- {
- // doubled quote -> literal quote
- sb.Append('"');
- i++;
- continue;
- }
- else
- {
- break; // end quoted value
- }
- }
- else
- {
- sb.Append(c);
- }
- }
- value = sb.ToString();
- }
- else
- {
- int valStart = i;
- while (i < input.Length && input[i] != ';') i++;
- value = input.Substring(valStart, i - valStart).Trim();
- }
-
- dict[key] = value;
-
- // skip to next, if ; present, consume one
- while (i < input.Length && input[i] != ';') i++;
- if (i < input.Length && input[i] == ';') i++;
- }
-
+ foreach (string k in db.Keys)
+ dict.Add(k, (string)db[k]);
return dict;
}
-
- private sealed class ConnectionDescriptorBuilder
- {
- public List Hosts { get; } = new();
- public string? Database { get; set; }
- public string? Username { get; set; }
- public string? Password { get; set; }
- public SslMode? SslMode { get; set; }
- public string? ApplicationName { get; set; }
- public int? TimeoutSeconds { get; set; }
- public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
-
- public void AddHost(string host, ushort? port)
- {
- if (string.IsNullOrWhiteSpace(host)) return;
- Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
- }
-
- public ConnectionDescriptor Build()
- {
- return new ConnectionDescriptor
- {
- Hosts = Hosts,
- Database = Database,
- Username = Username,
- Password = Password,
- SslMode = SslMode,
- ApplicationName = ApplicationName,
- TimeoutSeconds = TimeoutSeconds,
- Properties = Properties
- };
- }
- }
}
diff --git a/pgLabII.PgUtils/ConnectionStrings/PLAN.md b/pgLabII.PgUtils/ConnectionStrings/PLAN.md
deleted file mode 100644
index b30d7fa..0000000
--- a/pgLabII.PgUtils/ConnectionStrings/PLAN.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Connection Strings Plan
-
-This document tracks the plan for supporting multiple PostgreSQL connection string formats, converting between them, and mapping to/from a canonical model.
-
-## Current Status (2025-08-30)
-
-Implemented:
-- Abstractions: `ConnStringFormat`, `HostEndpoint`, `ConnectionDescriptor`, `IConnectionStringCodec`, `IConnectionStringService`.
-- Codecs:
- - `LibpqCodec` (libpq): parse/format; multi-host; `sslmode`, `application_name`, `connect_timeout`; quoting/escaping; preserves extras.
- - `NpgsqlCodec` (.NET/Npgsql): parse/format; alias recognition; multi-host with single or per-host ports; `SSL Mode`, `Application Name`, `Timeout`; double-quote rules; preserves extras.
-- Tests for both codecs: parse, format, round-trip, edge quoting.
-
-Not yet implemented:
-- URL (postgresql://) codec ✓
-- JDBC (jdbc:postgresql://) codec
-- Composite `ConnectionStringService` (detect + convert) ✓
-- Mapping helpers to/from `ServerConfiguration` ✓
-
-## Updated Plan
-
-1. Define canonical model and interfaces for connection strings. ✓
-2. Establish normalization strategy for parameter aliases and extra `Properties` handling. ✓
-3. Implement format-specific codecs:
- - libpq codec (parse/format; multi-host, quoting, sslmode, timeout, extras). ✓
- - Npgsql codec (parse/format; aliases, multi-host/ports, quoting, ssl mode, timeout, extras). ✓
- - URL (postgresql://) codec (parse/format; userinfo, host[:port], db, query params, percent-encoding). ✓
- - JDBC (jdbc:postgresql://) codec (parse/format; hosts, ports, db, properties; URL-like semantics).
-4. Composite conversion service:
- - Implement `ConnectionStringService` composing codecs, detecting formats, converting via `ConnectionDescriptor`, and resolving alias priorities.
-5. Mapping with application model:
- - Add mapping utilities between `ConnectionDescriptor` and `ServerConfiguration` (primary host/port, db, SSL mode), with sensible defaults.
-6. Validation and UX:
- - Validation for malformed inputs & edge cases (mismatched host/port counts, invalid SSL mode, missing db/host, IPv6 bracket handling).
- - Ensure sensitive fields (password) are masked in logs/preview.
-7. Tests:
- - Unit tests for URL and JDBC codecs; composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding).
-8. Documentation:
- - Keep this plan updated and enrich XML docs on codecs/service including alias mappings and quoting/escaping rules per format.
-
-## Next Small Step
-
-Implement the URL (postgresql://) codec with unit tests. Scope:
-- Parse: `postgresql://[user[:password]@]host1[:port1][,hostN[:portN]]/[database]?param=value&...`
- - Support percent-decoding for user, password, database, and query values.
- - Handle IPv6 literals in `[::1]` form; allow multiple hosts with optional per-host ports.
- - Map common params: `sslmode`, `application_name`, `connect_timeout` and preserve other query params in `Properties`.
-- Format: Build a URL using percent-encoding where required; emit multi-hosts and parameters from `Properties` not already emitted.
-- Tests: basic parse/format, quoting/percent-encoding, multi-host with mixed ports, round-trips.
-
-After that, implement the composite `ConnectionStringService` to detect/convert across libpq, Npgsql, and URL formats.
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
index 7e4a3dc..fe48afc 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
@@ -1,9 +1,5 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
+using System.Text;
using FluentResults;
-using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@@ -16,11 +12,13 @@ public sealed class LibpqCodec : IConnectionStringCodec
{
try
{
- var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
+ Result> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
+ if (kv.IsFailed)
+ return kv.ToResult();
// libpq keywords are case-insensitive; normalize to lower for lookup
var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var pair in kv)
+ foreach (var pair in kv.Value)
dict[pair.Key] = pair.Value;
var descriptor = new ConnectionDescriptorBuilder();
@@ -28,7 +26,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
if (dict.TryGetValue("host", out var host))
{
// libpq supports host lists separated by commas
- var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ string[] hosts = CodecCommon.SplitHosts(host);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@@ -37,10 +35,10 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.AddHost(h, portForAll);
}
}
- if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
+ if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
{
- // If hostaddr is provided without host, include as host entries as well
- var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ // If hostaddr is provided without a host, include as host entries as well
+ string[] hosts = CodecCommon.SplitHosts(hostaddr);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@@ -58,7 +56,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.Password = pass;
if (dict.TryGetValue("sslmode", out var sslStr))
- descriptor.SslMode = ParseSslMode(sslStr);
+ descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr);
if (dict.TryGetValue("application_name", out var app))
descriptor.ApplicationName = app;
if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds))
@@ -90,7 +88,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
var parts = new List();
// Hosts and port
- if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
parts.Add(FormatPair("host", hostList));
@@ -107,7 +105,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
if (!string.IsNullOrEmpty(descriptor.Password))
parts.Add(FormatPair("password", descriptor.Password));
if (descriptor.SslMode.HasValue)
- parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value)));
+ parts.Add(FormatPair("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@@ -129,34 +127,6 @@ public sealed class LibpqCodec : IConnectionStringCodec
}
}
- private static SslMode ParseSslMode(string s)
- {
- return s.Trim().ToLowerInvariant() switch
- {
- "disable" => SslMode.Disable,
- "allow" => SslMode.Allow,
- "prefer" => SslMode.Prefer,
- "require" => SslMode.Require,
- "verify-ca" => SslMode.VerifyCA,
- "verify-full" => SslMode.VerifyFull,
- _ => throw new ArgumentException($"Not a valid SSL mode: {s}")
- };
- }
-
- private static string FormatSslMode(SslMode mode)
- {
- return mode switch
- {
- SslMode.Disable => "disable",
- SslMode.Allow => "allow",
- SslMode.Prefer => "prefer",
- SslMode.Require => "require",
- SslMode.VerifyCA => "verify-ca",
- SslMode.VerifyFull => "verify-full",
- _ => "prefer"
- };
- }
-
private static string FormatPair(string key, string? value)
{
value ??= string.Empty;
@@ -167,56 +137,17 @@ public sealed class LibpqCodec : IConnectionStringCodec
private static bool NeedsQuoting(string value)
{
- if (value.Length == 0) return true;
- foreach (var c in value)
- {
- if (char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\')
- return true;
- }
- return false;
+ return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\');
}
private static string EscapeValue(string value)
{
var sb = new StringBuilder();
- foreach (var c in value)
+ foreach (char c in value)
{
if (c == '\'' || c == '\\') sb.Append('\\');
sb.Append(c);
}
return sb.ToString();
}
-
- private sealed class ConnectionDescriptorBuilder
- {
- public List Hosts { get; } = new();
- public string? Database { get; set; }
- public string? Username { get; set; }
- public string? Password { get; set; }
- public SslMode? SslMode { get; set; }
- public string? ApplicationName { get; set; }
- public int? TimeoutSeconds { get; set; }
- public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
-
- public void AddHost(string host, ushort? port)
- {
- if (string.IsNullOrWhiteSpace(host)) return;
- Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
- }
-
- public ConnectionDescriptor Build()
- {
- return new ConnectionDescriptor
- {
- Hosts = Hosts,
- Database = Database,
- Username = Username,
- Password = Password,
- SslMode = SslMode,
- ApplicationName = ApplicationName,
- TimeoutSeconds = TimeoutSeconds,
- Properties = Properties
- };
- }
- }
}
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs
index cb83332..6842a19 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs
@@ -48,7 +48,7 @@ public ref struct PqConnectionStringParser
//service
//target_session_attrs
- public static IDictionary Parse(string input)
+ public static Result> Parse(string input)
{
return new PqConnectionStringParser(
new PqConnectionStringTokenizer(input)
@@ -63,12 +63,16 @@ public ref struct PqConnectionStringParser
this._tokenizer = tokenizer;
}
- public IDictionary Parse()
+ public Result> Parse()
{
_result.Clear();
while (!_tokenizer.IsEof)
- ParsePair();
+ {
+ var result = ParsePair();
+ if (result.IsFailed)
+ return result;
+ }
return _result;
}
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
index fd46bb8..c27a568 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
@@ -72,8 +72,18 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
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.PgUtils/ConnectionStrings/UrlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
index 3714d94..5fe972c 100644
--- a/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
@@ -1,11 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Net;
+using System.Globalization;
using System.Text;
using FluentResults;
-using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@@ -73,13 +68,12 @@ public sealed class UrlCodec : IConnectionStringCodec
builder.Password = Uri.UnescapeDataString(up[1]);
}
- // Parse hosts (may be comma-separated)
- foreach (var hostPart in SplitHosts(authority))
+ // Parse hosts (maybe comma-separated)
+ foreach (string hostPart in CodecCommon.SplitHosts(authority))
{
- if (string.IsNullOrWhiteSpace(hostPart)) continue;
- ParseHostPort(hostPart, out var host, out ushort? port);
+ CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port);
if (!string.IsNullOrEmpty(host))
- builder.AddHost(host!, port);
+ builder.AddHost(host, port);
}
// Parse path (database) and query
@@ -88,24 +82,25 @@ public sealed class UrlCodec : IConnectionStringCodec
if (!string.IsNullOrEmpty(pathAndQuery))
{
// pathAndQuery like /db?x=y
- var qIdx = pathAndQuery.IndexOf('?');
- string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
- query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
+ int qIdx = pathAndQuery.IndexOf('?');
+ string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
+ query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
if (path.Length > 0)
{
// strip leading '/'
- if (path[0] == '/') path = path.Substring(1);
+ if (path[0] == '/')
+ path = path[1..];
if (path.Length > 0)
database = Uri.UnescapeDataString(path);
}
}
if (!string.IsNullOrEmpty(database)) builder.Database = database;
- var queryDict = ParseQuery(query);
+ var queryDict = CodecCommon.ParseQuery(query);
// Map known params
if (queryDict.TryGetValue("sslmode", out var sslVal))
- builder.SslMode = ParseSslMode(sslVal);
+ builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal);
if (queryDict.TryGetValue("application_name", out var app))
builder.ApplicationName = app;
if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts))
@@ -146,7 +141,7 @@ public sealed class UrlCodec : IConnectionStringCodec
}
// hosts
- if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts.Count > 0)
{
var hostParts = new List(descriptor.Hosts.Count);
foreach (var h in descriptor.Hosts)
@@ -170,7 +165,7 @@ public sealed class UrlCodec : IConnectionStringCodec
// query
var queryPairs = new List();
if (descriptor.SslMode.HasValue)
- queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value)));
+ queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@@ -202,153 +197,4 @@ public sealed class UrlCodec : IConnectionStringCodec
=> key.Equals("sslmode", StringComparison.OrdinalIgnoreCase)
|| key.Equals("application_name", StringComparison.OrdinalIgnoreCase)
|| key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase);
-
- private static IEnumerable SplitHosts(string authority)
- {
- // authority may contain comma-separated hosts, each may be IPv6 [..] with optional :port
- // We split on commas that are not inside brackets
- var parts = new List();
- int depth = 0;
- int start = 0;
- for (int i = 0; i < authority.Length; i++)
- {
- char c = authority[i];
- if (c == '[') depth++;
- else if (c == ']') depth = Math.Max(0, depth - 1);
- else if (c == ',' && depth == 0)
- {
- parts.Add(authority.Substring(start, i - start));
- start = i + 1;
- }
- }
- // last
- if (start <= authority.Length)
- parts.Add(authority.Substring(start));
- return parts.Select(p => p.Trim()).Where(p => p.Length > 0);
- }
-
- private static void ParseHostPort(string hostPart, out string host, out ushort? port)
- {
- host = string.Empty; port = null;
- if (string.IsNullOrWhiteSpace(hostPart)) return;
-
- if (hostPart[0] == '[')
- {
- // IPv6 literal [....]:port?
- int end = hostPart.IndexOf(']');
- if (end < 0)
- {
- host = hostPart; // let it pass raw
- return;
- }
- var h = hostPart.Substring(1, end - 1);
- host = h;
- if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
- {
- var ps = hostPart.Substring(end + 2);
- if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
- port = up;
- }
- return;
- }
- // non-IPv6, split last ':' as port if 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 up))
- {
- port = up;
- host = hostPart.Substring(0, colon);
- return;
- }
- }
- host = hostPart;
- }
-
- private static SslMode ParseSslMode(string s)
- {
- switch (s.Trim().ToLowerInvariant())
- {
- case "disable": return SslMode.Disable;
- case "allow": return SslMode.Allow;
- case "prefer": return SslMode.Prefer;
- case "require": return SslMode.Require;
- case "verify-ca":
- case "verifyca": return SslMode.VerifyCA;
- case "verify-full":
- case "verifyfull": return SslMode.VerifyFull;
- default: throw new ArgumentException($"Not a valid sslmode: {s}");
- }
- }
-
- private static string FormatSslMode(SslMode mode)
- {
- return mode switch
- {
- SslMode.Disable => "disable",
- SslMode.Allow => "allow",
- SslMode.Prefer => "prefer",
- SslMode.Require => "require",
- SslMode.VerifyCA => "verify-ca",
- SslMode.VerifyFull => "verify-full",
- _ => "prefer"
- };
- }
-
- private sealed class ConnectionDescriptorBuilder
- {
- public List Hosts { get; } = new();
- public string? Database { get; set; }
- public string? Username { get; set; }
- public string? Password { get; set; }
- public SslMode? SslMode { get; set; }
- public string? ApplicationName { get; set; }
- public int? TimeoutSeconds { get; set; }
- public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
-
- public void AddHost(string host, ushort? port)
- {
- if (string.IsNullOrWhiteSpace(host)) return;
- Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
- }
-
- public ConnectionDescriptor Build()
- {
- return new ConnectionDescriptor
- {
- Hosts = Hosts,
- Database = Database,
- Username = Username,
- Password = Password,
- SslMode = SslMode,
- ApplicationName = ApplicationName,
- TimeoutSeconds = TimeoutSeconds,
- Properties = Properties
- };
- }
- }
-
- private static Dictionary ParseQuery(string query)
- {
- var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- if (string.IsNullOrEmpty(query)) return dict;
- var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries);
- foreach (var pair in pairs)
- {
- var idx = pair.IndexOf('=');
- if (idx < 0)
- {
- var k = Uri.UnescapeDataString(pair);
- dict[k] = string.Empty;
- }
- else
- {
- var k = Uri.UnescapeDataString(pair.Substring(0, idx));
- var v = Uri.UnescapeDataString(pair.Substring(idx + 1));
- dict[k] = v;
- }
- }
- return dict;
- }
}
diff --git a/pgLabII.Tests/Services/ServerConfigurationMappingTests.cs b/pgLabII.Tests/Services/ServerConfigurationMappingTests.cs
index 185f3cc..981ad0d 100644
--- a/pgLabII.Tests/Services/ServerConfigurationMappingTests.cs
+++ b/pgLabII.Tests/Services/ServerConfigurationMappingTests.cs
@@ -12,20 +12,20 @@ public class ServerConfigurationMappingTests
[Fact]
public void ToDescriptor_Basic_MapsExpectedFields()
{
- var cfg = new ServerConfiguration
+ ServerConfigurationEntity cfg = new()
{
Name = "Prod",
Host = "db.example.com",
Port = 5433,
InitialDatabase = "appdb",
- DefaultSslMode = SslMode.Require,
- User = new ServerUser { Name = "alice", Password = "secret" }
+ SslMode = SslMode.Require,
+ UserName = "alice",
+ Password = "secret"
};
var extra = new Dictionary{{"search_path","public"}};
var d = ServerConfigurationMapping.ToDescriptor(cfg, applicationName: "pgLabII", timeoutSeconds: 15, extraProperties: extra);
- Assert.Equal("Prod", d.Name);
Assert.Single(d.Hosts);
Assert.Equal("db.example.com", d.Hosts[0].Host);
Assert.Equal((ushort)5433, d.Hosts[0].Port);
@@ -42,12 +42,13 @@ public class ServerConfigurationMappingTests
[Fact]
public void ToDescriptor_OmitsEmptyFields()
{
- var cfg = new ServerConfiguration
+ ServerConfigurationEntity cfg = new ()
{
Name = "Empty",
Host = "",
InitialDatabase = "",
- User = new ServerUser { Name = "", Password = "" }
+ UserName = "",
+ Password = "",
};
var d = ServerConfigurationMapping.ToDescriptor(cfg);
@@ -63,7 +64,6 @@ public class ServerConfigurationMappingTests
{
var desc = new ConnectionDescriptor
{
- Name = "Staging",
Hosts = new []
{
new HostEndpoint{ Host = "host1", Port = 5432 },
@@ -75,28 +75,28 @@ public class ServerConfigurationMappingTests
SslMode = SslMode.VerifyFull
};
- var cfg = ServerConfigurationMapping.FromDescriptor(desc);
+ ServerConfigurationEntity cfg = ServerConfigurationMapping.FromDescriptor(desc);
- Assert.Equal("Staging", cfg.Name);
Assert.Equal("host1", cfg.Host);
Assert.Equal((ushort)5432, cfg.Port);
Assert.Equal("stagedb", cfg.InitialDatabase);
- Assert.Equal(SslMode.VerifyFull, cfg.DefaultSslMode);
- Assert.Equal("bob", cfg.User.Name);
- Assert.Equal("pwd", cfg.User.Password);
+ Assert.Equal(SslMode.VerifyFull, cfg.SslMode);
+ Assert.Equal("bob", cfg.UserName);
+ Assert.Equal("pwd", cfg.Password);
}
[Fact]
public void FromDescriptor_UpdatesExisting_PreservesMissing()
{
- var existing = new ServerConfiguration
+ ServerConfigurationEntity existing = new()
{
Name = "Existing",
Host = "keep-host",
Port = 5432,
InitialDatabase = "keepdb",
- DefaultSslMode = SslMode.Prefer,
- User = new ServerUser { Name = "keepuser", Password = "keeppwd" }
+ SslMode = SslMode.Prefer,
+ UserName = "keepuser",
+ Password = "keeppwd",
};
// Descriptor missing db and user/pass and sslmode
@@ -110,33 +110,33 @@ public class ServerConfigurationMappingTests
Assert.Equal("new-host", cfg.Host);
Assert.Equal((ushort)5432, cfg.Port); // unchanged
Assert.Equal("keepdb", cfg.InitialDatabase); // preserved
- Assert.Equal(SslMode.Prefer, cfg.DefaultSslMode); // preserved
- Assert.Equal("keepuser", cfg.User.Name); // preserved
- Assert.Equal("keeppwd", cfg.User.Password); // preserved
+ Assert.Equal(SslMode.Prefer, cfg.SslMode); // preserved
+ Assert.Equal("keepuser", cfg.UserName); // preserved
+ Assert.Equal("keeppwd", cfg.Password); // preserved
}
[Fact]
public void Roundtrip_Basic()
{
- var cfg = new ServerConfiguration
+ ServerConfigurationEntity cfg = new()
{
Name = "Round",
Host = "localhost",
Port = 5432,
InitialDatabase = "postgres",
- DefaultSslMode = SslMode.Allow,
- User = new ServerUser { Name = "me", Password = "pw" }
+ SslMode = SslMode.Allow,
+ UserName = "me",
+ Password = "pw",
};
var d = ServerConfigurationMapping.ToDescriptor(cfg);
var cfg2 = ServerConfigurationMapping.FromDescriptor(d);
- Assert.Equal(cfg.Name, cfg2.Name);
Assert.Equal(cfg.Host, cfg2.Host);
Assert.Equal(cfg.Port, cfg2.Port);
Assert.Equal(cfg.InitialDatabase, cfg2.InitialDatabase);
- Assert.Equal(cfg.DefaultSslMode, cfg2.DefaultSslMode);
- Assert.Equal(cfg.User.Name, cfg2.User.Name);
- Assert.Equal(cfg.User.Password, cfg2.User.Password);
+ Assert.Equal(cfg.SslMode, cfg2.SslMode);
+ Assert.Equal(cfg.UserName, cfg2.UserName);
+ Assert.Equal(cfg.Password, cfg2.Password);
}
}
diff --git a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
new file mode 100644
index 0000000..048239b
--- /dev/null
+++ b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
@@ -0,0 +1,83 @@
+using System;
+using Avalonia.Headless.XUnit;
+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(new ServerConfigurationEntity()));
+ 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.UserName);
+ Assert.Equal("pass", vm.Configuration.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(new ServerConfigurationEntity()));
+
+ // Use a string with quoted values that libpq would struggle with due to incorrect quoting
+ vm.InputConnectionString = "Host=\"server with spaces\";Username=\"bob\";Password=\"secret\";Database=\"db1\"";
+
+ // Force interpret as libpq should fail to parse (libpq expects single quotes, not double quotes for quoting)
+ 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 with spaces", vm.Configuration.Host);
+ Assert.Equal("db1", vm.Configuration.InitialDatabase);
+ Assert.Equal("bob", vm.Configuration.UserName);
+ }
+
+ [AvaloniaFact]
+ public void Parse_Npgsql_with_inline_host_port_updates_all_fields()
+ {
+ var vm = new EditServerConfigurationViewModel(new(new ServerConfigurationEntity()));
+ 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.UserName);
+ Assert.Equal("admin", vm.Configuration.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.sln.DotSettings b/pgLabII.sln.DotSettings
new file mode 100644
index 0000000..1cfe955
--- /dev/null
+++ b/pgLabII.sln.DotSettings
@@ -0,0 +1,4 @@
+
+ True
+ True
+ True
\ No newline at end of file
diff --git a/pgLabII/App.axaml.cs b/pgLabII/App.axaml.cs
index 799f898..4403271 100644
--- a/pgLabII/App.axaml.cs
+++ b/pgLabII/App.axaml.cs
@@ -49,6 +49,6 @@ public partial class App : Application
using var scope = services.CreateScope();
var db = services.GetRequiredService();
- //db.Database.Migrate();
+ db.Database.Migrate();
}
}
diff --git a/pgLabII/Infra/LocalDb.cs b/pgLabII/Infra/LocalDb.cs
index 7dd09b4..2d1343b 100644
--- a/pgLabII/Infra/LocalDb.cs
+++ b/pgLabII/Infra/LocalDb.cs
@@ -1,4 +1,5 @@
using System;
+using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -8,7 +9,7 @@ namespace pgLabII.Infra;
public class LocalDb : DbContext
{
- public DbSet ServerConfigurations => Set();
+ public DbSet ServerConfigurations => Set();
public DbSet Documents => Set();
public DbSet EditHistory => Set();
@@ -16,20 +17,22 @@ public class LocalDb : DbContext
public LocalDb()
{
- var folder = Environment.SpecialFolder.LocalApplicationData;
- var path = Environment.GetFolderPath(folder);
+ var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ path = System.IO.Path.Join(path, "pgLabII");
+ System.IO.Directory.CreateDirectory(path);
DbPath = System.IO.Path.Join(path, "local.db");
}
// 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)
{
- new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity());
- new ServerUserEntityConfiguration().Configure(modelBuilder.Entity());
+ new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity());
new DocumentEntityConfiguration().Configure(modelBuilder.Entity());
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity());
}
@@ -38,23 +41,16 @@ public class LocalDb : DbContext
{
base.ConfigureConventions(configurationBuilder);
+ // Keep Color converter for any other entities still using Avalonia Color
configurationBuilder
.Properties()
.HaveConversion();
}
}
-public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration
+public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration
{
- public void Configure(EntityTypeBuilder b)
- {
- b.HasKey(e => e.Id);
- }
-}
-
-public class ServerUserEntityConfiguration : IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder b)
+ public void Configure(EntityTypeBuilder b)
{
b.HasKey(e => e.Id);
}
diff --git a/pgLabII/Migrations/20251025162617_First.Designer.cs b/pgLabII/Migrations/20251025162617_First.Designer.cs
new file mode 100644
index 0000000..6020602
--- /dev/null
+++ b/pgLabII/Migrations/20251025162617_First.Designer.cs
@@ -0,0 +1,139 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using pgLabII.Infra;
+
+#nullable disable
+
+namespace pgLabII.Migrations
+{
+ [DbContext(typeof(LocalDb))]
+ [Migration("20251025162617_First")]
+ partial class First
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
+
+ modelBuilder.Entity("pgLabII.Model.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("BaseCopyFilename")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalFilename")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DocumentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("InsertedText")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Offset")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemovedText")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Timestamp")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Timestamp");
+
+ b.ToTable("EditHistory");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ColorArgb")
+ .HasColumnType("INTEGER");
+
+ b.Property("ColorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("InitialDatabase")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.Property("SslMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ServerConfigurations");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
+ {
+ b.HasOne("pgLabII.Model.Document", "Document")
+ .WithMany("EditHistory")
+ .HasForeignKey("DocumentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Document");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.Document", b =>
+ {
+ b.Navigation("EditHistory");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/pgLabII/Migrations/20251025162617_First.cs b/pgLabII/Migrations/20251025162617_First.cs
new file mode 100644
index 0000000..8b042a4
--- /dev/null
+++ b/pgLabII/Migrations/20251025162617_First.cs
@@ -0,0 +1,92 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace pgLabII.Migrations
+{
+ ///
+ public partial class First : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Documents",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ OriginalFilename = table.Column(type: "TEXT", nullable: false),
+ BaseCopyFilename = table.Column(type: "TEXT", nullable: false),
+ Created = table.Column(type: "TEXT", nullable: false),
+ LastModified = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Documents", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ServerConfigurations",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ Host = table.Column(type: "TEXT", nullable: false),
+ Port = table.Column(type: "INTEGER", nullable: false),
+ InitialDatabase = table.Column(type: "TEXT", nullable: false),
+ SslMode = table.Column(type: "INTEGER", nullable: false),
+ ColorEnabled = table.Column(type: "INTEGER", nullable: false),
+ ColorArgb = table.Column(type: "INTEGER", nullable: false),
+ UserName = table.Column(type: "TEXT", nullable: false),
+ Password = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ServerConfigurations", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "EditHistory",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ DocumentId = table.Column(type: "INTEGER", nullable: false),
+ Timestamp = table.Column(type: "TEXT", nullable: false),
+ Offset = table.Column(type: "INTEGER", nullable: false),
+ InsertedText = table.Column(type: "TEXT", nullable: false),
+ RemovedText = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_EditHistory", x => x.Id);
+ table.ForeignKey(
+ name: "FK_EditHistory_Documents_DocumentId",
+ column: x => x.DocumentId,
+ principalTable: "Documents",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_EditHistory_DocumentId_Timestamp",
+ table: "EditHistory",
+ columns: new[] { "DocumentId", "Timestamp" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "EditHistory");
+
+ migrationBuilder.DropTable(
+ name: "ServerConfigurations");
+
+ migrationBuilder.DropTable(
+ name: "Documents");
+ }
+ }
+}
diff --git a/pgLabII/Migrations/LocalDbModelSnapshot.cs b/pgLabII/Migrations/LocalDbModelSnapshot.cs
new file mode 100644
index 0000000..c860147
--- /dev/null
+++ b/pgLabII/Migrations/LocalDbModelSnapshot.cs
@@ -0,0 +1,136 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using pgLabII.Infra;
+
+#nullable disable
+
+namespace pgLabII.Migrations
+{
+ [DbContext(typeof(LocalDb))]
+ partial class LocalDbModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
+
+ modelBuilder.Entity("pgLabII.Model.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("BaseCopyFilename")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalFilename")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DocumentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("InsertedText")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Offset")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemovedText")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Timestamp")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId", "Timestamp");
+
+ b.ToTable("EditHistory");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ColorArgb")
+ .HasColumnType("INTEGER");
+
+ b.Property("ColorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("InitialDatabase")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.Property("SslMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ServerConfigurations");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
+ {
+ b.HasOne("pgLabII.Model.Document", "Document")
+ .WithMany("EditHistory")
+ .HasForeignKey("DocumentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Document");
+ });
+
+ modelBuilder.Entity("pgLabII.Model.Document", b =>
+ {
+ b.Navigation("EditHistory");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/pgLabII/Model/ServerConfiguration.cs b/pgLabII/Model/ServerConfiguration.cs
deleted file mode 100644
index b251880..0000000
--- a/pgLabII/Model/ServerConfiguration.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.Reactive;
-using Avalonia.Media;
-using Npgsql;
-using pgLabII.ViewModels;
-using pgLabII.Views;
-using ReactiveUI;
-
-namespace pgLabII.Model;
-
-public class ServerConfiguration : ReactiveObject
-{
- private Color _color;
- private bool _colorEnabled = true;
- private string initialDatabase = "";
-
- public Guid Id { get; set; } = Guid.NewGuid();
- ///
- /// For the user to help him identify the item
- ///
- public string Name { get; set; } = "";
-
- public Color Color
- {
- get => _color;
- set
- {
- if (_color != value)
- {
- _color = value;
- this.RaisePropertyChanged();
- this.RaisePropertyChanged(propertyName: nameof(BackgroundBrush));
- }
- }
- }
-
- public bool ColorEnabled
- {
- get => _colorEnabled;
- set
- {
- if (_colorEnabled != value)
- {
- _colorEnabled = value;
- this.RaisePropertyChanged();
- this.RaisePropertyChanged(propertyName: nameof(BackgroundBrush));
- }
- }
- }
-
- public string Host { get; set; } = "";
- public ushort Port { get; set; } = 5432;
- public string InitialDatabase
- {
- get => initialDatabase;
- set
- {
- if (initialDatabase != value)
- {
- initialDatabase = value;
- this.RaisePropertyChanged();
- }
- }
- }
- public SslMode DefaultSslMode { get; set; } = SslMode.Prefer;
- public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
-
- public ServerUser User { get; set; } = new();
-
- public ReactiveCommand EditCommand { get; }
-
- public ReactiveCommand ExploreCommand { get; }
-
- public ServerConfiguration()
- {
- EditCommand = ReactiveCommand.Create(() =>
- {
- EditServerConfigurationWindow window = new(
- new ViewModels.EditServerConfigurationViewModel(this))
- { New = false };
- window.Show();
- });
- ExploreCommand = ReactiveCommand.Create(() =>
- {
- SingleDatabaseWindow window = new() { DataContext = new ViewListViewModel() };
- window.Show();
- });
- }
-
- public ServerConfiguration(ServerConfiguration src)
- : this()
- {
- Color = src.Color;
- ColorEnabled = src.ColorEnabled;
- Id = src.Id;
- Name = src.Name;
- Port = src.Port;
- InitialDatabase = src.InitialDatabase;
- DefaultSslMode = src.DefaultSslMode;
- User = src.User;
- }
-}
-
diff --git a/pgLabII/Model/ServerConfigurationEntity.cs b/pgLabII/Model/ServerConfigurationEntity.cs
new file mode 100644
index 0000000..0b28c7b
--- /dev/null
+++ b/pgLabII/Model/ServerConfigurationEntity.cs
@@ -0,0 +1,20 @@
+using System;
+using Npgsql;
+
+namespace pgLabII.Model;
+
+// Pure persistence entity for EF Core: no UI dependencies, no ReactiveObject
+public class ServerConfigurationEntity
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Name { get; set; } = string.Empty;
+ public string Host { get; set; } = string.Empty;
+ public ushort Port { get; set; } = 5432;
+ public string InitialDatabase { get; set; } = string.Empty;
+ public SslMode SslMode { get; set; } = SslMode.Prefer;
+ public bool ColorEnabled { get; set; } = true;
+ public int ColorArgb { get; set; } = unchecked((int)0xFF_33_33_33); // default dark gray
+ public string UserName { get; set; } = "";
+
+ public string Password { get; set; } = "";
+}
diff --git a/pgLabII/Model/ServerUser.cs b/pgLabII/Model/ServerUser.cs
deleted file mode 100644
index c8d4574..0000000
--- a/pgLabII/Model/ServerUser.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System;
-
-namespace pgLabII.Model;
-
-public class ServerUser : ViewModelBase
-{
- public Guid Id { get; set; }
- public Guid ServerConfigurationId { get; set; }
- public string Name { get; set; } = "";
- public string Password { get; set; } = "";
-}
diff --git a/pgLabII/QueryToolPlan.md b/pgLabII/QueryToolPlan.md
new file mode 100644
index 0000000..e94d51c
--- /dev/null
+++ b/pgLabII/QueryToolPlan.md
@@ -0,0 +1,108 @@
+# Features
+
+- Is a part of the SingleDatabaseWindow
+- It's view should go in QueryToolView.axaml
+- Uses mvvm
+- AvaloniaEdit should be used as a query editor
+
+## Editor
+
+- Use CodeEditorView
+- We want to be able to open and save .sql files with the editor
+
+## Result grid
+
+- Use an Avalonia.Controls.DataGrid
+- The columns will change on runtime so it should be able to get the column count captions and data types from the viewmodel
+- We want to be able to sort the columns
+- We want to be able to filter the rows by defining conditions for the columns
+- We want to be able to copy the rows to the clipboard
+- We want to be able to customize cell rendering to use different colors for types and also do special things like rendering booleans as green checks and red crosses
+- Be aware results may contain many rows, we should make a decision on how to handle this
+- We want to be able to save the results to a file
+
+---
+
+# Step-by-step plan to create the Query Tool
+
+1. Add QueryToolView to the UI shell.
+ - Place the view in pgLabII\Views\QueryToolView.axaml and include it within SingleDatabaseWindow as a child region/panel. Ensure DataContext is set to QueryToolViewModel.
+ - Confirm MVVM wiring: commands and properties will be bound from the ViewModel.
+
+2. Integrate the SQL editor.
+ - Embed AvaloniaEdit editor in the top area of QueryToolView.
+ - Bind editor text to a ViewModel property (e.g., UserSql).
+ - Provide commands for OpenSqlFile and SaveSqlFile; wire to toolbar/buttons and standard shortcuts (Ctrl+O/Ctrl+S).
+ - Ensure file filters default to .sql and that encoding/line-endings preserve content when saving.
+
+3. Add a results toolbar for query operations.
+ - Buttons/controls: Run, Cancel (optional), "Load more", Auto-load on scroll toggle, Export..., and a compact status/summary text (e.g., "Showing X of Y rows").
+ - Bind to RunQuery, LoadMore, ExportResults, AutoLoadMore, ResultSummary, and Status properties.
+
+4. Add the result grid using Avalonia.Controls.DataGrid.
+ - Enable row and column virtualization. Keep cell templates lightweight to preserve performance.
+ - Start with AutoGenerateColumns=true; later switch to explicit columns if custom cell templates per type are needed.
+ - Bind Items to a read-only observable collection of row objects (e.g., Rows).
+ - Enable extended selection and clipboard copy.
+
+5. Support dynamic columns and types from the ViewModel.
+ - Expose a Columns metadata collection (names, data types, display hints) from the ViewModel.
+ - On first page load, update metadata so the grid can reflect the current query’s shape.
+ - If AutoGenerateColumns is disabled, construct DataGrid columns based on metadata (text, number, date, boolean with check/cross visuals).
+
+6. Sorting model.
+ - On column header sort request, send sort descriptor(s) to the ViewModel.
+ - Re-run the query via server-side ORDER BY by wrapping the user SQL as a subquery and applying sort expressions.
+ - Reset paging when sort changes (reload from page 1).
+ - Clearly indicate if sorting is client-side (fallback) and only affects loaded rows.
+
+7. Filtering model.
+ - Provide a simple filter row/panel to define per-column conditions.
+ - Convert user-entered filters to a filter descriptor list in the ViewModel.
+ - Prefer server-side WHERE by wrapping the user SQL; reset paging when filters change.
+ - If server-side wrapping is not possible for a given statement, apply client-side filtering to the currently loaded subset and warn that the filter is partial.
+
+8. Data paging and virtualization (for 100k+ rows).
+ - Choose a default page size of 1000 rows (range 500–2000).
+ - On RunQuery: clear rows, reset page index, set CanLoadMore=true, fetch page 1.
+ - "Load more" fetches the next page and appends. Enable infinite scroll optionally when near the end.
+ - Display summary text: "Showing N of M+ rows" when total is known; otherwise "Showing N rows".
+ - Consider a cap on retained rows (e.g., last 10–20k) if memory is a concern.
+
+9. Query execution abstraction.
+ - Use a service (e.g., IQueryExecutor) to run database calls.
+ - Provide: FetchPageAsync(userSql, sort, filters, page, size, ct) and StreamAllAsync(userSql, sort, filters, ct) for export.
+ - Wrap user SQL as a subquery to inject WHERE/ORDER BY/LIMIT/OFFSET safely; trim trailing semicolons.
+ - Prefer keyset pagination when a stable ordered key exists.
+
+10. Export/Save results.
+ - Export should re-execute the query and stream the full result set directly from the database to CSV/TSV/JSON.
+ - Do not export from the grid items because the grid may contain only a subset of rows.
+ - Provide a Save As dialog with format choice and destination path.
+
+11. Copy to clipboard and selection.
+ - Enable extended row selection in the grid; support Ctrl+C to copy selected rows.
+ - Provide a toolbar "Copy" button as an alternative entry point.
+
+12. Status, cancellation, and errors.
+ - Show progress/state (Running, Idle, Loading page k, Cancelled, Error).
+ - Support cancellation tokens for long-running queries and paging operations.
+ - Surface exceptions as non-blocking notifications and preserve the last successful rows.
+
+13. Theming and custom cell rendering.
+ - Apply subtle coloring by type (numbers, dates, strings) via cell styles or templates.
+ - Render booleans as green checks/red crosses with minimal template overhead to keep virtualization effective.
+
+14. Wiring in SingleDatabaseWindow.
+ - Add a dedicated region/tab/panel for the Query Tool.
+ - Ensure lifetime management of the QueryToolViewModel aligns with the connection/session scope.
+ - Provide the active connection context/service to the ViewModel (DI or constructor).
+
+15. Testing and verification.
+ - Manual test: small query, large query (100k rows), sorting, filtering, load more, infinite scroll, export, copy, boolean rendering.
+ - Edge cases: empty results, wide tables (many columns), slow network, cancellation mid-page, schema change between pages.
+ - Performance check: scroll smoothness, memory growth under repeated paging, export throughput.
+
+16. Documentation and UX notes.
+ - In help/tooltip, clarify that sorting/filtering are server-side when possible; otherwise they apply only to loaded rows.
+ - Show a banner when results are truncated by paging limits and how to load more.
diff --git a/pgLabII/Services/ServerConfigurationMapping.cs b/pgLabII/Services/ServerConfigurationMapping.cs
index 9f51efd..a61b0f6 100644
--- a/pgLabII/Services/ServerConfigurationMapping.cs
+++ b/pgLabII/Services/ServerConfigurationMapping.cs
@@ -21,7 +21,7 @@ public static class ServerConfigurationMapping
/// - ApplicationName and TimeoutSeconds don't exist on ServerConfiguration; we preserve any passed-in
/// values via optional parameters or Properties if provided by caller.
///
- public static ConnectionDescriptor ToDescriptor(ServerConfiguration cfg,
+ public static ConnectionDescriptor ToDescriptor(ServerConfigurationEntity cfg,
string? applicationName = null,
int? timeoutSeconds = null,
IReadOnlyDictionary? extraProperties = null)
@@ -43,12 +43,11 @@ public static class ServerConfigurationMapping
return new ConnectionDescriptor
{
- Name = cfg.Name,
Hosts = hosts,
Database = string.IsNullOrWhiteSpace(cfg.InitialDatabase) ? null : cfg.InitialDatabase,
- Username = string.IsNullOrWhiteSpace(cfg.User?.Name) ? null : cfg.User!.Name,
- Password = string.IsNullOrEmpty(cfg.User?.Password) ? null : cfg.User!.Password,
- SslMode = cfg.DefaultSslMode,
+ Username = string.IsNullOrWhiteSpace(cfg.UserName) ? null : cfg.UserName,
+ Password = string.IsNullOrEmpty(cfg.Password) ? null : cfg.Password,
+ SslMode = cfg.SslMode,
ApplicationName = applicationName,
TimeoutSeconds = timeoutSeconds,
Properties = props
@@ -61,17 +60,13 @@ public static class ServerConfigurationMapping
/// - If descriptor has multiple hosts, the first is mapped to Host/Port.
/// - If descriptor omits sslmode/database/username/password, existing values are preserved (if any).
///
- public static ServerConfiguration FromDescriptor(ConnectionDescriptor descriptor, ServerConfiguration? existing = null)
+ public static ServerConfigurationEntity FromDescriptor(ConnectionDescriptor descriptor, ServerConfigurationEntity? existing = null)
{
if (descriptor == null) throw new ArgumentNullException(nameof(descriptor));
- var cfg = existing ?? new ServerConfiguration();
-
- // Name
- if (!string.IsNullOrWhiteSpace(descriptor.Name))
- cfg.Name = descriptor.Name!;
+ var cfg = existing ?? new ServerConfigurationEntity();
// Host/Port: take first
- if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts.Count > 0)
{
var h = descriptor.Hosts[0];
if (!string.IsNullOrWhiteSpace(h.Host))
@@ -86,17 +81,35 @@ public static class ServerConfigurationMapping
// SSL Mode
if (descriptor.SslMode.HasValue)
- cfg.DefaultSslMode = descriptor.SslMode.Value;
+ cfg.SslMode = descriptor.SslMode.Value;
// User
- if (cfg.User == null)
- cfg.User = new ServerUser();
if (!string.IsNullOrWhiteSpace(descriptor.Username))
- cfg.User.Name = descriptor.Username!;
+ cfg.UserName = descriptor.Username!;
if (!string.IsNullOrEmpty(descriptor.Password))
- cfg.User.Password = descriptor.Password!;
+ cfg.Password = descriptor.Password!;
// Nothing to do for ApplicationName/TimeoutSeconds here; not represented in ServerConfiguration.
return cfg;
}
+
+ // Overloads for new UI ViewModel wrapper
+ public static ConnectionDescriptor ToDescriptor(pgLabII.ViewModels.ServerConfigurationViewModel cfgVm,
+ string? applicationName = null,
+ int? timeoutSeconds = null,
+ IReadOnlyDictionary? extraProperties = null)
+ => ToDescriptor(cfgVm.Entity, applicationName, timeoutSeconds, extraProperties);
+
+ public static void FromDescriptorInto(pgLabII.ViewModels.ServerConfigurationViewModel targetVm, ConnectionDescriptor descriptor)
+ {
+ //var updated = targetVm.Entity;
+ var n = FromDescriptor(descriptor, null);
+ // push back updated values into VM's entity to trigger the notifies
+ targetVm.Host = n.Host;
+ targetVm.Port = n.Port;
+ targetVm.InitialDatabase = n.InitialDatabase;
+ targetVm.DefaultSslMode = n.SslMode;
+ targetVm.UserName = n.UserName;
+ targetVm.Password = n.Password;
+ }
}
diff --git a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
index 2d27905..42108fe 100644
--- a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
+++ b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
@@ -1,33 +1,176 @@
-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;
public class EditServerConfigurationViewModel : ViewModelBase
{
- public ServerConfiguration Configuration { get; set; }
+ // Prefer new UI VM; keep old model for compatibility by wrapping when needed
+ public ServerConfigurationViewModel 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,
+ Jdbc
+ }
+
+ 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();
+ Configuration = new(new ServerConfigurationEntity());
+ _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)
+ public EditServerConfigurationViewModel(ServerConfigurationViewModel configuration)
: this()
{
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(),
+ ForcedFormatOption.Jdbc => new JdbcCodec(),
+ _ => 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.FromDescriptorInto(Configuration, descriptor);
+ // Also set a 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,
+ ForcedFormatOption.Jdbc => ConnStringFormat.Jdbc,
+ _ => 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/QueryToolViewModel.cs b/pgLabII/ViewModels/QueryToolViewModel.cs
index a2a1854..8968d48 100644
--- a/pgLabII/ViewModels/QueryToolViewModel.cs
+++ b/pgLabII/ViewModels/QueryToolViewModel.cs
@@ -1,22 +1,205 @@
-using System.Reactive;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Reactive;
+using System.Threading.Tasks;
+using Npgsql;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
+using pgLabII.Model;
namespace pgLabII.ViewModels;
+public class ColumnInfo
+{
+ public string Name { get; set; } = string.Empty;
+ public string? DisplayName { get; set; }
+ public Type DataType { get; set; } = typeof(string);
+ public bool IsSortable { get; set; } = true;
+ public bool IsFilterable { get; set; } = true;
+ // Add more metadata as needed (format, cell template, etc.)
+}
+
public partial class QueryToolViewModel : ViewModelBase, IViewItem
{
- [Reactive] private string _caption = "Cap";
+ private readonly ServerConfigurationEntity? _serverConfig;
- [Reactive] private string _query = "";
+ // Tab caption
+ [Reactive] private string _caption = "Query";
- public ReactiveCommand EditCommand { get; }
+ // SQL text bound to the editor
+ [Reactive] private string _userSql = "SELECT n, 1, 'hello', '2025-4-5'::date FROM generate_series(1, 2000) AS d(n)";
- public QueryToolViewModel()
+ // Summary and status labels
+ [Reactive] private string _resultSummary = "Showing 0 rows";
+ [Reactive] private string _status = "Ready";
+
+ // Paging flags
+ [Reactive] private bool _canLoadMore;
+ [Reactive] private bool _autoLoadMore;
+
+ // Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns.
+ public ObservableCollection