diff --git a/.ai-guidelines.md b/.ai-guidelines.md
index b7e1d1d..82b8c08 100644
--- a/.ai-guidelines.md
+++ b/.ai-guidelines.md
@@ -1,8 +1,7 @@
# pgLabII AI Assistant Guidelines
## Project Context
-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.
+This is a .NET 8/C# 13 Avalonia cross-platform application for document management.
### Architecture Overview
- **Main Project**: pgLabII (Avalonia UI)
@@ -13,18 +12,16 @@ postgresql databases. It should also be a good editor for SQL files.
## Coding Standards
### C# Guidelines
-- Use C# 14 features and modern .NET patterns
+- Use C# 13 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 ac326a4..6a883b3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,7 +37,5 @@
-
-
\ No newline at end of file
diff --git a/pgLabII.Desktop/pgLabII.Desktop.csproj b/pgLabII.Desktop/pgLabII.Desktop.csproj
index e989c75..9002229 100644
--- a/pgLabII.Desktop/pgLabII.Desktop.csproj
+++ b/pgLabII.Desktop/pgLabII.Desktop.csproj
@@ -20,7 +20,6 @@
None
All
-
diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
index 5674377..4342c08 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
@@ -14,14 +14,6 @@ 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
deleted file mode 100644
index 303d5da..0000000
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/DbConnectionStringBuilderTests.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index 5bf2e1c..0000000
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/JdbcCodecTests.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-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 11fa216..5f29a89 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,12 +58,11 @@ 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);
- // Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior
- Assert.Contains("Password='p;ss" + '"' + "word'", s);
+ Assert.Contains("Database=\"prod db\"", s);
+ Assert.Contains("Username=bob", s);
+ 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);
}
@@ -78,12 +77,11 @@ 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);
- // 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("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 37c3f11..d953ffc 100644
--- a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs
@@ -1,6 +1,4 @@
-using FluentResults;
-using pgLabII.PgUtils.ConnectionStrings;
-using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
+using pgLabII.PgUtils.ConnectionStrings;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
@@ -24,25 +22,20 @@ public class PqConnectionStringParserTests
public void Success()
{
var parser = new PqConnectionStringParser(tokenizer);
- Result> output = parser.Parse();
- ResultAssert.Success(output, v =>
- {
- Assert.Single(v);
- Assert.True(v.TryGetValue(kw, out string? result));
- Assert.Equal(val, result);
- });
+ IDictionary output = parser.Parse();
+
+ Assert.Single(output);
+ Assert.True(output.TryGetValue(kw, out string? result));
+ Assert.Equal(val, result);
}
[Fact]
public void StaticParse()
{
- 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);
- });
+ var output = PqConnectionStringParser.Parse("foo=bar");
+ Assert.Single(output);
+ Assert.True(output.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
deleted file mode 100644
index bef3c04..0000000
--- a/pgLabII.PgUtils/ConnectionStrings/CodecCommon.cs
+++ /dev/null
@@ -1,101 +0,0 @@
-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 3b39478..f8dfe8c 100644
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptor.cs
@@ -8,6 +8,8 @@ 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();
@@ -24,4 +26,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
deleted file mode 100644
index 86f2703..0000000
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionDescriptorBuilder.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-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 b715866..11e69b3 100644
--- a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
@@ -21,22 +21,18 @@ public sealed class ConnectionStringService : IConnectionStringService
}
///
- /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url, Jdbc).
+ /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
///
public static ConnectionStringService CreateDefault()
- => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec(), new JdbcCodec() });
+ => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
public Result DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail("Empty input");
- // URL: postgresql:// or postgres:// or JDBC jdbc:postgresql://
+ // URL: postgresql:// or postgres://
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
deleted file mode 100644
index 1274f9c..0000000
--- a/pgLabII.PgUtils/ConnectionStrings/JdbcCodec.cs
+++ /dev/null
@@ -1,175 +0,0 @@
-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 ba67313..7aa6519 100644
--- a/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/NpgsqlCodec.cs
@@ -1,6 +1,7 @@
-using System.Collections;
-using System.Data.Common;
+using System;
+using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
using System.Text;
using FluentResults;
using Npgsql;
@@ -8,7 +9,19 @@ using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
///
-/// Parser/formatter for Npgsql-style .NET connection strings.
+/// 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.
///
public sealed class NpgsqlCodec : IConnectionStringCodec
{
@@ -25,42 +38,30 @@ 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 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.
+ var hosts = SplitList(hostVal).ToList();
+ List portsPerHost = new();
if (dict.TryGetValue("Port", out var portVal))
{
var ports = SplitList(portVal).ToList();
- if (ports.Count == 1 && ushort.TryParse(ports[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var singlePort))
+ if (ports.Count == 1 && ushort.TryParse(ports[0], out var singlePort))
{
- for (int i = 0; i < portsPerHost.Count; i++)
- if (!portsPerHost[i].HasValue)
- portsPerHost[i] = singlePort;
+ foreach (var _ in hosts) portsPerHost.Add(singlePort);
}
else if (ports.Count == hosts.Count)
{
- for (int i = 0; i < ports.Count; i++)
+ foreach (var p in ports)
{
- if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
- portsPerHost[i] = up;
+ if (ushort.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
+ portsPerHost.Add(up);
+ else
+ portsPerHost.Add(null);
}
}
}
-
for (int i = 0; i < hosts.Count; i++)
{
- descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
+ ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
+ descriptor.AddHost(hosts[i], port);
}
}
@@ -106,16 +107,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
{
try
{
- var parts = new DbConnectionStringBuilder();
+ var parts = new List();
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
- parts["Host"] = hostList;
+ parts.Add(FormatPair("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["Port"] = ports[0].ToString(CultureInfo.InvariantCulture);
+ parts.Add(FormatPair("Port", ports[0].ToString(CultureInfo.InvariantCulture)));
}
else if (ports.Count == 0)
{
@@ -126,24 +127,31 @@ 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["Port"] = string.Join(',', perHost);
+ parts.Add(FormatPair("Port", string.Join(',', perHost)));
}
}
if (!string.IsNullOrEmpty(descriptor.Database))
- parts["Database"] = descriptor.Database;
+ parts.Add(FormatPair("Database", descriptor.Database));
if (!string.IsNullOrEmpty(descriptor.Username))
- parts["Username"] = descriptor.Username;
+ parts.Add(FormatPair("Username", descriptor.Username));
if (!string.IsNullOrEmpty(descriptor.Password))
- parts["Password"] = descriptor.Password;
+ parts.Add(FormatPair("Password", descriptor.Password));
if (descriptor.SslMode.HasValue)
- parts["SSL Mode"] = FormatSslMode(descriptor.SslMode.Value);
+ parts.Add(FormatPair("SSL Mode", FormatSslMode(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
- parts["Application Name"] = descriptor.ApplicationName;
+ parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
- parts["Timeout"] = descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture);
+ parts.Add(FormatPair("Timeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
- return Result.Ok(parts.ConnectionString);
+ 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));
}
catch (Exception ex)
{
@@ -156,42 +164,6 @@ 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)
@@ -202,25 +174,158 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return false;
}
- private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s);
-
- private static string FormatSslMode(SslMode mode) => mode switch
+ private static SslMode ParseSslMode(string s)
{
- SslMode.Disable => "Disable",
- SslMode.Allow => "Allow",
- SslMode.Prefer => "Prefer",
- SslMode.Require => "Require",
- SslMode.VerifyCA => "VerifyCA",
- SslMode.VerifyFull => "VerifyFull",
- _ => "Prefer"
- };
+ 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 string FormatSslMode(SslMode mode)
+ {
+ 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("\"", "\"\"");
+ }
private static Dictionary Tokenize(string input)
{
- DbConnectionStringBuilder db = new() { ConnectionString = input };
+ // Simple tokenizer for .NET connection strings: key=value pairs separated by semicolons; values may be quoted with double quotes
var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (string k in db.Keys)
- dict.Add(k, (string)db[k]);
+ 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++;
+ }
+
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
new file mode 100644
index 0000000..b30d7fa
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/PLAN.md
@@ -0,0 +1,51 @@
+# 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 fe48afc..7e4a3dc 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/LibpqCodec.cs
@@ -1,5 +1,9 @@
-using System.Text;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
using FluentResults;
+using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@@ -12,13 +16,11 @@ public sealed class LibpqCodec : IConnectionStringCodec
{
try
{
- Result> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
- if (kv.IsFailed)
- return kv.ToResult();
+ var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
// libpq keywords are case-insensitive; normalize to lower for lookup
var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var pair in kv.Value)
+ foreach (var pair in kv)
dict[pair.Key] = pair.Value;
var descriptor = new ConnectionDescriptorBuilder();
@@ -26,7 +28,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
if (dict.TryGetValue("host", out var host))
{
// libpq supports host lists separated by commas
- string[] hosts = CodecCommon.SplitHosts(host);
+ var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@@ -35,10 +37,10 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.AddHost(h, portForAll);
}
}
- if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
+ if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
{
- // If hostaddr is provided without a host, include as host entries as well
- string[] hosts = CodecCommon.SplitHosts(hostaddr);
+ // If hostaddr is provided without host, include as host entries as well
+ var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@@ -56,7 +58,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.Password = pass;
if (dict.TryGetValue("sslmode", out var sslStr))
- descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr);
+ descriptor.SslMode = ParseSslMode(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))
@@ -88,7 +90,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
var parts = new List();
// Hosts and port
- if (descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
parts.Add(FormatPair("host", hostList));
@@ -105,7 +107,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", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
+ parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@@ -127,6 +129,34 @@ 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;
@@ -137,17 +167,56 @@ public sealed class LibpqCodec : IConnectionStringCodec
private static bool NeedsQuoting(string value)
{
- return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\');
+ 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 EscapeValue(string value)
{
var sb = new StringBuilder();
- foreach (char c in value)
+ foreach (var 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 6842a19..cb83332 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 Result> Parse(string input)
+ public static IDictionary Parse(string input)
{
return new PqConnectionStringParser(
new PqConnectionStringTokenizer(input)
@@ -63,16 +63,12 @@ public ref struct PqConnectionStringParser
this._tokenizer = tokenizer;
}
- public Result> Parse()
+ public IDictionary Parse()
{
_result.Clear();
while (!_tokenizer.IsEof)
- {
- var result = ParsePair();
- if (result.IsFailed)
- return result;
- }
+ ParsePair();
return _result;
}
diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
index c27a568..fd46bb8 100644
--- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs
@@ -72,18 +72,8 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
private string UnquotedString(bool forKeyword)
{
int start = 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;
- }
+ while (++position < input.Length && !char.IsWhiteSpace(input[position]) && (!forKeyword || input[position] != '='))
+ { }
return input.Substring(start, position - start);
}
diff --git a/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
index 5fe972c..3714d94 100644
--- a/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
+++ b/pgLabII.PgUtils/ConnectionStrings/UrlCodec.cs
@@ -1,6 +1,11 @@
-using System.Globalization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
using System.Text;
using FluentResults;
+using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@@ -68,12 +73,13 @@ public sealed class UrlCodec : IConnectionStringCodec
builder.Password = Uri.UnescapeDataString(up[1]);
}
- // Parse hosts (maybe comma-separated)
- foreach (string hostPart in CodecCommon.SplitHosts(authority))
+ // Parse hosts (may be comma-separated)
+ foreach (var hostPart in SplitHosts(authority))
{
- CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port);
+ if (string.IsNullOrWhiteSpace(hostPart)) continue;
+ ParseHostPort(hostPart, out var host, out ushort? port);
if (!string.IsNullOrEmpty(host))
- builder.AddHost(host, port);
+ builder.AddHost(host!, port);
}
// Parse path (database) and query
@@ -82,25 +88,24 @@ public sealed class UrlCodec : IConnectionStringCodec
if (!string.IsNullOrEmpty(pathAndQuery))
{
// pathAndQuery like /db?x=y
- int qIdx = pathAndQuery.IndexOf('?');
- string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
- query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
+ var qIdx = pathAndQuery.IndexOf('?');
+ string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
+ query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
if (path.Length > 0)
{
// strip leading '/'
- if (path[0] == '/')
- path = path[1..];
+ 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);
+ var queryDict = ParseQuery(query);
// Map known params
if (queryDict.TryGetValue("sslmode", out var sslVal))
- builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal);
+ builder.SslMode = ParseSslMode(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))
@@ -141,7 +146,7 @@ public sealed class UrlCodec : IConnectionStringCodec
}
// hosts
- if (descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostParts = new List(descriptor.Hosts.Count);
foreach (var h in descriptor.Hosts)
@@ -165,7 +170,7 @@ public sealed class UrlCodec : IConnectionStringCodec
// query
var queryPairs = new List();
if (descriptor.SslMode.HasValue)
- queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
+ queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@@ -197,4 +202,153 @@ 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 981ad0d..185f3cc 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()
{
- ServerConfigurationEntity cfg = new()
+ var cfg = new ServerConfiguration
{
Name = "Prod",
Host = "db.example.com",
Port = 5433,
InitialDatabase = "appdb",
- SslMode = SslMode.Require,
- UserName = "alice",
- Password = "secret"
+ DefaultSslMode = SslMode.Require,
+ User = new ServerUser { Name = "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,13 +42,12 @@ public class ServerConfigurationMappingTests
[Fact]
public void ToDescriptor_OmitsEmptyFields()
{
- ServerConfigurationEntity cfg = new ()
+ var cfg = new ServerConfiguration
{
Name = "Empty",
Host = "",
InitialDatabase = "",
- UserName = "",
- Password = "",
+ User = new ServerUser { Name = "", Password = "" }
};
var d = ServerConfigurationMapping.ToDescriptor(cfg);
@@ -64,6 +63,7 @@ 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
};
- ServerConfigurationEntity cfg = ServerConfigurationMapping.FromDescriptor(desc);
+ var 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.SslMode);
- Assert.Equal("bob", cfg.UserName);
- Assert.Equal("pwd", cfg.Password);
+ Assert.Equal(SslMode.VerifyFull, cfg.DefaultSslMode);
+ Assert.Equal("bob", cfg.User.Name);
+ Assert.Equal("pwd", cfg.User.Password);
}
[Fact]
public void FromDescriptor_UpdatesExisting_PreservesMissing()
{
- ServerConfigurationEntity existing = new()
+ var existing = new ServerConfiguration
{
Name = "Existing",
Host = "keep-host",
Port = 5432,
InitialDatabase = "keepdb",
- SslMode = SslMode.Prefer,
- UserName = "keepuser",
- Password = "keeppwd",
+ DefaultSslMode = SslMode.Prefer,
+ User = new ServerUser { Name = "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.SslMode); // preserved
- Assert.Equal("keepuser", cfg.UserName); // preserved
- Assert.Equal("keeppwd", cfg.Password); // preserved
+ Assert.Equal(SslMode.Prefer, cfg.DefaultSslMode); // preserved
+ Assert.Equal("keepuser", cfg.User.Name); // preserved
+ Assert.Equal("keeppwd", cfg.User.Password); // preserved
}
[Fact]
public void Roundtrip_Basic()
{
- ServerConfigurationEntity cfg = new()
+ var cfg = new ServerConfiguration
{
Name = "Round",
Host = "localhost",
Port = 5432,
InitialDatabase = "postgres",
- SslMode = SslMode.Allow,
- UserName = "me",
- Password = "pw",
+ DefaultSslMode = SslMode.Allow,
+ User = new ServerUser { Name = "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.SslMode, cfg2.SslMode);
- Assert.Equal(cfg.UserName, cfg2.UserName);
- Assert.Equal(cfg.Password, cfg2.Password);
+ Assert.Equal(cfg.DefaultSslMode, cfg2.DefaultSslMode);
+ Assert.Equal(cfg.User.Name, cfg2.User.Name);
+ Assert.Equal(cfg.User.Password, cfg2.User.Password);
}
}
diff --git a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs b/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
deleted file mode 100644
index 048239b..0000000
--- a/pgLabII.Tests/Views/EditServerConfigurationWindowTests.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-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 e67a3e1..f10cf0d 100644
--- a/pgLabII.Tests/pgLabII.Tests.csproj
+++ b/pgLabII.Tests/pgLabII.Tests.csproj
@@ -15,8 +15,6 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
diff --git a/pgLabII.sln.DotSettings b/pgLabII.sln.DotSettings
deleted file mode 100644
index 1cfe955..0000000
--- a/pgLabII.sln.DotSettings
+++ /dev/null
@@ -1,4 +0,0 @@
-
- True
- True
- True
\ No newline at end of file
diff --git a/pgLabII/App.axaml.cs b/pgLabII/App.axaml.cs
index 4403271..799f898 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 2d1343b..7dd09b4 100644
--- a/pgLabII/Infra/LocalDb.cs
+++ b/pgLabII/Infra/LocalDb.cs
@@ -1,5 +1,4 @@
using System;
-using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -9,7 +8,7 @@ namespace pgLabII.Infra;
public class LocalDb : DbContext
{
- public DbSet ServerConfigurations => Set();
+ public DbSet ServerConfigurations => Set();
public DbSet Documents => Set();
public DbSet EditHistory => Set();
@@ -17,22 +16,20 @@ public class LocalDb : DbContext
public LocalDb()
{
- var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- path = System.IO.Path.Join(path, "pgLabII");
- System.IO.Directory.CreateDirectory(path);
+ var folder = Environment.SpecialFolder.LocalApplicationData;
+ var path = Environment.GetFolderPath(folder);
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 ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity());
+ new ServerUserEntityConfiguration().Configure(modelBuilder.Entity());
new DocumentEntityConfiguration().Configure(modelBuilder.Entity());
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity());
}
@@ -41,16 +38,23 @@ 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)
+ public void Configure(EntityTypeBuilder b)
+ {
+ b.HasKey(e => e.Id);
+ }
+}
+
+public class ServerUserEntityConfiguration : IEntityTypeConfiguration
+{
+ 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
deleted file mode 100644
index 6020602..0000000
--- a/pgLabII/Migrations/20251025162617_First.Designer.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-//
-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
deleted file mode 100644
index 8b042a4..0000000
--- a/pgLabII/Migrations/20251025162617_First.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-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
deleted file mode 100644
index c860147..0000000
--- a/pgLabII/Migrations/LocalDbModelSnapshot.cs
+++ /dev/null
@@ -1,136 +0,0 @@
-//
-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
new file mode 100644
index 0000000..b251880
--- /dev/null
+++ b/pgLabII/Model/ServerConfiguration.cs
@@ -0,0 +1,105 @@
+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
deleted file mode 100644
index 0b28c7b..0000000
--- a/pgLabII/Model/ServerConfigurationEntity.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-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
new file mode 100644
index 0000000..c8d4574
--- /dev/null
+++ b/pgLabII/Model/ServerUser.cs
@@ -0,0 +1,11 @@
+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
deleted file mode 100644
index e94d51c..0000000
--- a/pgLabII/QueryToolPlan.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# 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 a61b0f6..9f51efd 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(ServerConfigurationEntity cfg,
+ public static ConnectionDescriptor ToDescriptor(ServerConfiguration cfg,
string? applicationName = null,
int? timeoutSeconds = null,
IReadOnlyDictionary? extraProperties = null)
@@ -43,11 +43,12 @@ public static class ServerConfigurationMapping
return new ConnectionDescriptor
{
+ Name = cfg.Name,
Hosts = hosts,
Database = string.IsNullOrWhiteSpace(cfg.InitialDatabase) ? null : cfg.InitialDatabase,
- Username = string.IsNullOrWhiteSpace(cfg.UserName) ? null : cfg.UserName,
- Password = string.IsNullOrEmpty(cfg.Password) ? null : cfg.Password,
- SslMode = cfg.SslMode,
+ Username = string.IsNullOrWhiteSpace(cfg.User?.Name) ? null : cfg.User!.Name,
+ Password = string.IsNullOrEmpty(cfg.User?.Password) ? null : cfg.User!.Password,
+ SslMode = cfg.DefaultSslMode,
ApplicationName = applicationName,
TimeoutSeconds = timeoutSeconds,
Properties = props
@@ -60,13 +61,17 @@ 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 ServerConfigurationEntity FromDescriptor(ConnectionDescriptor descriptor, ServerConfigurationEntity? existing = null)
+ public static ServerConfiguration FromDescriptor(ConnectionDescriptor descriptor, ServerConfiguration? existing = null)
{
if (descriptor == null) throw new ArgumentNullException(nameof(descriptor));
- var cfg = existing ?? new ServerConfigurationEntity();
+ var cfg = existing ?? new ServerConfiguration();
+
+ // Name
+ if (!string.IsNullOrWhiteSpace(descriptor.Name))
+ cfg.Name = descriptor.Name!;
// Host/Port: take first
- if (descriptor.Hosts.Count > 0)
+ if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var h = descriptor.Hosts[0];
if (!string.IsNullOrWhiteSpace(h.Host))
@@ -81,35 +86,17 @@ public static class ServerConfigurationMapping
// SSL Mode
if (descriptor.SslMode.HasValue)
- cfg.SslMode = descriptor.SslMode.Value;
+ cfg.DefaultSslMode = descriptor.SslMode.Value;
// User
+ if (cfg.User == null)
+ cfg.User = new ServerUser();
if (!string.IsNullOrWhiteSpace(descriptor.Username))
- cfg.UserName = descriptor.Username!;
+ cfg.User.Name = descriptor.Username!;
if (!string.IsNullOrEmpty(descriptor.Password))
- cfg.Password = descriptor.Password!;
+ cfg.User.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 42108fe..2d27905 100644
--- a/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
+++ b/pgLabII/ViewModels/EditServerConfigurationViewModel.cs
@@ -1,176 +1,33 @@
-using System;
-using System.Linq;
-using System.Reactive;
-using System.Reactive.Linq;
-using Npgsql;
+using System.Reactive;
using pgLabII.Model;
-using pgLabII.PgUtils.ConnectionStrings;
-using pgLabII.Services;
using ReactiveUI;
namespace pgLabII.ViewModels;
public class EditServerConfigurationViewModel : ViewModelBase
{
- // 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 ServerConfiguration Configuration { get; set; }
public ReactiveCommand SaveAndCloseCommand { get; }
public ReactiveCommand CloseCommand { get; }
- private readonly IConnectionStringService _service;
-
public EditServerConfigurationViewModel()
{
- Configuration = new(new ServerConfigurationEntity());
- _service = ConnectionStringService.CreateDefault();
+ Configuration = new();
- ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
- GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
- CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
-
- SaveAndCloseCommand = ReactiveCommand.Create(() => { });
- CloseCommand = ReactiveCommand.Create(() => { });
+ SaveAndCloseCommand = ReactiveCommand.Create(() =>
+ {
+ });
+ CloseCommand = ReactiveCommand.Create(() =>
+ {
+ });
}
- public EditServerConfigurationViewModel(ServerConfigurationViewModel configuration)
+ public EditServerConfigurationViewModel(ServerConfiguration 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 8968d48..a2a1854 100644
--- a/pgLabII/ViewModels/QueryToolViewModel.cs
+++ b/pgLabII/ViewModels/QueryToolViewModel.cs
@@ -1,205 +1,22 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Reactive;
-using System.Threading.Tasks;
-using Npgsql;
+using System.Reactive;
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
{
- private readonly ServerConfigurationEntity? _serverConfig;
+ [Reactive] private string _caption = "Cap";
- // Tab caption
- [Reactive] private string _caption = "Query";
+ [Reactive] private string _query = "";
- // 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 ReactiveCommand EditCommand { get; }
- // 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